clamp 1.4.0 → 1.5.0

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: 8f2a9c7d404e87023cc8694e18498df5719974cd11e58663df640c08a0f87c3e
4
+ data.tar.gz: da2a65496e24eb9abc13eaddc370f31d3e5764a832c3d0a18015dd636e43dbfa
5
5
  SHA512:
6
- metadata.gz: 8c8a6136a3e4463c2099381a712ffd1625f4aca300d8d43ca28fc1a7b7b380b95477ba7ba7d5ffab6d89181aedf5e47fe234bcdcb29b358c191b9834a35dfaf6
7
- data.tar.gz: abdede290dd091cc2d26e79c40e2c65486cefd0c5b29918c44c9fdee1e25bf127cfb4744ddcdbcb2488d75cc46bed5e9b06f6de35c922e5a3491b2d1469d7014
6
+ metadata.gz: 7318c3498b2e425ecd18395e4ad3d24109e130bac38f9f9406fba97b02b8b78d859374f5e1439a064dff1e4037b2cab2df8210a13a9ea54cac030df8b35e86c8
7
+ data.tar.gz: 5f46c6724d06d2fa30425369a1867f0e2199e05997d796142af125dad5369a08c444f5a06fb15c2ad206b8e62afcb582fe1442c36fbe7ce994fa7d8400f71993
data/CHANGES.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0 (2026-03-03)
4
+
5
+ * Add `--shell-completions` support.
6
+
3
7
  ## 1.4.0 (2026-02-09)
4
8
 
