clamp 1.5.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: 8f2a9c7d404e87023cc8694e18498df5719974cd11e58663df640c08a0f87c3e
4
- data.tar.gz: da2a65496e24eb9abc13eaddc370f31d3e5764a832c3d0a18015dd636e43dbfa
3
+ metadata.gz: d0987845ae98be0a62e6501a780e42f4e73826df831d5d3809e17c8f8d335db2
4
+ data.tar.gz: 07665f169706158283c5e3d5fa9e231d668fa7105b8f4726e46f801679173071
5
5
  SHA512:
6
- metadata.gz: 7318c3498b2e425ecd18395e4ad3d24109e130bac38f9f9406fba97b02b8b78d859374f5e1439a064dff1e4037b2cab2df8210a13a9ea54cac030df8b35e86c8
7
- data.tar.gz: 5f46c6724d06d2fa30425369a1867f0e2199e05997d796142af125dad5369a08c444f5a06fb15c2ad206b8e62afcb582fe1442c36fbe7ce994fa7d8400f71993
6
+ metadata.gz: 1a2b310ce3f8a1e2cd0bb100abf9a1fec7b8dc36ad639f4b2007824136dd77409d460a2305d36fba0c11999c5e8620406fef5326ab749e31505809631671ee0e
7
+ data.tar.gz: 3fbc558438ae5dd809e60562a9b799c034a3bbbf620eeb77a7cec2ca0c8eb4dd35a86330214a94bde6c22a449974527dc480c9d4278774af0dacb0212a6556fe
data/CHANGES.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 1.5.0 (2026-03-03)
4
8
 
5
9
  * Add `--shell-completions` support.
@@ -19,22 +19,25 @@ module Clamp
19
19
  "",
20
20
  takes_value_function,
21
21
  "",
22
- completion_function,
22
+ param_count_function,
23
23
  "",
24
- "complete -F _#{function_name} #{@executable_name}"
24
+ canonical_function,
25
+ "",
26
+ main_function,
27
+ "",
28
+ "complete -F #{completion_function} #{@executable_name}"
25
29
  ].push("").join("\n")
26
30
  end
27
31
 
28
32
  private
29
33
 
30
- def function_name
31
- @executable_name.gsub(/[^a-zA-Z0-9_]/, "_")
34
+ def completion_function
35
+ "_clamp_complete_#{Completion.encode_name(@executable_name)}"
32
36
  end
33
37
 
34
- def completion_function
35
- fn = function_name
38
+ def main_function
36
39
  [
37
- "_#{fn}() {",
40
+ "#{completion_function}() {",
38
41
  " local cur prev",
39
42
  " if type _init_completion &>/dev/null; then",
40
43
  " _init_completion",
@@ -43,13 +46,19 @@ module Clamp
43
46
  ' prev="${COMP_WORDS[COMP_CWORD-1]}"',
44
47
  " fi",
45
48
  "",
46
- " local subcmd",
47
- " subcmd=$(__#{fn}_find_subcmd)",
48
- " if __#{fn}_takes_value \"$prev\" \"$subcmd\"; then",
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",
49
54
  " return",
50
55
  " fi",
51
56
  "",
52
- completions_case("$subcmd"),
57
+ options_case("$subcmd"),
58
+ "",
59
+ ' if [ "$params_remaining" -eq 0 ]; then',
60
+ subcommands_case("$subcmd"),
61
+ " fi",
53
62
  "}",
54
63
  "",
55
64
  find_subcmd_function
@@ -57,54 +66,111 @@ module Clamp
57
66
  end
58
67
 
59
68
  def find_subcmd_function
60
- fn = function_name
61
- subcmds = Completion.collect_subcommand_names(@command_class).join("|")
62
69
  [
63
- "__#{fn}_find_subcmd() {",
64
- " local i=1 word subcmd",
70
+ "#{completion_function}_find_subcmd() {",
71
+ " local i=1 word subcmd skip",
72
+ " skip=$(#{completion_function}_param_count \"\")",
65
73
  ' while [ "$i" -lt "$COMP_CWORD" ]; do',
66
74
  ' word="${COMP_WORDS[$i]}"',
67
75
  ' case "$word" in',
68
76
  " -*)",
69
- " if __#{fn}_takes_value \"$word\" \"$subcmd\"; then",
77
+ " if #{completion_function}_takes_value \"$word\" \"$subcmd\"; then",
70
78
  " ((i++))",
71
79
  " fi",
72
80
  " ;;",
73
81
  " *)",
74
- ' case "$word" in',
75
- " #{subcmds})",
76
- ' if [ -z "$subcmd" ]; then',
77
- ' subcmd="$word"',
78
- " else",
79
- ' subcmd="${subcmd}::${word}"',
80
- " fi",
81
- " ;;",
82
- " esac",
82
+ find_subcmd_match_word,
83
83
  " ;;",
84
84
  " esac",
85
85
  " ((i++))",
86
86
  " done",
87
- ' echo "$subcmd"',
87
+ ' echo "$subcmd $skip"',
88
88
  "}"
89
89
  ].join("\n")
90
90
  end
91
91
 
92
- def completions_case(var)
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)
93
126
  entries = {}
