Skip to content

Commit 4459b13

Browse files
committed
Add completions_from_executable artifact for Casks
Adds a new DSL stanza for casks to generate shell completions by running the installed executable: cask "mycli" do binary "mycli" completions_from_executable "mycli", shell_parameter_format: :cobra end This works as a proper artifact (like `binary`, `app`, etc.) that: - Automatically generates completions for bash, zsh, fish (and pwsh with :cobra/:typer) - Automatically removes generated files on uninstall - Supports all shell_parameter_format options from Formula Closes #18092 Made-with: Cursor
1 parent 0ac3954 commit 4459b13

4 files changed

Lines changed: 317 additions & 0 deletions

File tree

Library/Homebrew/cask/artifact.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
require "cask/artifact/bashcompletion"
2626
require "cask/artifact/fishcompletion"
2727
require "cask/artifact/zshcompletion"
28+
require "cask/artifact/generated_completion"
2829
require "cask/artifact/service"
2930
require "cask/artifact/stage_only"
3031
require "cask/artifact/suite"
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# typed: true
2+
# frozen_string_literal: true
3+
4+
require "cask/artifact/abstract_artifact"
5+
require "utils/popen"
6+
7+
module Cask
8+
module Artifact
9+
# Artifact corresponding to the `completions_from_executable` stanza.
10+
#
11+
# Generates shell completions by running the cask's executable.
12+
class GeneratedCompletion < AbstractArtifact
13+
sig { override.returns(String) }
14+
def self.english_name
15+
"Generated Completion"
16+
end
17+
18+
sig { override.returns(Symbol) }
19+
def self.dsl_key
20+
:completions_from_executable
21+
end
22+
23+
sig { returns(Pathname) }
24+
attr_reader :executable
25+
26+
sig { returns(T::Array[String]) }
27+
attr_reader :subcommands
28+
29+
sig { returns(String) }
30+
attr_reader :base_name
31+
32+
sig { returns(T.nilable(T.any(Symbol, String))) }
33+
attr_reader :shell_parameter_format
34+
35+
sig { returns(T::Array[Symbol]) }
36+
attr_reader :shells
37+
38+
sig { params(cask: Cask, executable: T.any(String, Pathname), args: T.untyped).void }
39+
def initialize(cask, executable, *args)
40+
super(cask)
41+
42+
options = args.last.is_a?(Hash) ? args.pop : {}
43+
@subcommands = T.let(args.map(&:to_s), T::Array[String])
44+
@executable = T.let(staged_path_join_executable(executable), Pathname)
45+
@shell_parameter_format = T.let(options[:shell_parameter_format], T.nilable(T.any(Symbol, String)))
46+
@base_name = T.let(options[:base_name] || cask.token, String)
47+
@shells = T.let(
48+
options[:shells] || default_completion_shells(@shell_parameter_format),
49+
T::Array[Symbol],
50+
)
51+
end
52+
53+
sig { override.returns(String) }
54+
def summarize
55+
"#{executable} generates completions for #{shells.join(", ")}"
56+
end
57+
58+
def install_phase(command: SystemCommand, **_options)
59+
generate_completions(command:)
60+
end
61+
62+
def uninstall_phase(command: SystemCommand, **_options)
63+
remove_completions(command:)
64+
end
65+
66+
private
67+
68+
sig { params(command: T.class_of(SystemCommand)).void }
69+
def generate_completions(command:)
70+
completion_script_path_map = {
71+
bash: config.bash_completion/base_name,
72+
zsh: config.zsh_completion/"_#{base_name}",
73+
fish: config.fish_completion/"#{base_name}.fish",
74+
pwsh: config.pwsh_completion/"_#{base_name}.ps1",
75+
}
76+
77+
shells.each do |shell|
78+
popen_read_env = { "SHELL" => shell.to_s }
79+
script_path = completion_script_path_map[shell]
80+
next if script_path.nil?
81+
82+
shell_parameter = completion_shell_parameter(
83+
shell_parameter_format,
84+
shell,
85+
executable.to_s,
86+
popen_read_env,
87+
)
88+
89+
popen_read_args = []
90+
popen_read_args << executable.to_s
91+
popen_read_args.concat(subcommands)
92+
popen_read_args << shell_parameter if shell_parameter.present?
93+
popen_read_args.flatten!
94+
95+
popen_read_options = {}
96+
popen_read_options[:err] = :err unless ENV["HOMEBREW_STDERR"]
97+
98+
ohai "Generating #{shell} completion for #{base_name}"
99+
script_path.dirname.mkpath
100+
script_path.write ::Utils.safe_popen_read(popen_read_env, *popen_read_args, **popen_read_options)
101+
end
102+
end
103+
104+
sig { params(command: T.class_of(SystemCommand)).void }
105+
def remove_completions(command:)
106+
[
107+
config.bash_completion/base_name,
108+
config.zsh_completion/"_#{base_name}",
109+
config.fish_completion/"#{base_name}.fish",
110+
config.pwsh_completion/"_#{base_name}.ps1",
111+
].each do |path|
112+
next unless path.exist?
113+
114+
ohai "Removing #{path.basename}"
115+
path.delete
116+
end
117+
end
118+
119+
sig { params(format: T.nilable(T.any(Symbol, String))).returns(T::Array[Symbol]) }
120+
def default_completion_shells(format)
121+
case format
122+
when :cobra, :typer
123+
[:bash, :zsh, :fish, :pwsh]
124+
else
125+
[:bash, :zsh, :fish]
126+
end
127+
end
128+
129+
sig {
130+
params(
131+
format: T.nilable(T.any(Symbol, String)),
132+
shell: Symbol,
133+
executable: String,
134+
env: T::Hash[String, String],
135+
).returns(T.nilable(T.any(String, T::Array[String])))
136+
}
137+
def completion_shell_parameter(format, shell, executable, env)
138+
shell_parameter = (shell == :pwsh) ? "powershell" : shell.to_s
139+
140+
case format
141+
when nil
142+
shell_parameter
143+
when :arg
144+
"--shell=#{shell_parameter}"
145+
when :clap
146+
env["COMPLETE"] = shell_parameter
147+
nil
148+
when :click
149+
prog_name = File.basename(executable).upcase.tr("-", "_")
150+
env["_#{prog_name}_COMPLETE"] = "#{shell_parameter}_source"
151+
nil
152+
when :cobra
153+
["completion", shell_parameter]
154+
when :flag
155+
"--#{shell_parameter}"
156+
when :none
157+
nil
158+
when :typer
159+
env["_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION"] = "1"
160+
["--show-completion", shell_parameter]
161+
else
162+
"#{format}#{shell}"
163+
end
164+
end
165+
end
166+
end
167+
end

