Skip to content

Commit e837d42

Browse files
authored
Merge pull request #21781 from mvanhorn/osc/21482-feat-cask-shell-completions
cask: add generate_completions_from_executable DSL artifact
2 parents a7fc924 + 7276dd4 commit e837d42

File tree

8 files changed

+456
-61
lines changed

8 files changed

+456
-61
lines changed

Library/Homebrew/cask/artifact.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
require "cask/artifact/screen_saver"
2525
require "cask/artifact/bashcompletion"
2626
require "cask/artifact/fishcompletion"
27+
require "cask/artifact/generated_completion"
2728
require "cask/artifact/zshcompletion"
2829
require "cask/artifact/service"
2930
require "cask/artifact/stage_only"
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "cask/artifact/abstract_artifact"
5+
require "cask/artifact/bashcompletion"
6+
require "cask/artifact/fishcompletion"
7+
require "cask/artifact/zshcompletion"
8+
require "extend/hash/keys"
9+
require "utils/shell_completion"
10+
11+
module Cask
12+
module Artifact
13+
# Artifact corresponding to the `generate_completions_from_executable` stanza.
14+
class GeneratedCompletion < AbstractArtifact
15+
SUPPORTED_SHELLS = T.let([:bash, :zsh, :fish, :pwsh].freeze, T::Array[Symbol])
16+
17+
sig { override.returns(Symbol) }
18+
def self.dsl_key
19+
:generate_completions_from_executable
20+
end
21+
22+
sig {
23+
params(
24+
cask: Cask,
25+
args: T.any(Pathname, String),
26+
base_name: T.nilable(String),
27+
shell_parameter_format: T.nilable(T.any(Symbol, String)),
28+
shells: T.nilable(T::Array[Symbol]),
29+
).returns(T.attached_class)
30+
}
31+
def self.from_args(cask, *args, base_name: nil, shell_parameter_format: nil, shells: nil)
32+
raise CaskInvalidError.new(cask.token, "'#{dsl_key}' requires at least one command") if args.empty?
33+
34+
commands = args.to_a
35+
resolved_shells = shells || ::Utils::ShellCompletion.default_completion_shells(shell_parameter_format)
36+
37+
unsupported_shells = resolved_shells - SUPPORTED_SHELLS
38+
unless unsupported_shells.empty?
39+
raise CaskInvalidError.new(
40+
cask.token,
41+
"'#{dsl_key}' does not support shell(s): #{unsupported_shells.join(", ")}",
42+
)
43+
end
44+
45+
new(
46+
cask,
47+
commands,
48+
base_name:,
49+
shell_parameter_format:,
50+
shells: resolved_shells,
51+
)
52+
end
53+
54+
sig {
55+
params(
56+
cask: Cask,
57+
commands: T::Array[T.any(Pathname, String)],
58+
base_name: T.nilable(String),
59+
shell_parameter_format: T.nilable(T.any(Symbol, String)),
60+
shells: T::Array[Symbol],
61+
).void
62+
}
63+
def initialize(cask, commands, base_name:, shell_parameter_format:, shells:)
64+
super(cask, *commands, base_name:, shell_parameter_format:, shells:)
65+
66+
@commands = T.let(commands, T::Array[T.any(Pathname, String)])
67+
@base_name = T.let(base_name, T.nilable(String))
68+
@shell_parameter_format = T.let(shell_parameter_format, T.nilable(T.any(Symbol, String)))
69+
@shells = T.let(shells, T::Array[Symbol])
70+
@resolved_base_name = T.let(nil, T.nilable(String))
71+
end
72+
73+
sig { returns(T::Array[T.any(Pathname, String)]) }
74+
attr_reader :commands
75+
76+
sig { returns(T.nilable(String)) }
77+
attr_reader :base_name
78+
79+
sig { returns(T.nilable(T.any(Symbol, String))) }
80+
attr_reader :shell_parameter_format
81+
82+
sig { returns(T::Array[Symbol]) }
83+
attr_reader :shells
84+
85+
sig { override.returns(String) }
86+
def summarize
87+
"#{commands.join(" ")} (base_name: #{resolved_base_name}, shells: #{shells.join(", ")})"
88+
end
89+
90+
sig { params(_options: T.untyped).void }
91+
def install_phase(**_options)
92+
executable = staged_path_join_executable(T.must(commands.first))
93+
94+
shells.each do |shell|
95+
popen_read_env = { "SHELL" => shell.to_s }
96+
shell_parameter = ::Utils::ShellCompletion.completion_shell_parameter(
97+
shell_parameter_format, shell, executable.to_s, popen_read_env
98+
)
99+
100+
script_path = completion_script_path(shell)
101+
script_path.dirname.mkpath
102+
script_path.write(::Utils::ShellCompletion.generate_completion_output(
103+
[executable, *commands[1..]],
104+
shell_parameter,
105+
popen_read_env,
106+
))
107+
rescue => e
108+
opoo "Failed to generate #{shell} completions from #{executable}: #{e}"
109+
end
110+
end
111+
112+
sig { params(command: T.class_of(SystemCommand), _options: T.untyped).void }
113+
def uninstall_phase(command: SystemCommand, **_options)
114+
shells.each do |shell|
115+
path = completion_script_path(shell)
116+
next unless path.exist?
117+
118+
Utils.gain_permissions_remove(path, command:)
119+
rescue => e
120+
opoo "Failed to remove #{shell} generated completions: #{e}"
121+
end
122+
end
123+
124+
private
125+
126+
sig { returns(String) }
127+
def resolved_base_name
128+
@resolved_base_name ||= T.let(begin
129+
executable = staged_path_join_executable(T.must(commands.first))
130+
name = base_name || File.basename(executable.to_s)
131+
name = cask.token if name.empty?
132+
name
133+
end, T.nilable(String))
134+
@resolved_base_name
135+
end
136+
137+
sig { params(shell: Symbol).returns(Pathname) }
138+
def completion_script_path(shell)
139+
case shell
140+
when :bash
141+
BashCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name)
142+
when :zsh
143+
ZshCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name)
144+
when :fish
145+
FishCompletion.new(cask, resolved_base_name).resolve_target(resolved_base_name)
146+
when :pwsh
147+
HOMEBREW_PREFIX/"share/pwsh/completions"/"_#{resolved_base_name}.ps1"
148+
else
149+
raise ArgumentError, "unsupported shell: #{shell}"
150+
end
151+
end
152+
end
153+
end
154+
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