94
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
+
95
131
  path_str = path.map { |sub| sub.names.first }.join("::")
96
- words = Completion.visible_options(cmd).flat_map { |o| Completion.expanded_switches(o) }
97
- cmd.recognised_subcommands.each { |sub| words.concat(sub.names) } if has_children
98
132
  entries[path_str] = words.join(" ")
99
133
  end
100
- lines = [" case \"#{var}\" in"]
134
+ lines = ["#{indent}case \"#{var}\" in"]
101
135
  entries.each do |path, words|
102
136
  pattern = path.empty? ? '""' : "\"#{path}\""
103
- lines << " #{pattern})"
104
- lines << " COMPREPLY=($(compgen -W \"#{words}\" -- \"$cur\"))"
105
- lines << " ;;"
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} ;;"
106
172
  end
107
- lines << " esac"
173
+ lines.push(" *) #{default} ;;", " esac", "}")
108
174
  lines.join("\n")
109
175
  end
110
176
 
@@ -116,7 +182,7 @@ module Clamp
116
182
  .flat_map { |o| Completion.expanded_switches(o) }
117
183
  end
118
184
  lines = [
119
- "__#{function_name}_takes_value() {",
185
+ "#{completion_function}_takes_value() {",
120
186
  ' local option="$1"',
121
187
  ' local subcmd="$2"',
122
188
  ' case "$subcmd" in'
@@ -16,6 +16,7 @@ module Clamp
16
16
  lines << "# Fish completions for #{@executable_name}"
17
17
  lines << "# Generated by Clamp"
18
18
  lines << ""
19
+ helpers = [subcmd_args_function]
19
20
  Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
20
21
  child_names = has_children ? cmd.recognised_subcommands.flat_map(&:names) : []
21
22
  condition = condition_for(path, child_names)
@@ -24,29 +25,77 @@ module Clamp
24
25
  end
25
26
  next unless has_children
26
27
 
27
- cmd.recognised_subcommands.each do |sub|
28
- sub.names.each do |name|
29
- lines << "complete -c #{@executable_name} -f -n '#{condition}' -a #{name} " \
30
- "-d '#{escape(sub.description)}'"
31
- end
32
- end
28
+ subcmd_condition = subcommand_condition(cmd, path, condition, helpers)
29
+ generate_subcommand_completions(lines, cmd, subcmd_condition)
33
30
  lines << ""
34
31
  end
35
- "#{lines.join("\n")}\n"
32
+ "#{helpers.join("\n\n")}\n\n#{lines.join("\n")}\n"
36
33
  end
37
34
 
38
35
  private
39
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
+
40
55
  def condition_for(path, child_names)
41
56
  if path.empty?
42
- "__fish_use_subcommand"
57
+ "not #{completion_function}_subcmd_args >/dev/null"
43
58
  else
44
- parts = path.map { |sub| "__fish_seen_subcommand_from #{sub.names.join(' ')}" }
45
- parts << "not __fish_seen_subcommand_from #{child_names.join(' ')}" if child_names.any?
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?
46
61
  parts.join("; and ")
47
62
  end
48
63
  end
49
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
+
50
99
  def option_completion(option, condition)
51
100
  parts = ["complete -c #{@executable_name} -f"]
52
101
  parts << "-n '#{condition}'"
@@ -71,5 +120,57 @@ module Clamp
71
120
 
72
121
  end
73
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
+
74
175
  end
75
176
  end
@@ -14,27 +14,27 @@ module Clamp
14
14
 
15
15
  def generate
16
16
  lines = ["#compdef #{@executable_name}", ""]
17
- generate_functions(lines, @command_class, [function_name], Set.new)
18
- lines << "_#{function_name}"
17
+ generate_functions(lines, @command_class, [completion_function], Set.new)
18
+ lines << completion_function
19
19
  lines.push("").join("\n")
20
20
  end
21
21
 
22
22
  private
23
23
 
24
- def function_name
25
- @executable_name.gsub(/[^a-zA-Z0-9_]/, "_")
24
+ def completion_function
25
+ "_clamp_complete_#{Completion.encode_name(@executable_name)}"
26
26
  end
27
27
 
28
28
  def generate_functions(lines, command_class, path, visited)
29
29
  has_children = command_class.has_subcommands? && !visited.include?(command_class)
30
30
  visited |= [command_class]
31
- func_name = "_#{path.join('_')}"
31
+ func_name = path.join("_")
32
32
 
33
33
  if has_children
34
34
  generate_subcommand_node(lines, command_class, path, func_name, visited)
35
35
  else
36
36
  lines << "#{func_name}() {"
37
- specs = Completion.visible_options(command_class).map { |o| option_spec(o) }
37
+ specs = Completion.visible_options(command_class).flat_map { |o| option_specs(o) }
38
38
  generate_arguments_call(lines, specs) if specs.any?
39
39
  lines << "}"
40
40
  lines << ""
@@ -46,7 +46,7 @@ module Clamp
46
46
  lines << " local context state state_descr line"
47
47
  lines << " typeset -A opt_args"
48
48
  lines << ""
49
- specs = Completion.visible_options(command_class).map { |o| option_spec(o) }
49
+ specs = Completion.visible_options(command_class).flat_map { |o| option_specs(o) }
50
50
  specs << "'1:command:->commands'"
51
51
  specs << "'*::args:->args'"
52
52
  lines << " _arguments -C \\"
@@ -56,7 +56,7 @@ module Clamp
56
56
  lines << "}"
57
57
  lines << ""
58
58
  command_class.recognised_subcommands.each do |sub|
59
- generate_functions(lines, sub.subcommand_class, path + [sanitize(sub.names.first)], visited)
59
+ generate_functions(lines, sub.subcommand_class, path + [Completion.encode_name(sub.names.first)], visited)
60
60
  end
61
61
  end
62
62
 
@@ -88,7 +88,7 @@ module Clamp
88
88
  lines << " args)"
89
89
  lines << " case $line[1] in"
90
90
  command_class.recognised_subcommands.each do |sub|
91
- sub_fn = "_#{(path + [sanitize(sub.names.first)]).join('_')}"
91
+ sub_fn = (path + [Completion.encode_name(sub.names.first)]).join("_")
92
92
  pattern = sub.names.join("|")
93
93
  lines << " #{pattern}) #{sub_fn} ;;"
94
94
  end
@@ -97,23 +97,20 @@ module Clamp
97
97
  lines << " esac"
98
98
  end
99
99
 
100
- def option_spec(option)
100
+ def option_specs(option)
101
101
  expanded = Completion.expanded_switches(option)
102
- desc = "[#{escape(option.description)}]"
103
- arg_spec = option.flag? ? "" : ":#{option.type.to_s.downcase}:"
104
- exclusion = expanded.length > 1 ? "(#{expanded.join(' ')})" : ""
105
-
106
- "'#{exclusion}#{switch_pattern(option.switches)}#{desc}#{arg_spec}'"
107
- end
108
-
109
- def switch_pattern(switches)
110
- short = switches.find { |s| s =~ /^-[^-]/ }
111
- long = switches.find { |s| s =~ /^--/ }
112
- short && long ? "{#{short},#{long}}" : (long || short).to_s
113
- end
114
-
115
- def sanitize(name)
116
- name.gsub(/[^a-zA-Z0-9_]/, "_")
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
117
114
  end
118
115
 
119
116
  def escape(str)
@@ -33,6 +33,12 @@ module Clamp
33
33
 
34
34
  module_function
35
35
 
36
+ # Encode a name for use as a shell function identifier.
37
+ # Special characters are replaced with _XX hex codes.
38
+ def encode_name(name)
39
+ name.gsub(/[^a-zA-Z0-9_]/) { |c| format("_%02x", c.ord) }
40
+ end
41
+
36
42
  def generate(command_class, shell, executable_name)
37
43
  generator_class = GENERATORS.fetch(shell) do
38
44
  raise ArgumentError, "unsupported shell: #{shell.inspect}"
@@ -71,6 +77,24 @@ module Clamp
71
77
  end
72
78
  end
73
79
 
80
+ # Count required, non-multivalued parameters for a command.
81
+ def required_parameter_count(command_class)
82
+ command_class.parameters.count { |p| p.required? && !p.multivalued? }
83
+ end
84
+
85
+ # Return fish argparse optspecs for an option.
86
+ def argparse_specs_for(option)
87
+ switches = expanded_switches(option)
88
+ suffix = option.flag? ? "" : "="
89
+ short = switches.find { |s| s.match?(/^-[^-]$/) }
90
+ longs = switches.select { |s| s.start_with?("--") }
91
+ if short && longs.length == 1
92
+ ["#{short.delete_prefix('-')}/#{longs.first.delete_prefix('--')}#{suffix}"]
93
+ else
94
+ longs.map { |l| "#{l.delete_prefix('--')}#{suffix}" }
95
+ end
96
+ end
97
+
74
98
  # Collect all subcommand names across the command tree.
75
99
  def collect_subcommand_names(command_class)
76
100
  names = []
data/lib/clamp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clamp
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clamp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Williams