Library/Homebrew/cask/dsl.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ class DSL
6161
Artifact::ZshCompletion,
6262
Artifact::FishCompletion,
6363
Artifact::BashCompletion,
64+
Artifact::GeneratedCompletion,
6465
Artifact::Uninstall,
6566
Artifact::Zap,
6667
].freeze
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# frozen_string_literal: true
2+
3+
require "cask/artifact/generated_completion"
4+
5+
RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
6+
let(:cask) { Cask::CaskLoader.load(cask_path("basic-cask")) }
7+
let(:config) { cask.config }
8+
9+
let(:executable) do
10+
(mktmpdir/"test-cli").tap do |path|
11+
path.write <<~BASH
12+
#!/bin/bash
13+
case "$1" in
14+
completion)
15+
case "$2" in
16+
bash) echo "# bash completion for test-cli" ;;
17+
zsh) echo "#compdef test-cli" ;;
18+
fish) echo "# fish completion for test-cli" ;;
19+
powershell) echo "# pwsh completion for test-cli" ;;
20+
esac
21+
;;
22+
esac
23+
BASH
24+
FileUtils.chmod "+x", path
25+
end
26+
end
27+
28+
describe "#install_phase" do
29+
subject(:artifact) do
30+
described_class.new(cask, executable, "completion", base_name: "test-cli")
31+
end
32+
33+
after do
34+
[
35+
config.bash_completion/"test-cli",
36+
config.zsh_completion/"_test-cli",
37+
config.fish_completion/"test-cli.fish",
38+
config.pwsh_completion/"_test-cli.ps1",
39+
].each { |p| p.delete if p.exist? }
40+
end
41+
42+
it "generates completion scripts for bash, zsh, and fish by default" do
43+
artifact.install_phase(command: NeverSudoSystemCommand)
44+
45+
expect(config.bash_completion/"test-cli").to be_a_file
46+
expect(config.zsh_completion/"_test-cli").to be_a_file
47+
expect(config.fish_completion/"test-cli.fish").to be_a_file
48+
end
49+
50+
it "generates completion scripts with correct content" do
51+
artifact.install_phase(command: NeverSudoSystemCommand)
52+
53+
expect((config.bash_completion/"test-cli").read).to include("bash completion")
54+
expect((config.zsh_completion/"_test-cli").read).to include("#compdef")
55+
expect((config.fish_completion/"test-cli.fish").read).to include("fish completion")
56+
end
57+
end
58+
59+
describe "#install_phase with :cobra format" do
60+
subject(:artifact) do
61+
described_class.new(cask, executable, base_name: "test-cli", shell_parameter_format: :cobra)
62+
end
63+
64+
after do
65+
[
66+
config.bash_completion/"test-cli",
67+
config.zsh_completion/"_test-cli",
68+
config.fish_completion/"test-cli.fish",
69+
config.pwsh_completion/"_test-cli.ps1",
70+
].each { |p| p.delete if p.exist? }
71+
end
72+
73+
it "generates pwsh completions when :cobra format is used" do
74+
artifact.install_phase(command: NeverSudoSystemCommand)
75+
76+
expect(config.pwsh_completion/"_test-cli.ps1").to be_a_file
77+
expect((config.pwsh_completion/"_test-cli.ps1").read).to include("pwsh completion")
78+
end
79+
end
80+
81+
describe "#install_phase with custom shells" do
82+
subject(:artifact) do
83+
described_class.new(cask, executable, "completion", base_name: "test-cli", shells: [:zsh])
84+
end
85+
86+
after do
87+
(config.zsh_completion/"_test-cli").delete if (config.zsh_completion/"_test-cli").exist?
88+
end
89+
90+
it "respects custom shells parameter" do
91+
artifact.install_phase(command: NeverSudoSystemCommand)
92+
93+
expect(config.bash_completion/"test-cli").not_to exist
94+
expect(config.zsh_completion/"_test-cli").to be_a_file
95+
expect(config.fish_completion/"test-cli.fish").not_to exist
96+
end
97+
end
98+
99+
describe "#uninstall_phase" do
100+
subject(:artifact) do
101+
described_class.new(cask, executable, base_name: base_name)
102+
end
103+
104+
let(:base_name) { "test-removal" }
105+
106+
before do
107+
config.bash_completion.mkpath
108+
config.zsh_completion.mkpath
109+
config.fish_completion.mkpath
110+
config.pwsh_completion.mkpath
111+
112+
(config.bash_completion/base_name).write("bash")
113+
(config.zsh_completion/"_#{base_name}").write("zsh")
114+
(config.fish_completion/"#{base_name}.fish").write("fish")
115+
(config.pwsh_completion/"_#{base_name}.ps1").write("pwsh")
116+
end
117+
118+
it "removes all generated completion files" do
119+
artifact.uninstall_phase(command: NeverSudoSystemCommand)
120+
121+
expect(config.bash_completion/base_name).not_to exist
122+
expect(config.zsh_completion/"_#{base_name}").not_to exist
123+
expect(config.fish_completion/"#{base_name}.fish").not_to exist
124+
expect(config.pwsh_completion/"_#{base_name}.ps1").not_to exist
125+
end
126+
127+
it "does not fail if files do not exist" do
128+
# Remove files first
129+
(config.bash_completion/base_name).delete
130+
(config.zsh_completion/"_#{base_name}").delete
131+
(config.fish_completion/"#{base_name}.fish").delete
132+
(config.pwsh_completion/"_#{base_name}.ps1").delete
133+
134+
expect { artifact.uninstall_phase(command: NeverSudoSystemCommand) }.not_to raise_error
135+
end
136+
end
137+
138+
describe "#summarize" do
139+
subject(:artifact) do
140+
described_class.new(cask, executable, "completion", base_name: "test-cli")
141+
end
142+
143+
it "returns a summary string" do
144+
expect(artifact.summarize).to include("test-cli")
145+
expect(artifact.summarize).to include("bash")
146+
end
147+
end
148+
end

0 commit comments

Comments
 (0)