clamp 1.4.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f0997bf253dd4d01c04923a7d5f9dcea232a076605a3129e0b1e90d5514ff6f
4
- data.tar.gz: af6938afdf7acdc0e513b7549ae5479c276b3457f7ced04bf55ad06dc89747a6
3
+ metadata.gz: d0987845ae98be0a62e6501a780e42f4e73826df831d5d3809e17c8f8d335db2
4
+ data.tar.gz: 07665f169706158283c5e3d5fa9e231d668fa7105b8f4726e46f801679173071
5
5
  SHA512:
6
- metadata.gz: 8c8a6136a3e4463c2099381a712ffd1625f4aca300d8d43ca28fc1a7b7b380b95477ba7ba7d5ffab6d89181aedf5e47fe234bcdcb29b358c191b9834a35dfaf6
7
- data.tar.gz: abdede290dd091cc2d26e79c40e2c65486cefd0c5b29918c44c9fdee1e25bf127cfb4744ddcdbcb2488d75cc46bed5e9b06f6de35c922e5a3491b2d1469d7014
6
+ metadata.gz: 1a2b310ce3f8a1e2cd0bb100abf9a1fec7b8dc36ad639f4b2007824136dd77409d460a2305d36fba0c11999c5e8620406fef5326ab749e31505809631671ee0e
7
+ data.tar.gz: 3fbc558438ae5dd809e60562a9b799c034a3bbbf620eeb77a7cec2ca0c8eb4dd35a86330214a94bde6c22a449974527dc480c9d4278774af0dacb0212a6556fe
data/CHANGES.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.1 (2026-03-11)
4
+
5
+ * Fix shell completion scripts: required parameters, subcommand aliases, and option value handling.
6
+
7
+ ## 1.5.0 (2026-03-03)
8
+
9
+ * Add `--shell-completions` support.
10
+
3
11
  ## 1.4.0 (2026-02-09)
4
12
 
5
13
  * Add support for Ruby 4.0.
data/README.md CHANGED
@@ -413,6 +413,50 @@ Options:
413
413
  -h, --help print help