Library/Homebrew/cask/installer.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ def install_artifacts(predecessor: nil)
333333
artifact,
334334
T.any(
335335
Artifact::AbstractFlightBlock,
336+
Artifact::GeneratedCompletion,
336337
Artifact::Installer,
337338
Artifact::KeyboardLayout,
338339
Artifact::Mdimporter,
@@ -600,6 +601,7 @@ def uninstall_artifacts(clear: false, successor: nil)
600601
artifact,
601602
T.any(
602603
Artifact::AbstractFlightBlock,
604+
Artifact::GeneratedCompletion,
603605
Artifact::KeyboardLayout,
604606
Artifact::Moved,
605607
Artifact::Qlplugin,

Library/Homebrew/formula.rb

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
require "utils/inreplace"
1515
require "utils/shebang"
1616
require "utils/shell"
17+
require "utils/shell_completion"
1718
require "utils/git_repository"
1819
require "build_environment"
1920
require "build_options"
@@ -2407,7 +2408,7 @@ def extract_macho_slice_from(file, arch = Hardware::CPU.arch)
24072408
def generate_completions_from_executable(*commands,
24082409
base_name: nil,
24092410
shell_parameter_format: nil,
2410-
shells: default_completion_shells(shell_parameter_format))
2411+
shells: Utils::ShellCompletion.default_completion_shells(shell_parameter_format))
24112412
executable = commands.first.to_s
24122413
base_name ||= File.basename(executable) if executable.start_with?(bin.to_s, sbin.to_s)
24132414
base_name ||= name
@@ -2423,75 +2424,17 @@ def generate_completions_from_executable(*commands,
24232424
popen_read_env = { "SHELL" => shell.to_s }
24242425
script_path = completion_script_path_map[shell]
24252426

2426-
shell_parameter = completion_shell_parameter(
2427+
shell_parameter = Utils::ShellCompletion.completion_shell_parameter(
24272428
shell_parameter_format,
24282429
shell,
24292430
executable,
24302431
popen_read_env,
24312432
)
24322433

2433-
popen_read_args = %w[]
2434-
popen_read_args << commands
2435-
popen_read_args << shell_parameter if shell_parameter.present?
2436-
popen_read_args.flatten!
2437-
2438-
popen_read_options = {}
2439-
popen_read_options[:err] = :err unless ENV["HOMEBREW_STDERR"]
2440-
24412434
script_path.dirname.mkpath
2442-
script_path.write Utils.safe_popen_read(popen_read_env, *popen_read_args, **popen_read_options)
2443-
end
2444-
end
2445-
2446-
sig { params(format: T.nilable(T.any(Symbol, String))).returns(T::Array[Symbol]) }
2447-
def default_completion_shells(format)
2448-
case format
2449-
when :cobra, :typer
2450-
[:bash, :zsh, :fish, :pwsh]
2451-
else
2452-
[:bash, :zsh, :fish]
2453-
end
2454-
end
2455-
private :default_completion_shells
2456-
2457-
sig {
2458-
params(
2459-
format: T.nilable(T.any(Symbol, String)),
2460-
shell: Symbol,
2461-
executable: String,
2462-
env: T::Hash[String, String],
2463-
).returns(T.nilable(T.any(String, T::Array[String])))
2464-
}
2465-
def completion_shell_parameter(format, shell, executable, env)
2466-
# Go's cobra and Rust's clap accept "powershell".
2467-
shell_parameter = (shell == :pwsh) ? "powershell" : shell.to_s
2468-
2469-
case format
2470-
when nil
2471-
shell_parameter
2472-
when :arg
2473-
"--shell=#{shell_parameter}"
2474-
when :clap
2475-
env["COMPLETE"] = shell_parameter
2476-
nil
2477-
when :click
2478-
prog_name = File.basename(executable).upcase.tr("-", "_")
2479-
env["_#{prog_name}_COMPLETE"] = "#{shell_parameter}_source"
2480-
nil
2481-
when :cobra
2482-
["completion", shell_parameter]
2483-
when :flag
2484-
"--#{shell_parameter}"
2485-
when :none
2486-
nil
2487-
when :typer
2488-
env["_TYPER_COMPLETE_TEST_DISABLE_SHELL_DETECTION"] = "1"
2489-
["--show-completion", shell_parameter]
2490-
else
2491-
"#{format}#{shell}"
2435+
script_path.write Utils::ShellCompletion.generate_completion_output(commands, shell_parameter, popen_read_env)
24922436
end
24932437
end
2494-
private :completion_shell_parameter
24952438

24962439
# An array of all core {Formula} names.
24972440
sig { returns(T::Array[String]) }
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe Cask::Artifact::GeneratedCompletion, :cask do
4+
let(:staged_path) { Pathname(Dir.mktmpdir) }
5+
6+
let(:cask) do
7+
tmp_staged = staged_path
8+
Cask::Cask.new("test-generated-completion") do
9+
version "1.0"
10+
sha256 :no_check
11+
url "file:///dev/null"
12+
generate_completions_from_executable "bin/foo", "completions"
13+
instance_variable_set(:@staged_path, tmp_staged)
14+
end
15+
end
16+
17+
let(:bash_dir) { cask.config.bash_completion }
18+
let(:zsh_dir) { cask.config.zsh_completion }
19+
let(:fish_dir) { cask.config.fish_completion }
20+
21+
before do
22+
(staged_path/"bin").mkpath
23+
(staged_path/"bin/foo").write("#!/bin/sh\necho \"$SHELL completion\"")
24+
(staged_path/"bin/foo").chmod(0755)
25+
end
26+
27+
after do
28+
FileUtils.rm_rf(staged_path)
29+
end
30+
31+
describe "#install_phase" do
32+
it "generates completion scripts for default shells" do
33+
artifact = cask.artifacts.grep(described_class).first
34+
35+
allow(Utils).to receive(:safe_popen_read) do |env, *_args, **_opts|
36+
"#{env.fetch("SHELL")} completion output"
37+
end
38+
39+
artifact.install_phase
40+
41+
expect(bash_dir/"foo").to be_a_file
42+
expect((bash_dir/"foo").read).to eq("bash completion output")
43+
expect(zsh_dir/"_foo").to be_a_file
44+
expect((zsh_dir/"_foo").read).to eq("zsh completion output")
45+
expect(fish_dir/"foo.fish").to be_a_file
46+
expect((fish_dir/"foo.fish").read).to eq("fish completion output")
47+
end
48+
49+
context "when generation fails for one shell" do
50+
it "warns and continues generating other shells" do
51+
artifact = cask.artifacts.grep(described_class).first
52+
53+
allow(Utils).to receive(:safe_popen_read) do |env, *_args, **_opts|
54+
raise "boom" if env.fetch("SHELL") == "bash"
55+
56+
"zsh completion"
57+
end
58+
59+
expect { artifact.install_phase }
60+
.to output(/Failed to generate bash completions/).to_stderr
61+
62+
expect(zsh_dir/"_foo").to be_a_file
63+
end
64+
end
65+
end
66+
67+
describe "#uninstall_phase" do
68+
it "removes generated completion scripts" do
69+
artifact = cask.artifacts.grep(described_class).first
70+
71+
bash_dir.mkpath
72+
zsh_dir.mkpath
73+
fish_dir.mkpath
74+
(bash_dir/"foo").write("bash")
75+
(zsh_dir/"_foo").write("zsh")
76+
(fish_dir/"foo.fish").write("fish")
77+
78+
artifact.uninstall_phase(command: NeverSudoSystemCommand)
79+
80+
expect(bash_dir/"foo").not_to exist
81+
expect(zsh_dir/"_foo").not_to exist
82+
expect(fish_dir/"foo.fish").not_to exist
83+
end
84+
end
85+
86+
context "with specific shells and format" do
87+
let(:cask) do
88+
tmp_staged = staged_path
89+
Cask::Cask.new("test-generated-completion") do
90+
version "1.0"
91+
sha256 :no_check
92+
url "file:///dev/null"
93+
generate_completions_from_executable "bin/foo", "completions",
94+
shells: [:zsh], shell_parameter_format: :arg, base_name: "bar"
95+
instance_variable_set(:@staged_path, tmp_staged)
96+
end
97+
end
98+
99+
it "generates only for the specified shell with the correct format" do
100+
artifact = cask.artifacts.grep(described_class).first
101+
captured_args = nil
102+
103+
allow(Utils).to receive(:safe_popen_read) do |_env, *args, **_opts|
104+
captured_args = args
105+
"zsh completion"
106+
end
107+
108+
artifact.install_phase
109+
110+
expect(captured_args).to include("--shell=zsh")
111+
expect(zsh_dir/"_bar").to be_a_file
112+
expect(bash_dir/"bar").not_to exist
113+
expect(fish_dir/"bar.fish").not_to exist
114+
end
115+
end
116+
end

0 commit comments

Comments
 (0)