5
9
  * 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,141 @@
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
+ completion_function,
23
+ "",
24
+ "complete -F _#{function_name} #{@executable_name}"
25
+ ].push("").join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def function_name
31
+ @executable_name.gsub(/[^a-zA-Z0-9_]/, "_")
32
+ end
33
+
34
+ def completion_function
35
+ fn = function_name
36
+ [
37
+ "_#{fn}() {",
38
+ " local cur prev",
39
+ " if type _init_completion &>/dev/null; then",
40
+ " _init_completion",
41
+ " else",
42
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
43
+ ' prev="${COMP_WORDS[COMP_CWORD-1]}"',
44
+ " fi",
45
+ "",
46
+ " local subcmd",
47
+ " subcmd=$(__#{fn}_find_subcmd)",
48
+ " if __#{fn}_takes_value \"$prev\" \"$subcmd\"; then",
49
+ " return",
50
+ " fi",
51
+ "",
52
+ completions_case("$subcmd"),
53
+ "}",
54
+ "",
55
+ find_subcmd_function
56
+ ].join("\n")
57
+ end
58
+
59
+ def find_subcmd_function
60
+ fn = function_name
61
+ subcmds = Completion.collect_subcommand_names(@command_class).join("|")
62
+ [
63
+ "__#{fn}_find_subcmd() {",
64
+ " local i=1 word subcmd",
65
+ ' while [ "$i" -lt "$COMP_CWORD" ]; do',
66
+ ' word="${COMP_WORDS[$i]}"',
67
+ ' case "$word" in',
68
+ " -*)",
69
+ " if __#{fn}_takes_value \"$word\" \"$subcmd\"; then",
70
+ " ((i++))",
71
+ " fi",
72
+ " ;;",
73
+ " *)",
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",
83
+ " ;;",
84
+ " esac",
85
+ " ((i++))",
86
+ " done",
87
+ ' echo "$subcmd"',
88
+ "}"
89
+ ].join("\n")
90
+ end
91
+
92
+ def completions_case(var)
93
+ entries = {}
94
+ Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
95
+ 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
+ entries[path_str] = words.join(" ")
99
+ end
100
+ lines = [" case \"#{var}\" in"]
101
+ entries.each do |path, words|
102
+ pattern = path.empty? ? '""' : "\"#{path}\""
103
+ lines << " #{pattern})"
104
+ lines << " COMPREPLY=($(compgen -W \"#{words}\" -- \"$cur\"))"
105
+ lines << " ;;"
106
+ end
107
+ lines << " esac"
108
+ lines.join("\n")
109
+ end
110
+
111
+ def takes_value_function
112
+ entries = {}
113
+ Completion.walk_command_tree(@command_class) do |cmd, path, _has_children|
114
+ path_str = path.map { |sub| sub.names.first }.join("::")
115
+ entries[path_str] = Completion.visible_options(cmd).reject(&:flag?)
116
+ .flat_map { |o| Completion.expanded_switches(o) }
117
+ end
118
+ lines = [
119
+ "__#{function_name}_takes_value() {",
120
+ ' local option="$1"',
121
+ ' local subcmd="$2"',
122
+ ' case "$subcmd" in'
123
+ ]
124
+ entries.each do |path, switches|
125
+ next if switches.empty?
126
+
127
+ pattern = path.empty? ? '""' : "\"#{path}\""
128
+ lines << " #{pattern})"
129
+ lines << ' case "$option" in'
130
+ lines << " #{switches.join('|')}) return 0 ;;"
131
+ lines << " esac"
132
+ lines << " ;;"
133
+ end
134
+ lines.push(" esac", " return 1", "}")
135
+ lines.join("\n")
136
+ end
137
+
138
+ end
139
+
140
+ end
141
+ end
@@ -0,0 +1,75 @@
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
+ Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
20
+ child_names = has_children ? cmd.recognised_subcommands.flat_map(&:names) : []
21
+ condition = condition_for(path, child_names)
22
+ Completion.visible_options(cmd).each do |option|
23
+ lines << option_completion(option, condition)
24
+ end
25
+ next unless has_children
26
+
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
33
+ lines << ""
34
+ end
35
+ "#{lines.join("\n")}\n"
36
+ end
37
+
38
+ private
39
+
40
+ def condition_for(path, child_names)
41
+ if path.empty?
42
+ "__fish_use_subcommand"
43
+ 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?
46
+ parts.join("; and ")
47
+ end
48
+ end
49
+
50
+ def option_completion(option, condition)
51
+ parts = ["complete -c #{@executable_name} -f"]
52
+ parts << "-n '#{condition}'"
53
+
54
+ Completion.expanded_switches(option).each do |switch|
55
+ parts << if switch.start_with?("--")
56
+ "-l #{switch.sub(/^--/, '')}"
57
+ else
58
+ "-s #{switch.sub(/^-/, '')}"
59
+ end
60
+ end
61
+
62
+ parts << "-r" unless option.flag?
63
+ parts << "-d '#{escape(option.description)}'"
64
+
65
+ parts.join(" ")
66
+ end
67
+
68
+ def escape(str)
69
+ str.gsub("'", "\\\\'")
70
+ end
71
+
72
+ end
73
+
74
+ end
75
+ end
@@ -0,0 +1,126 @@
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, [function_name], Set.new)
18
+ lines << "_#{function_name}"
19
+ lines.push("").join("\n")
20
+ end
21
+
22
+ private
23
+
24
+ def function_name
25
+ @executable_name.gsub(/[^a-zA-Z0-9_]/, "_")
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).map { |o| option_spec(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).map { |o| option_spec(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 + [sanitize(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 + [sanitize(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_spec(option)
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_]/, "_")
117
+ end
118
+
119
+ def escape(str)
120
+ str.gsub("'", "'\\''")
121
+ end
122
+
123
+ end
124
+
125
+ end
126
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "clamp/command"
4
+ require "clamp/completion/bash_generator"
5
+ require "clamp/completion/fish_generator"
6
+ require "clamp/completion/zsh_generator"
7
+
8
+ module Clamp
9
+
10
+ # Shell completion script generation.
11
+ #
12
+ module Completion
13
+
14
+ GENERATORS = {
15
+ bash: Clamp::Completion::BashGenerator,
16
+ fish: Clamp::Completion::FishGenerator,
17
+ zsh: Clamp::Completion::ZshGenerator
18
+ }.freeze
19
+
20
+ # Raised when --shell-completions is used; caught by Command.run.
21
+ #
22
+ class Wanted < StandardError
23
+
24
+ def initialize(command, shell)
25
+ super("completion requested")
26
+ @command = command
27
+ @shell = shell
28
+ end
29
+
30
+ attr_reader :command, :shell
31
+
32
+ end
33
+
34
+ module_function
35
+
36
+ def generate(command_class, shell, executable_name)
37
+ generator_class = GENERATORS.fetch(shell) do
38
+ raise ArgumentError, "unsupported shell: #{shell.inspect}"
39
+ end
40
+ generator_class.new(command_class, executable_name).generate
41
+ end
42
+
43
+ # Return switches with --[no-]foo expanded to --foo and --no-foo.
44
+ def expanded_switches(option)
45
+ option.switches.flat_map do |switch|
46
+ if switch =~ /^--\[no-\](.*)/
47
+ ["--#{Regexp.last_match(1)}", "--no-#{Regexp.last_match(1)}"]
48
+ else
49
+ switch
50
+ end
51
+ end
52
+ end
53
+
54
+ # Options visible in completion (excludes hidden).
55
+ def visible_options(command_class)
56
+ command_class.recognised_options.reject(&:hidden?)
57
+ end
58
+
59
+ # Walk the command tree depth-first, yielding (command_class, path, has_children).
60
+ # Path is an array of Subcommand::Definition objects.
61
+ # Always yields, even for revisited classes (with has_children=false).
62
+ def walk_command_tree(command_class, path = [], visited = Set.new, &block)
63
+ fresh = !visited.include?(command_class)
64
+ visited |= [command_class]
65
+ has_children = command_class.has_subcommands? && fresh
66
+ yield command_class, path, has_children
67
+ return unless has_children
68
+
69
+ command_class.recognised_subcommands.each do |sub|
70
+ walk_command_tree(sub.subcommand_class, path + [sub], visited, &block)
71
+ end
72
+ end
73
+
74
+ # Collect all subcommand names across the command tree.
75
+ def collect_subcommand_names(command_class)
76
+ names = []
77
+ walk_command_tree(command_class) do |cmd, _path, has_children|
78
+ cmd.recognised_subcommands.each { |sub| names.concat(sub.names) } if has_children
79
+ end
80
+ names.uniq
81
+ end
82
+
83
+ end
84
+ end
85
+
86
+ module Clamp
87
+
88
+ # Reopened to add completion support.
89
+ #
90
+ class Command
91
+
92
+ def self.generate_completion(shell, executable_name)
93
+ Clamp::Completion.generate(self, shell, executable_name)
94
+ end
95
+
96
+ # Adds --shell-completions option and handles the Wanted exception.
97
+ #
98
+ module RunWithCompletion
99
+
100
+ def run(invocation_path = File.basename($PROGRAM_NAME), arguments = ARGV, context = {})
101
+ context[:root_command_class] ||= self
102
+ super
103
+ rescue Clamp::Completion::Wanted => e
104
+ shell_name = File.basename(e.shell).to_sym
105
+ begin
106
+ puts generate_completion(shell_name, invocation_path)
107
+ rescue ArgumentError => ex
108
+ $stderr.puts "ERROR: #{ex.message}"
109
+ exit(1)
110
+ end
111
+ end
112
+
113
+ end
114
+
115
+ class << self
116
+
117
+ prepend RunWithCompletion
118
+
119
+ end
120
+
121
+ end
122
+
123
+ end
124
+
125
+ module Clamp
126
+ module Option
127
+
128
+ # Adds implicit --shell-completions option to all commands.
129
+ #
130
+ module Declaration
131
+
132
+ # Declares --shell-completions alongside other implicit options.
133
+ #
134
+ module WithCompletionOption
135
+
136
+ def recognised_options
137
+ unless @implicit_completion_option_declared
138
+ @implicit_completion_option_declared = true
139
+ declare_implicit_completion_option
140
+ end
141
+ super
142
+ end
143
+
144
+ private
145
+
146
+ def declare_implicit_completion_option
147
+ return if effective_options.find { |o| o.handles?("--shell-completions") }
148
+
149
+ option "--shell-completions", "SHELL",
150
+ "generate shell completion script",
151
+ hidden: true do |shell|
152
+ raise Clamp::Completion::Wanted.new(self, shell)
153
+ end
154
+ end
155
+
156
+ end
157
+
158
+ prepend WithCompletionOption
159
+
160
+ end
161
+
162
+ end
163
+ end
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.4.0"
4
+ VERSION = "1.5.0"
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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Williams
@@ -17,20 +17,9 @@ executables: []
17
17
  extensions: []
18
18
  extra_rdoc_files: []
19
19
  files:
20
- - ".autotest"
21
- - ".editorconfig"
22
- - ".github/workflows/ci.yml"
23
- - ".gitignore"
24
- - ".rspec"
25
- - ".rubocop.yml"
26
20
  - CHANGES.md
27
- - CODEOWNERS
28
- - Gemfile
29
- - Guardfile
30
21
  - LICENSE
31
22
  - README.md
32
- - Rakefile
33
- - clamp.gemspec
34
23
  - examples/admin
35
24
  - examples/defaulted
36
25
  - examples/flipflop
@@ -45,6 +34,10 @@ files:
45
34
  - lib/clamp/attribute/definition.rb
46
35
  - lib/clamp/attribute/instance.rb
47
36
  - lib/clamp/command.rb
37
+ - lib/clamp/completion.rb
38
+ - lib/clamp/completion/bash_generator.rb
39
+ - lib/clamp/completion/fish_generator.rb
40
+ - lib/clamp/completion/zsh_generator.rb
48
41
  - lib/clamp/errors.rb
49
42
  - lib/clamp/help.rb
50
43
  - lib/clamp/messages.rb
@@ -60,15 +53,6 @@ files:
60
53
  - lib/clamp/subcommand/parsing.rb
61
54
  - lib/clamp/truthy.rb
62
55
  - lib/clamp/version.rb
63
- - spec/clamp/command_group_spec.rb
64
- - spec/clamp/command_option_module_spec.rb
65
- - spec/clamp/command_option_reordering_spec.rb
66
- - spec/clamp/command_spec.rb
67
- - spec/clamp/help/builder_spec.rb
68
- - spec/clamp/messages_spec.rb
69
- - spec/clamp/option/definition_spec.rb
70
- - spec/clamp/parameter/definition_spec.rb
71
- - spec/spec_helper.rb
72
56
  homepage: https://github.com/mdub/clamp
73
57
  licenses:
74
58
  - MIT
data/.autotest DELETED
@@ -1,11 +0,0 @@
1
- require "autotest/bundler"
2
-
3
- Autotest.add_hook :initialize do |at|
4
-
5
- at.add_exception ".git"
6
-
7
- at.add_mapping(%r{^lib/(.*)\.rb$}, :prepend) do |_, match|
8
- ["spec/unit/#{match[1]}_spec.rb"] + Dir['spec/clamp/command*_spec.rb']
9
- end
10
-
11
- end
data/.editorconfig DELETED
@@ -1,10 +0,0 @@
1
- root = true
2
-
3
- [*]
4
- indent_style = space
5
- indent_size = 2
6
- end_of_line = lf
7
- charset = utf-8
8
- trim_trailing_whitespace = true
9
- insert_final_newline = true
10
- max_line_length = 120