414
414
  ```
415
415
 
416
+ ## Shell completion
417
+
418
+ Clamp can generate shell completion scripts for bash, zsh, and fish. This is an opt-in feature:
419
+
420
+ ```ruby
421
+ require 'clamp/completion'
422
+ ```
423
+
424
+ This adds a hidden `--shell-completions` option to all commands. Use it to generate a completion script:
425
+
426
+ ```sh
427
+ $ myapp --shell-completions bash # or: zsh, fish
428
+ ```
429
+
430
+ ### Activating completions
431
+
432
+ For **bash**, add to your `~/.bashrc`:
433
+
434
+ ```sh
435
+ eval "$(myapp --shell-completions bash)"
436
+ ```
437
+
438
+ For **zsh**, add to your `~/.zshrc`:
439
+
440
+ ```sh
441
+ eval "$(myapp --shell-completions zsh)"
442
+ ```
443
+
444
+ For **fish**, add to your `~/.config/fish/config.fish`:
445
+
446
+ ```sh
447
+ myapp --shell-completions fish | source
448
+ ```
449
+
450
+ ### Programmatic API
451
+
452
+ You can also generate completion scripts programmatically:
453
+
454
+ ```ruby
455
+ script = MyCommand.generate_completion(:fish, "myapp")
456
+ ```
457
+
458
+ This returns the completion script as a string, which you can write to a file or use however you like.
459
+
416
460
  ## Localization
417
461
 
418
462
  Clamp comes with support for overriding strings with custom translations. You can use localization library of your choice and override the strings at startup.
data/examples/gitdown CHANGED
@@ -4,6 +4,7 @@
4
4
  # Demonstrate how subcommands can be declared as classes
5
5
 
6
6
  require "clamp"
7
+ require "clamp/completion"
7
8
 
8
9
  module GitDown
9
10
 
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clamp
4
+ module Completion
5
+
6
+ # Generates bash shell completion scripts.
7
+ #
8
+ class BashGenerator
9
+
10
+ def initialize(command_class, executable_name)
11
+ @command_class = command_class
12
+ @executable_name = executable_name
13
+ end
14
+
15
+ def generate
16
+ [
17
+ "# Bash completions for #{@executable_name}",
18
+ "# Generated by Clamp",
19
+ "",
20
+ takes_value_function,
21
+ "",
22
+ param_count_function,
23
+ "",
24
+ canonical_function,
25
+ "",
26
+ main_function,
27
+ "",
28
+ "complete -F #{completion_function} #{@executable_name}"
29
+ ].push("").join("\n")
30
+ end
31
+
32
+ private
33
+
34
+ def completion_function
35
+ "_clamp_complete_#{Completion.encode_name(@executable_name)}"
36
+ end
37
+
38
+ def main_function
39
+ [
40
+ "#{completion_function}() {",
41
+ " local cur prev",
42
+ " if type _init_completion &>/dev/null; then",
43
+ " _init_completion",
44
+ " else",
45
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
46
+ ' prev="${COMP_WORDS[COMP_CWORD-1]}"',
47
+ " fi",
48
+ "",
49
+ " local subcmd_info subcmd params_remaining",
50
+ " subcmd_info=$(#{completion_function}_find_subcmd)",
51
+ ' subcmd="${subcmd_info%% *}"',
52
+ ' params_remaining="${subcmd_info##* }"',
53
+ " if #{completion_function}_takes_value \"$prev\" \"$subcmd\"; then",
54
+ " return",
55
+ " fi",
56
+ "",
57
+ options_case("$subcmd"),
58
+ "",
59
+ ' if [ "$params_remaining" -eq 0 ]; then',
60
+ subcommands_case("$subcmd"),
61
+ " fi",
62
+ "}",
63
+ "",
64
+ find_subcmd_function
65
+ ].join("\n")
66
+ end
67
+
68
+ def find_subcmd_function
69
+ [
70
+ "#{completion_function}_find_subcmd() {",
71
+ " local i=1 word subcmd skip",
72
+ " skip=$(#{completion_function}_param_count \"\")",
73
+ ' while [ "$i" -lt "$COMP_CWORD" ]; do',
74
+ ' word="${COMP_WORDS[$i]}"',
75
+ ' case "$word" in',
76
+ " -*)",
77
+ " if #{completion_function}_takes_value \"$word\" \"$subcmd\"; then",
78
+ " ((i++))",
79
+ " fi",
80
+ " ;;",
81
+ " *)",
82
+ find_subcmd_match_word,
83
+ " ;;",
84
+ " esac",
85
+ " ((i++))",
86
+ " done",
87
+ ' echo "$subcmd $skip"',
88
+ "}"
89
+ ].join("\n")
90
+ end
91
+
92
+ def find_subcmd_match_word
93
+ subcmds = Completion.collect_subcommand_names(@command_class).join("|")
94
+ [
95
+ ' if [ "$skip" -gt 0 ]; then',
96
+ " ((skip--))",
97
+ " else",
98
+ ' case "$word" in',
99
+ " #{subcmds})",
100
+ " local canonical=$(#{completion_function}_canonical \"$word\")",
101
+ ' if [ -z "$subcmd" ]; then',
102
+ ' subcmd="$canonical"',
103
+ " else",
104
+ ' subcmd="${subcmd}::${canonical}"',
105
+ " fi",
106
+ " skip=$(#{completion_function}_param_count \"$subcmd\")",
107
+ " ;;",
108
+ " esac",
109
+ " fi"
110
+ ].join("\n")
111
+ end
112
+
113
+ def options_case(var)
114
+ build_case(var, " ", "COMPREPLY=") do |cmd, _has_children|
115
+ Completion.visible_options(cmd).flat_map { |o| Completion.expanded_switches(o) }
116
+ end
117
+ end
118
+
119
+ def subcommands_case(var)
120
+ build_case(var, " ", "COMPREPLY+=") do |cmd, has_children|
121
+ cmd.recognised_subcommands.flat_map(&:names) if has_children
122
+ end
123
+ end
124
+
125
+ def build_case(var, indent, assign)
126
+ entries = {}
127
+ Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
128
+ words = yield(cmd, has_children)
129
+ next if words.nil? || words.empty?
130
+
131
+ path_str = path.map { |sub| sub.names.first }.join("::")
132
+ entries[path_str] = words.join(" ")
133
+ end
134
+ lines = ["#{indent}case \"#{var}\" in"]
135
+ entries.each do |path, words|
136
+ pattern = path.empty? ? '""' : "\"#{path}\""
137
+ lines << "#{indent} #{pattern})"
138
+ lines << "#{indent} #{assign}($(compgen -W \"#{words}\" -- \"$cur\"))"
139
+ lines << "#{indent} ;;"
140
+ end
141
+ lines << "#{indent}esac"
142
+ lines.join("\n")
143
+ end
144
+
145
+ def param_count_function
146
+ entries = {}
147
+ Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
148
+ next unless has_children
149
+
150
+ entries[path.map { |s| s.names.first }.join("::")] = Completion.required_parameter_count(cmd)
151
+ end
152
+ build_lookup_function("param_count", entries, "echo", default: "echo 0")
153
+ end
154
+
155
+ def canonical_function
156
+ aliases = {}
157
+ Completion.walk_command_tree(@command_class) do |cmd, _path, has_children|
158
+ next unless has_children
159
+
160
+ cmd.recognised_subcommands.each do |sub|
161
+ sub.names.drop(1).each { |name| aliases[name] = sub.names.first }
162
+ end
163
+ end
164
+ build_lookup_function("canonical", aliases, "echo", default: 'echo "$1"')
165
+ end
166
+
167
+ def build_lookup_function(suffix, entries, verb, default:)
168
+ lines = ["#{completion_function}_#{suffix}() {", ' case "$1" in']
169
+ entries.each do |key, value|
170
+ pattern = key.empty? ? '""' : key
171
+ lines << " #{pattern}) #{verb} #{value.is_a?(String) ? "\"#{value}\"" : value} ;;"
172
+ end
173
+ lines.push(" *) #{default} ;;", " esac", "}")
174
+ lines.join("\n")
175
+ end
176
+
177
+ def takes_value_function
178
+ entries = {}
179
+ Completion.walk_command_tree(@command_class) do |cmd, path, _has_children|
180
+ path_str = path.map { |sub| sub.names.first }.join("::")
181
+ entries[path_str] = Completion.visible_options(cmd).reject(&:flag?)
182
+ .flat_map { |o| Completion.expanded_switches(o) }
183
+ end
184
+ lines = [
185
+ "#{completion_function}_takes_value() {",
186
+ ' local option="$1"',
187
+ ' local subcmd="$2"',
188
+ ' case "$subcmd" in'
189
+ ]
190
+ entries.each do |path, switches|
191
+ next if switches.empty?
192
+
193
+ pattern = path.empty? ? '""' : "\"#{path}\""
194
+ lines << " #{pattern})"
195
+ lines << ' case "$option" in'
196
+ lines << " #{switches.join('|')}) return 0 ;;"
197
+ lines << " esac"
198
+ lines << " ;;"
199
+ end
200
+ lines.push(" esac", " return 1", "}")
201
+ lines.join("\n")
202
+ end
203
+
204
+ end
205
+
206
+ end
207
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clamp
4
+ module Completion
5
+
6
+ # Generates fish shell completion scripts.
7
+ class FishGenerator
8
+
9
+ def initialize(command_class, executable_name)
10
+ @command_class = command_class
11
+ @executable_name = executable_name
12
+ end
13
+
14
+ def generate
15
+ lines = []
16
+ lines << "# Fish completions for #{@executable_name}"
17
+ lines << "# Generated by Clamp"
18
+ lines << ""
19
+ helpers = [subcmd_args_function]
20
+ Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
21
+ child_names = has_children ? cmd.recognised_subcommands.flat_map(&:names) : []
22
+ condition = condition_for(path, child_names)
23
+ Completion.visible_options(cmd).each do |option|
24
+ lines << option_completion(option, condition)
25
+ end
26
+ next unless has_children
27
+
28
+ subcmd_condition = subcommand_condition(cmd, path, condition, helpers)
29
+ generate_subcommand_completions(lines, cmd, subcmd_condition)
30
+ lines << ""
31
+ end
32
+ "#{helpers.join("\n\n")}\n\n#{lines.join("\n")}\n"
33
+ end
34
+
35
+ private
36
+
37
+ def subcommand_condition(cmd, path, condition, helpers)
38
+ param_count = Completion.required_parameter_count(cmd)
39
+ return condition unless param_count.positive?
40
+
41
+ helper = ParamsSatisfiedHelper.new(@executable_name, path, param_count)
42
+ helpers << helper.to_s
43
+ "#{condition}; and #{helper.function_name}"
44
+ end
45
+
46
+ def generate_subcommand_completions(lines, cmd, condition)
47
+ cmd.recognised_subcommands.each do |sub|
48
+ sub.names.each do |name|
49
+ lines << "complete -c #{@executable_name} -f -n '#{condition}' -a #{name} " \
50
+ "-d '#{escape(sub.description)}'"
51
+ end
52
+ end
53
+ end
54
+
55
+ def condition_for(path, child_names)
56
+ if path.empty?
57
+ "not #{completion_function}_subcmd_args >/dev/null"
58
+ else
59
+ parts = path.map { |sub| "#{completion_function}_seen_subcommand_from #{sub.names.join(' ')}" }
60
+ parts << "not #{completion_function}_seen_subcommand_from #{child_names.join(' ')}" if child_names.any?
61
+ parts.join("; and ")
62
+ end
63
+ end
64
+
65
+ def completion_function
66
+ "_clamp_complete_#{@executable_name}"
67
+ end
68
+
69
+ def subcmd_args_function
70
+ optspecs = argparse_optspecs(@command_class)
71
+ lines = [
72
+ "function #{completion_function}_subcmd_args",
73
+ " set -l tokens (commandline -opc)",
74
+ " set -e tokens[1]",
75
+ " argparse -si #{optspecs.map { |s| "'#{s}'" }.join(' ')} -- $tokens 2>/dev/null",
76
+ " or return 1",
77
+ " test (count $argv) -gt 0",
78
+ " and printf '%s\\n' $argv",
79
+ "end",
80
+ "",
81
+ "function #{completion_function}_seen_subcommand_from",
82
+ " for p in (#{completion_function}_subcmd_args)",
83
+ " if contains -- $p $argv",
84
+ " return 0",
85
+ " end",
86
+ " end",
87
+ " return 1",
88
+ "end"
89
+ ]
90
+ lines.join("\n")
91
+ end
92
+
93
+ def argparse_optspecs(command_class)
94
+ command_class.recognised_options.flat_map do |option|
95
+ Completion.argparse_specs_for(option)
96
+ end
97
+ end
98
+
99
+ def option_completion(option, condition)
100
+ parts = ["complete -c #{@executable_name} -f"]
101
+ parts << "-n '#{condition}'"
102
+
103
+ Completion.expanded_switches(option).each do |switch|
104
+ parts << if switch.start_with?("--")
105
+ "-l #{switch.sub(/^--/, '')}"
106
+ else
107
+ "-s #{switch.sub(/^-/, '')}"
108
+ end
109
+ end
110
+
111
+ parts << "-r" unless option.flag?
112
+ parts << "-d '#{escape(option.description)}'"
113
+
114
+ parts.join(" ")
115
+ end
116
+
117
+ def escape(str)
118
+ str.gsub("'", "\\\\'")
119
+ end
120
+
121
+ end
122
+
123
+ # Generates a fish function that checks whether required parameters
124
+ # have been provided before offering subcommand completions.
125
+ class ParamsSatisfiedHelper
126
+
127
+ def initialize(executable_name, path, required_count)
128
+ @executable_name = executable_name
129
+ @path = path
130
+ @required_count = required_count
131
+ end
132
+
133
+ def function_name
134
+ parts = [@executable_name]
135
+ @path.each { |sub| parts << sub.names.first }
136
+ "_clamp_complete_#{parts.join('_')}_params_satisfied"
137
+ end
138
+
139
+ def to_s
140
+ lines = ["function #{function_name}", " set -l tokens (commandline -opc)", " set -l positional 0"]
141
+ if @path.empty?
142
+ count_positional_root(lines)
143
+ else
144
+ count_positional_nested(lines)
145
+ end
146
+ lines.push(" test $positional -ge #{@required_count}", "end")
147
+ lines.join("\n")
148
+ end
149
+
150
+ private
151
+
152
+ def count_positional_root(lines)
153
+ lines.push(" for token in $tokens[2..]",
154
+ " if not string match -q -- '-*' $token",
155
+ " set positional (math $positional + 1)",
156
+ " end", " end")
157
+ end
158
+
159
+ def count_positional_nested(lines)
160
+ lines.push(" set -l depth 0", " for token in $tokens[2..]", " switch $depth")
161
+ @path.each_with_index do |sub, i|
162
+ all_names = sub.names.join(" ")
163
+ lines.push(" case #{i}",
164
+ " if contains -- $token #{all_names}",
165
+ " set depth #{i + 1}", " end")
166
+ end
167
+ lines.push(" case #{@path.length}",
168
+ " if not string match -q -- '-*' $token",
169
+ " set positional (math $positional + 1)",
170
+ " end", " end", " end")
171
+ end
172
+
173
+ end
174
+
175
+ end
176
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clamp
4
+ module Completion
5
+
6
+ # Generates zsh shell completion scripts.
7
+ #
8
+ class ZshGenerator
9
+
10
+ def initialize(command_class, executable_name)
11
+ @command_class = command_class
12
+ @executable_name = executable_name
13
+ end
14
+
15
+ def generate
16
+ lines = ["#compdef #{@executable_name}", ""]
17
+ generate_functions(lines, @command_class, [completion_function], Set.new)
18
+ lines << completion_function
19
+ lines.push("").join("\n")
20
+ end
21
+
22
+ private
23
+
24
+ def completion_function
25
+ "_clamp_complete_#{Completion.encode_name(@executable_name)}"
26
+ end
27
+
28
+ def generate_functions(lines, command_class, path, visited)
29
+ has_children = command_class.has_subcommands? && !visited.include?(command_class)
30
+ visited |= [command_class]
31
+ func_name = path.join("_")
32
+
33
+ if has_children
34
+ generate_subcommand_node(lines, command_class, path, func_name, visited)
35
+ else
36
+ lines << "#{func_name}() {"
37
+ specs = Completion.visible_options(command_class).flat_map { |o| option_specs(o) }
38
+ generate_arguments_call(lines, specs) if specs.any?
39
+ lines << "}"
40
+ lines << ""
41
+ end
42
+ end
43
+
44
+ def generate_subcommand_node(lines, command_class, path, func_name, visited)
45
+ lines << "#{func_name}() {"
46
+ lines << " local context state state_descr line"
47
+ lines << " typeset -A opt_args"
48
+ lines << ""
49
+ specs = Completion.visible_options(command_class).flat_map { |o| option_specs(o) }
50
+ specs << "'1:command:->commands'"
51
+ specs << "'*::args:->args'"
52
+ lines << " _arguments -C \\"
53
+ generate_spec_lines(lines, specs)
54
+ lines << ""
55
+ generate_state_dispatch(lines, command_class, path)
56
+ lines << "}"
57
+ lines << ""
58
+ command_class.recognised_subcommands.each do |sub|
59
+ generate_functions(lines, sub.subcommand_class, path + [Completion.encode_name(sub.names.first)], visited)
60
+ end
61
+ end
62
+
63
+ def generate_arguments_call(lines, specs)
64
+ lines << " _arguments \\"
65
+ generate_spec_lines(lines, specs)
66
+ end
67
+
68
+ def generate_spec_lines(lines, specs)
69
+ specs.each_with_index do |spec, i|
70
+ suffix = i < specs.length - 1 ? " \\" : ""
71
+ lines << " #{spec}#{suffix}"
72
+ end
73
+ end
74
+
75
+ def generate_state_dispatch(lines, command_class, path)
76
+ lines << " case $state in"
77
+ lines << " commands)"
78
+ lines << " local -a cmds"
79
+ lines << " cmds=("
80
+ command_class.recognised_subcommands.each do |sub|
81
+ sub.names.each do |name|
82
+ lines << " '#{escape(name)}:#{escape(sub.description)}'"
83
+ end
84
+ end
85
+ lines << " )"
86
+ lines << " _describe 'command' cmds"
87
+ lines << " ;;"
88
+ lines << " args)"
89
+ lines << " case $line[1] in"
90
+ command_class.recognised_subcommands.each do |sub|
91
+ sub_fn = (path + [Completion.encode_name(sub.names.first)]).join("_")
92
+ pattern = sub.names.join("|")
93
+ lines << " #{pattern}) #{sub_fn} ;;"
94
+ end
95
+ lines << " esac"
96
+ lines << " ;;"
97
+ lines << " esac"
98
+ end
99
+
100
+ def option_specs(option)
101
+ expanded = Completion.expanded_switches(option)
102
+ suffix = "[#{escape(option.description)}]"
103
+ suffix += ":#{option.type.to_s.downcase}:" unless option.flag?
104
+ exclusion = expanded.length > 1 ? "'(#{expanded.join(' ')})'" : ""
105
+ short = expanded.find { |s| s.match?(/^-[^-]/) }
106
+ longs = expanded.grep(/^--/)
107
+
108
+ if short && longs.length == 1
109
+ # Braces outside quotes for zsh brace expansion
110
+ ["#{exclusion}{#{short},#{longs.first}}'#{suffix}'"]
111
+ else
112
+ expanded.map { |sw| "#{exclusion}'#{sw}#{suffix}'" }
113
+ end
114
+ end
115
+
116
+ def escape(str)
117
+ str.gsub("'", "'\\''")
118
+ end
119
+
120
+ end
121
+
122
+ end
123
+ end