samovar 2.4.2 → 2.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: 467711ed04ecc759513ab6187d17a8c5208fa24869d202fb23a46217d3ceedab
4
- data.tar.gz: 293c9e07e9f0ff0b9550b0ad3411b5e10f460a6c72dd8be2bc254d985dec95da
3
+ metadata.gz: c9c0de84734b05646d8e1effd9c06339148112dc6202636d436f2909c6bb5835
4
+ data.tar.gz: 5d2a79b6375044119235462e71af0b9ca63f7c9ab272cd118f125a4967bae3bf
5
5
  SHA512:
6
- metadata.gz: bf717af75e91db10dc75008e1421601181168190aa89ea5bd289ca5e832505d1382cee60ac7e1b66d4500673779cc2cc5325dc33110ce4871443fa93c820d817
7
- data.tar.gz: c7c0f9b82b3ce5502c29da74b7810f69c63687237b2a7550dde8ff5eda35f3db6739848907d9669a2083df9ec186097c90d44e4e119aa1176ca1c6a52c7f6f07
6
+ metadata.gz: '09729f11890c05e4f15aa07b9af5202f718c5769f1e794fb9a90b8b843b230c98215cfb96d260923d7fad50cf6bb734f71c24f8f871e38e30c39cc9fc8a2242a'
7
+ data.tar.gz: a8927f7dd61c3423ea892049dcc4d776811601fd6932a3a0ed1834d6e0751deb3c04bffeea434884a2ea3649640e12d3a02048413b3f5392fb3bc94c1ab68932
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1,206 @@
1
+ # Completion
2
+
3
+ This guide explains how to add shell completion to commands built with `samovar`.
4
+
5
+ Samovar can complete command lines using the same grammar used for parsing. It can complete option flags, boolean flag variants, nested command names, option values, positional arguments, and split arguments.
6
+
7
+ ## Command Entry Point
8
+
9
+ Commands expose a completion entry point alongside the normal execution entry point:
10
+
11
+ ~~~ ruby
12
+ Application.call(ARGV) # Parse and execute the command.
13
+ Application.complete # Print completion candidates.
14
+ ~~~
15
+
16
+ `complete` expects the command-line arguments to be truncated to the cursor. The final argument is the token being completed. When completing after a space, pass an empty string as the final argument:
17
+
18
+ ~~~ ruby
19
+ Application.complete(["serve", "--bind", ""])
20
+ ~~~
21
+
22
+ Completion candidates are printed as tab-separated values:
23
+
24
+ ~~~ text
25
+ type value description key=value
26
+ ~~~
27
+
28
+ The first three fields are always the completion type, value, and description. Additional fields are optional metadata entries encoded as `key=value`.
29
+
30
+ ## Static Completions
31
+
32
+ Option flags and nested command names are completed automatically. You can add static completions for option values and positional arguments with `completions:`.
33
+
34
+ ~~~ ruby
35
+ require "samovar"
36
+
37
+ class Serve < Samovar::Command
38
+ self.description = "Run the server."
39
+
40
+ options do
41
+ option "--format <name>", "The output format.", default: "text", completions: ["json", "text", "yaml"]
42
+ end
43
+ end
44
+
45
+ class Application < Samovar::Command
46
+ options do
47
+ option "-h/--help", "Print help."
48
+ end
49
+
50
+ nested :command, {
51
+ "serve" => Serve
52
+ }, default: "serve"
53
+ end
54
+ ~~~
55
+
56
+ Examples:
57
+
58
+ ~~~ ruby
59
+ Application.complete(["ser"])
60
+ # command serve Run the server.
61
+
62
+ Application.complete(["serve", "--format", "j"])
63
+ # value json
64
+ ~~~
65
+
66
+ If an option has a default value, the default is offered before other value completions.
67
+
68
+ ## Dynamic Completions
69
+
70
+ Use a callable provider when completions depend on runtime state.
71
+
72
+ ~~~ ruby
73
+ class Serve < Samovar::Command
74
+ def self.host_completions(context)
75
+ ["localhost", "0.0.0.0"].select do |host|
76
+ host.start_with?(context.current)
77
+ end
78
+ end
79
+
80
+ options do
81
+ option "--bind <host>", "The bind address.", completions: method(:host_completions)
82
+ end
83
+ end
84
+ ~~~
85
+
86
+ The provider receives a `Samovar::Completion::Context` with:
87
+
88
+ - `current`: The token being completed.
89
+ - `arguments`: The full truncated argument list.
90
+ - `environment`: The environment hash passed to `complete`.
91
+ - `row`: The parser row whose value is being completed. This can be an option or a positional argument.
92
+
93
+ Providers can return strings, hashes, or `Samovar::Completion::Suggestion` instances:
94
+
95
+ ~~~ ruby
96
+ option "--mode <name>", "The mode.",
97
+ completions: [
98
+ {value: "development", description: "Local development", type: :value, suffix: " "},
99
+ {value: "production", description: "Production", type: :value}
100
+ ]
101
+ ~~~
102
+
103
+ ## Path Completion
104
+
105
+ For path-like arguments, let the shell do native path expansion by using one of the native completion providers:
106
+
107
+ ~~~ ruby
108
+ class Process < Samovar::Command
109
+ options do
110
+ option "--output <path>", "The output path.", completions: :path
111
+ option "--root <path>", "The root directory.", completions: :directory
112
+ end
113
+
114
+ one :input, "The input path.", completions: :file
115
+ end
116
+ ~~~
117
+
118
+ Supported native providers:
119
+
120
+ - `:path`: Complete files and directories using the shell.
121
+ - `:file`: Alias for `:path`.
122
+ - `:directory`: Complete directories using the shell.
123
+ - `:executable`: Complete executable commands using the shell.
124
+
125
+ Samovar does not inspect the filesystem for these providers. It emits a typed completion request, and the shell adapter translates it to native shell path completion.
126
+
127
+ For split arguments, `:executable` completes the command immediately after the split marker. Once a command is present, Samovar emits a `delegate` completion with an `index` metadata field. The index is the zero-based argument index where delegated completion begins.
128
+
129
+ ## Dedicated Completion Executable
130
+
131
+ Shell adapters call a dedicated completion executable named `completion-<command>`. This avoids running the normal command during completion.
132
+
133
+ For a command named `falcon`, provide:
134
+
135
+ ~~~ text
136
+ bin/falcon
137
+ bin/completion-falcon
138
+ ~~~
139
+
140
+ The completion executable can be very small:
141
+
142
+ ~~~ ruby
143
+ #!/usr/bin/env ruby
144
+ # frozen_string_literal: true
145
+
146
+ require_relative "../lib/my/application"
147
+
148
+ My::Application.complete
149
+ ~~~
150
+
151
+ When the user completes a command by path, the shell adapter resolves the completion executable next to that command:
152
+
153
+ ~~~ text
154
+ falcon -> completion-falcon
155
+ bin/falcon -> bin/completion-falcon
156
+ ./bin/falcon -> ./bin/completion-falcon
157
+ /path/falcon -> /path/completion-falcon
158
+ ~~~
159
+
160
+ ## Installing Shell Adapters
161
+
162
+ Shell adapter generation and installation is provided by the `completion` gem.
163
+
164
+ Generate an adapter script:
165
+
166
+ ~~~ bash
167
+ $ completion generate --shell zsh --command falcon
168
+ ~~~
169
+
170
+ Install a generic adapter script into the default directory for the current shell:
171
+
172
+ ~~~ bash
173
+ $ completion install
174
+ ~~~
175
+
176
+ The generic adapter checks whether a matching `completion-<command>` executable exists before handling a command. You can install an adapter for a specific command instead:
177
+
178
+ ~~~ bash
179
+ $ completion install --command falcon
180
+ ~~~
181
+
182
+ You can specify the shell and directory explicitly:
183
+
184
+ ~~~ bash
185
+ $ completion install --shell fish --directory ~/.config/fish/completions --command falcon
186
+ ~~~
187
+
188
+ The installed adapter calls the matching `completion-<command>` executable when completion is requested.
189
+
190
+ ## Testing Completion
191
+
192
+ You can test completion directly without involving a shell:
193
+
194
+ ~~~ ruby
195
+ output = StringIO.new
196
+
197
+ Application.complete(["serve", "--format", "j"], output: output)
198
+
199
+ expect(output.string).to be == "value\tjson\t\n"
200
+ ~~~
201
+
202
+ For a trailing-space completion, pass an empty final token:
203
+
204
+ ~~~ ruby
205
+ Application.complete(["serve", "--format", ""], output: output)
206
+ ~~~
data/context/index.yaml CHANGED
@@ -12,3 +12,7 @@ files:
12
12
  title: Getting Started
13
13
  description: This guide explains how to use `samovar` to build command-line tools
14
14
  and applications.
15
+ - path: completion.md
16
+ title: Completion
17
+ description: This guide explains how to add shell completion to commands built with
18
+ `samovar`.
@@ -14,6 +14,7 @@ require_relative "split"
14
14
  require_relative "output"
15
15
 
16
16
  require_relative "error"
17
+ require_relative "completion"
17
18
 
18
19
  module Samovar
19
20
  # Represents a command in the command-line interface.
@@ -23,12 +24,13 @@ module Samovar
23
24
  # Parse and execute the command with the given input.
24
25
  #
25
26
  # This is the high-level entry point for CLI applications. It handles errors gracefully by printing usage and returning nil.
27
+ # The given arguments are passed to the parser, which consumes them as mutable input.
26
28
  #
27
- # @parameter input [Array(String)] The command-line arguments to parse.
29
+ # @parameter arguments [Array(String)] The command-line arguments to parse.
28
30
  # @parameter output [IO] The output stream for error messages.
29
31
  # @returns [Object | Nil] The result of the command's call method, or nil if parsing/execution failed.
30
- def self.call(input = ARGV, output: $stderr)
31
- self.parse(input).call
32
+ def self.call(arguments = ARGV, output: $stderr)
33
+ self.parse(arguments).call
32
34
  rescue Error => error
33
35
  error.command.print_usage(output: output) do |formatter|
34
36
  formatter.map(error)
@@ -37,27 +39,40 @@ module Samovar
37
39
  return nil
38
40
  end
39
41
 
42
+ # Complete the command-line input without executing the command.
43
+ # The given arguments are treated as the stable command-line boundary; completion internals consume derived input arrays.
44
+ #
45
+ # @parameter arguments [Array(String)] The command-line arguments to complete.
46
+ # @parameter environment [Hash] The environment for completion callbacks.
47
+ # @parameter output [IO] The output stream for printing completion results.
48
+ # @returns [Completion::Result] The completion result.
49
+ def self.complete(arguments = ARGV, environment: ENV, output: $stdout)
50
+ Completion.complete(self, arguments, environment: environment).tap do |result|
51
+ result.print(output)
52
+ end
53
+ end
54
+
40
55
  # Parse the command-line input and create a command instance.
41
56
  #
42
57
  # This is the low-level parsing primitive. It raises {Error} exceptions on parsing failures.
43
58
  # For CLI applications, use {call} instead which handles errors gracefully.
44
59
  #
45
- # @parameter input [Array(String)] The command-line arguments to parse.
60
+ # @parameter arguments [Array(String)] The command-line arguments to parse.
46
61
  # @returns [Command] The parsed command instance.
47
62
  # @raises [Error] If parsing fails.
48
- def self.parse(input)
49
- self.new(input)
63
+ def self.parse(arguments)
64
+ self.new(arguments)
50
65
  end
51
66
 
52
67
  # Create a new command instance with the given arguments.
53
68
  #
54
69
  # This is a convenience method for creating command instances with explicit arguments.
55
70
  #
56
- # @parameter input [Array(String)] The command-line arguments to parse.
71
+ # @parameter arguments [Array(String)] The command-line arguments to parse.
57
72
  # @parameter options [Hash] Additional options to pass to the command.
58
73
  # @returns [Command] The command instance.
59
- def self.[](*input, **options)
60
- self.new(input, **options)
74
+ def self.[](*arguments, **options)
75
+ self.new(arguments, **options)
61
76
  end
62
77
 
63
78
  class << self
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "result"
7
+
8
+ module Samovar
9
+ module Completion
10
+ # The context provided to dynamic completion callbacks.
11
+ class Context
12
+ # Build a context for a command class and argument list.
13
+ #
14
+ # @parameter command_class [Class] The command class to complete.
15
+ # @parameter arguments [Array(String)] The truncated command-line arguments.
16
+ # @parameter environment [Hash] The environment for completion callbacks.
17
+ # @returns [Context] The completion context.
18
+ def self.for(command_class, arguments, environment: ENV)
19
+ return self.new(
20
+ command_class.table.merged,
21
+ arguments,
22
+ arguments.last || "",
23
+ environment: environment,
24
+ )
25
+ end
26
+
27
+ # Initialize a new completion context.
28
+ #
29
+ # @parameter table [Table] The command table to complete.
30
+ # @parameter arguments [Array(String)] The truncated command-line arguments.
31
+ # @parameter current [String] The token being completed.
32
+ # @parameter row [Object | Nil] The parser row whose value is being completed.
33
+ # @parameter environment [Hash] The environment for completion callbacks.
34
+ def initialize(table, arguments, current, row = nil, environment: ENV)
35
+ @table = table
36
+ @arguments = arguments
37
+ @current = current
38
+ @row = row
39
+ @environment = environment
40
+ end
41
+
42
+ # @attribute [Table] The command table to complete.
43
+ attr :table
44
+
45
+ # @attribute [Array(String)] The truncated command-line arguments.
46
+ attr :arguments
47
+
48
+ # @attribute [String] The token being completed.
49
+ attr :current
50
+
51
+ # @attribute [Object | Nil] The parser row whose value is being completed.
52
+ attr :row
53
+
54
+ # @attribute [Hash] The environment for completion callbacks.
55
+ attr :environment
56
+
57
+ # Create a context for completing the given parser row.
58
+ #
59
+ # @parameter row [Object] The parser row whose value is being completed.
60
+ # @returns [Context] The specialized completion context.
61
+ def with_row(row)
62
+ return self.class.new(
63
+ @table,
64
+ @arguments,
65
+ @current,
66
+ row,
67
+ environment: @environment,
68
+ )
69
+ end
70
+
71
+ # The completed words before the current token.
72
+ #
73
+ # @returns [Array(String)] The arguments before the token being completed.
74
+ def words
75
+ @arguments.take(@arguments.size - 1)
76
+ end
77
+
78
+ # Complete the current command class.
79
+ #
80
+ # @returns [Result] The completion result.
81
+ def complete
82
+ complete_rows(@table, words)
83
+ end
84
+
85
+ # Complete the given command class with completed words.
86
+ #
87
+ # @parameter command_class [Class] The command class to complete.
88
+ # @parameter words [Array(String)] The completed words before the current token.
89
+ # @returns [Result] The completion result.
90
+ def complete_command(command_class, words = [])
91
+ complete_rows(command_class.table.merged, words)
92
+ end
93
+
94
+ # Complete the rows in a command table.
95
+ # The input array is mutable and may be consumed by parser rows.
96
+ #
97
+ # @parameter table [Table] The command table to complete.
98
+ # @parameter input [Array(String)] The mutable completed words to consume.
99
+ # @returns [Result] The completion result.
100
+ def complete_rows(table, input)
101
+ collected = []
102
+
103
+ table.each do |row|
104
+ if row.respond_to?(:complete)
105
+ if result = row.complete(input, self, collected)
106
+ return result
107
+ end
108
+ end
109
+ end
110
+
111
+ return Result.new(collected)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "result"
7
+ require_relative "suggestion"
8
+
9
+ module Samovar
10
+ module Completion
11
+ # Expands static, dynamic, and native completion providers.
12
+ class Provider
13
+ # Initialize a new completion provider.
14
+ #
15
+ # @parameter context [Context] The completion context.
16
+ # @parameter completions [Array | Proc | Symbol | Nil] The static, dynamic, or native completions.
17
+ def initialize(context, completions)
18
+ @context = context
19
+ @completions = completions
20
+ end
21
+
22
+ # Generate suggestions from the provider.
23
+ #
24
+ # @returns [Result] The matching completion suggestions.
25
+ def suggestions
26
+ case @completions
27
+ when nil
28
+ Result.new
29
+ when Symbol
30
+ native_suggestions
31
+ else
32
+ matching_suggestions
33
+ end
34
+ end
35
+
36
+ protected
37
+
38
+ # Generate matching suggestions from static or dynamic completions.
39
+ #
40
+ # @returns [Result] The matching completion suggestions.
41
+ def matching_suggestions
42
+ values = @completions
43
+
44
+ if values.respond_to?(:call)
45
+ values = values.call(@context)
46
+ end
47
+
48
+ values = Array(values).filter_map do |value|
49
+ suggestion = Suggestion.wrap(value)
50
+
51
+ suggestion if suggestion.start_with?(@context.current)
52
+ end
53
+
54
+ return Result.new(values)
55
+ end
56
+
57
+ # Generate native shell completion requests.
58
+ #
59
+ # @returns [Result] The native completion request suggestions.
60
+ def native_suggestions
61
+ case @completions
62
+ when :path, :file
63
+ Result.new([Suggestion.new(@context.current, description: "Path", type: :path)])
64
+ when :directory
65
+ Result.new([Suggestion.new(@context.current, description: "Directory", type: :directory)])
66
+ when :executable
67
+ Result.new([Suggestion.new(@context.current, description: "Executable", type: :executable)])
68
+ else
69
+ Result.new
70
+ end
71
+ end
72
+
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Samovar
7
+ module Completion
8
+ # A collection of completion suggestions.
9
+ class Result
10
+ include Enumerable
11
+
12
+ # Initialize a new completion result.
13
+ #
14
+ # @parameter suggestions [Array(Suggestion)] The suggestions in this result.
15
+ def initialize(suggestions = [])
16
+ @suggestions = suggestions
17
+ end
18
+
19
+ # @attribute [Array(Suggestion)] The suggestions in this result.
20
+ attr :suggestions
21
+
22
+ # Iterate over each suggestion.
23
+ #
24
+ # @yields {|suggestion| ...} The block to call for each suggestion.
25
+ def each(&block)
26
+ @suggestions.each(&block)
27
+ end
28
+
29
+ # Whether this result contains no suggestions.
30
+ #
31
+ # @returns [Boolean] True if there are no suggestions.
32
+ def empty?
33
+ @suggestions.empty?
34
+ end
35
+
36
+ # Combine this result with another result.
37
+ #
38
+ # @parameter other [Result] The other result to append.
39
+ # @returns [Result] The combined result.
40
+ def +(other)
41
+ self.class.new(@suggestions + other.suggestions)
42
+ end
43
+
44
+ # Print suggestions as tab-separated completion records.
45
+ #
46
+ # @parameter output [IO] The output stream to write to.
47
+ def print(output = $stdout)
48
+ each do |suggestion|
49
+ output.puts suggestion.to_record
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Samovar
7
+ module Completion
8
+ # A single completion suggestion.
9
+ class Suggestion
10
+ # Wrap a raw completion value in a suggestion.
11
+ #
12
+ # @parameter value [Suggestion | Hash | Object] The value to wrap.
13
+ # @returns [Suggestion] The normalized suggestion.
14
+ def self.wrap(value)
15
+ case value
16
+ when self
17
+ return value
18
+ when Hash
19
+ value = value.dup
20
+ suggestion = value.fetch(:value)
21
+ value.delete(:value)
22
+
23
+ return self.new(suggestion, **value)
24
+ else
25
+ return self.new(value)
26
+ end
27
+ end
28
+
29
+ # Initialize a new completion suggestion.
30
+ #
31
+ # @parameter value [Object] The completion value.
32
+ # @parameter type [Symbol | String | Nil] The completion type.
33
+ # @parameter description [String | Nil] The completion description.
34
+ # @parameter options [Hash] Additional completion metadata.
35
+ def initialize(value, type: nil, description: nil, **options)
36
+ @type = type
37
+ @value = value
38
+ @description = description
39
+ @options = options
40
+ end
41
+
42
+ # @attribute [Symbol | String | Nil] The completion type.
43
+ attr :type
44
+
45
+ # @attribute [Object] The completion value.
46
+ attr :value
47
+
48
+ # @attribute [String | Nil] The completion description.
49
+ attr :description
50
+
51
+ # @attribute [Hash] Additional completion metadata.
52
+ attr :options
53
+
54
+ # Whether this suggestion starts with the given prefix.
55
+ #
56
+ # @parameter prefix [String] The prefix to check.
57
+ # @returns [Boolean] True if the suggestion starts with the given prefix.
58
+ def start_with?(prefix)
59
+ to_s.start_with?(prefix)
60
+ end
61
+
62
+ # Convert the suggestion to a tab-separated completion record.
63
+ #
64
+ # @returns [String] The escaped completion record.
65
+ def to_record
66
+ fields = [
67
+ escape(@type),
68
+ escape(@value),
69
+ escape(@description),
70
+ ]
71
+ @options.each do |key, value|
72
+ next if value.nil?
73
+
74
+ fields << "#{escape(key)}=#{escape(value)}"
75
+ end
76
+
77
+ return fields.join("\t")
78
+ end
79
+
80
+ # Convert the suggestion to its value.
81
+ #
82
+ # @returns [String] The suggestion value.
83
+ def to_s
84
+ @value.to_s
85
+ end
86
+
87
+ private
88
+
89
+ def escape(value)
90
+ value.to_s.gsub(/[\t\r\n]/, " ")
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "completion/context"
7
+ require_relative "completion/provider"
8
+ require_relative "completion/result"
9
+ require_relative "completion/suggestion"
10
+
11
+ module Samovar
12
+ # Shell completion support for Samovar commands.
13
+ module Completion
14
+ # Complete the command line for the given command class.
15
+ #
16
+ # @parameter command_class [Class] The command class to complete.
17
+ # @parameter arguments [Array(String)] The application arguments.
18
+ # @parameter environment [Hash] The environment for completion callbacks.
19
+ # @returns [Result] The completion result.
20
+ def self.complete(command_class, arguments, environment: ENV)
21
+ Context.for(command_class, arguments, environment: environment).complete
22
+ end
23
+ end
24
+ end
data/lib/samovar/flags.rb CHANGED
@@ -8,6 +8,8 @@ module Samovar
8
8
  #
9
9
  # Flags parse text like `-f/--flag <value>` into individual flag parsers.
10
10
  class Flags
11
+ include Enumerable
12
+
11
13
  # Initialize a new flags parser.
12
14
  #
13
15
  # @parameter text [String] The flags specification string (e.g., `-f/--flag <value>`).
@@ -24,6 +26,21 @@ module Samovar
24
26
  @ordered.each(&block)
25
27
  end
26
28
 
29
+ # Find the flag that matches the given token.
30
+ #
31
+ # @parameter token [String] The token to match.
32
+ # @returns [Flag | Nil] The matching flag.
33
+ def flag_for(token)
34
+ @ordered.find{|flag| flag.prefix?(token)}
35
+ end
36
+
37
+ # The possible flag prefixes for completion.
38
+ #
39
+ # @returns [Array(String)] The flag prefixes and alternatives.
40
+ def completions
41
+ @ordered.flat_map(&:completions)
42
+ end
43
+
27
44
  # Get the first flag.
28
45
  #
29
46
  # @returns [Flag] The first flag.
@@ -132,6 +149,21 @@ module Samovar
132
149
  def boolean?
133
150
  false
134
151
  end
152
+
153
+ # Check if the token matches this flag.
154
+ #
155
+ # @parameter token [String] The token to check.
156
+ # @returns [Boolean] True if the token matches.
157
+ def prefix?(token)
158
+ @prefix == token or @alternatives&.include?(token)
159
+ end
160
+
161
+ # The possible flag prefixes for completion.
162
+ #
163
+ # @returns [Array(String)] The flag prefix and alternatives.
164
+ def completions
165
+ [@prefix, *@alternatives]
166
+ end
135
167
  end
136
168
 
137
169
  # Represents a flag that accepts a value or acts as a boolean.
data/lib/samovar/many.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2016-2026, by Samuel Williams.
5
5
 
6
+ require_relative "completion"
7
+
6
8
  module Samovar
7
9
  # Represents multiple positional arguments in a command.
8
10
  #
@@ -15,12 +17,14 @@ module Samovar
15
17
  # @parameter stop [Regexp] A pattern that indicates the end of this argument list.
16
18
  # @parameter default [Object] The default value if no arguments are provided.
17
19
  # @parameter required [Boolean] Whether at least one argument is required.
18
- def initialize(key, description = nil, stop: /^-/, default: nil, required: false)
20
+ # @parameter completions [Array | Proc | Nil] Completions for these arguments.
21
+ def initialize(key, description = nil, stop: /^-/, default: nil, required: false, completions: nil)
19
22
  @key = key
20
23
  @description = description
21
24
  @stop = stop
22
25
  @default = default
23
26
  @required = required
27
+ @completions = completions
24
28
  end
25
29
 
26
30
  # The name of the attribute to store the values in.
@@ -48,6 +52,11 @@ module Samovar
48
52
  # @attribute [Boolean]
49
53
  attr :required
50
54
 
55
+ # Completions for these arguments.
56
+ #
57
+ # @attribute [Array | Proc | Nil]
58
+ attr :completions
59
+
51
60
  # Generate a string representation for usage output.
52
61
  #
53
62
  # @returns [String] The usage string.
@@ -87,5 +96,27 @@ module Samovar
87
96
  raise MissingValueError.new(parent, @key)
88
97
  end
89
98
  end
99
+
100
+ # Complete this repeating positional argument.
101
+ #
102
+ # @parameter input [Array(String)] Previously completed command-line arguments.
103
+ # @parameter context [Completion::Context] The completion context.
104
+ # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far.
105
+ # @returns [Completion::Result | Nil] A final completion result, or nil to continue.
106
+ def complete(input, context, collected)
107
+ if @stop
108
+ input.shift while input.any? && !(@stop === input.first)
109
+
110
+ return nil if @stop === context.current
111
+ else
112
+ input.clear
113
+ end
114
+
115
+ if input.empty?
116
+ return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions
117
+ end
118
+
119
+ return nil
120
+ end
90
121
  end
91
122
  end
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2016-2026, by Samuel Williams.
5
5
 
6
+ require_relative "completion"
7
+
6
8
  module Samovar
7
9
  # Represents nested sub-commands in a command.
8
10
  #
@@ -85,16 +87,57 @@ module Samovar
85
87
  name = input.shift
86
88
 
87
89
  # puts "Instantiating #{command} with #{input}"
88
- command.new(input, name: name, parent: parent)
90
+ command.new(input, name: name, parent: parent, output: parent&.output)
89
91
  elsif default
90
92
  return default
91
93
  elsif @default
92
- @commands[@default].new(input, name: @default, parent: parent)
94
+ @commands[@default].new(input, name: @default, parent: parent, output: parent&.output)
93
95
  elsif @required
94
96
  raise MissingValueError.new(parent, @key)
95
97
  end
96
98
  end
97
99
 
100
+ # Complete nested command names or continue into a selected command.
101
+ #
102
+ # @parameter input [Array(String)] Previously completed command-line arguments.
103
+ # @parameter context [Completion::Context] The completion context.
104
+ # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far.
105
+ # @returns [Completion::Result | Nil] A final completion result, or nil to continue.
106
+ def complete(input, context, collected)
107
+ if input.empty?
108
+ result = suggestions(context)
109
+
110
+ if result.empty? && @default
111
+ return Completion::Result.new(collected) + context.complete_command(@commands.fetch(@default))
112
+ end
113
+
114
+ return Completion::Result.new(collected) + result
115
+ end
116
+
117
+ if command = @commands[input.first]
118
+ input.shift
119
+ return context.complete_command(command, input)
120
+ elsif @default
121
+ return context.complete_command(@commands.fetch(@default), input)
122
+ else
123
+ return Completion::Result.new(collected)
124
+ end
125
+ end
126
+
127
+ # Complete nested command names for the current token.
128
+ #
129
+ # @parameter context [Completion::Context] The completion context.
130
+ # @returns [Completion::Result] The matching nested command suggestions.
131
+ def suggestions(context)
132
+ suggestions = @commands.collect do |name, command_class|
133
+ next unless name.start_with?(context.current)
134
+
135
+ Completion::Suggestion.new(name, description: command_class.description, type: :command)
136
+ end.compact
137
+
138
+ Completion::Result.new(suggestions)
139
+ end
140
+
98
141
  # Generate usage information for this nested command.
99
142
  #
100
143
  # @parameter rows [Output::Rows] The rows to append usage information to.
data/lib/samovar/one.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2016-2026, by Samuel Williams.
5
5
 
6
+ require_relative "completion"
7
+
6
8
  module Samovar
7
9
  # Represents a single positional argument in a command.
8
10
  #
@@ -15,12 +17,14 @@ module Samovar
15
17
  # @parameter pattern [Regexp] A pattern to match valid values.
16
18
  # @parameter default [Object] The default value if no argument is provided.
17
19
  # @parameter required [Boolean] Whether the argument is required.
18
- def initialize(key, description, pattern: //, default: nil, required: false)
20
+ # @parameter completions [Array | Proc | Nil] Completions for this argument.
21
+ def initialize(key, description, pattern: //, default: nil, required: false, completions: nil)
19
22
  @key = key
20
23
  @description = description
21
24
  @pattern = pattern
22
25
  @default = default
23
26
  @required = required
27
+ @completions = completions
24
28
  end
25
29
 
26
30
  # The name of the attribute to store the value in.
@@ -48,6 +52,11 @@ module Samovar
48
52
  # @attribute [Boolean]
49
53
  attr :required
50
54
 
55
+ # Completions for this argument.
56
+ #
57
+ # @attribute [Array | Proc | Nil]
58
+ attr :completions
59
+
51
60
  # Generate a string representation for usage output.
52
61
  #
53
62
  # @returns [String] The usage string.
@@ -85,5 +94,22 @@ module Samovar
85
94
  raise MissingValueError.new(parent, @key)
86
95
  end
87
96
  end
97
+
98
+ # Complete this positional argument.
99
+ #
100
+ # @parameter input [Array(String)] Previously completed command-line arguments.
101
+ # @parameter context [Completion::Context] The completion context.
102
+ # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far.
103
+ # @returns [Completion::Result | Nil] A final completion result, or nil to continue.
104
+ def complete(input, context, collected)
105
+ if input.empty?
106
+ return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions
107
+ elsif @pattern =~ input.first
108
+ input.shift
109
+ return nil
110
+ else
111
+ return Completion::Result.new(collected)
112
+ end
113
+ end
88
114
  end
89
115
  end
@@ -5,6 +5,8 @@
5
5
 
6
6
  require_relative "flags"
7
7
  require_relative "error"
8
+ require_relative "completion/provider"
9
+ require_relative "completion/result"
8
10
 
9
11
  module Samovar
10
12
  # Represents a single command-line option.
@@ -20,8 +22,9 @@ module Samovar
20
22
  # @parameter value [Object | Nil] A fixed value to use regardless of user input.
21
23
  # @parameter type [Class | Proc | Nil] The type to coerce the value to.
22
24
  # @parameter required [Boolean] Whether the option is required.
25
+ # @parameter completions [Array | Proc | Nil] Completions for option values.
23
26
  # @yields {|value| ...} An optional block to transform the parsed value.
24
- def initialize(flags, description, key: nil, default: nil, value: nil, type: nil, required: false, &block)
27
+ def initialize(flags, description, key: nil, default: nil, value: nil, type: nil, required: false, completions: nil, &block)
25
28
  @flags = Flags.new(flags)
26
29
  @description = description
27
30
 
@@ -39,6 +42,7 @@ module Samovar
39
42
 
40
43
  @type = type
41
44
  @required = required
45
+ @completions = completions
42
46
  @block = block
43
47
  end
44
48
 
@@ -59,8 +63,21 @@ module Samovar
59
63
 
60
64
  # The default value if the option is not provided.
61
65
  #
62
- # @attribute [Object]
63
- attr :default
66
+ # @returns [Object | Nil] The resolved default value.
67
+ def default
68
+ if @default.respond_to?(:call)
69
+ @default.call
70
+ else
71
+ @default
72
+ end
73
+ end
74
+
75
+ # Whether this option has a default value.
76
+ #
77
+ # @returns [Boolean] True if the option has a default value.
78
+ def default?
79
+ !@default.nil?
80
+ end
64
81
 
65
82
  # A fixed value to use regardless of user input.
66
83
  #
@@ -77,11 +94,52 @@ module Samovar
77
94
  # @attribute [Boolean]
78
95
  attr :required
79
96
 
97
+ # Completions for option values.
98
+ #
99
+ # @attribute [Array | Proc | Nil]
100
+ attr :completions
101
+
80
102
  # An optional block to transform the parsed value.
81
103
  #
82
104
  # @attribute [Proc | Nil]
83
105
  attr :block
84
106
 
107
+ # Find the flag that matches the given token.
108
+ #
109
+ # @parameter token [String] The token to match.
110
+ # @returns [Flag | Nil] The matching flag.
111
+ def flag_for(token)
112
+ @flags.flag_for(token)
113
+ end
114
+
115
+ # Whether this option consumes a value after the flag.
116
+ #
117
+ # @returns [Boolean] True if any flag for this option consumes a value.
118
+ def value?
119
+ @flags.any?{|flag| !flag.boolean?}
120
+ end
121
+
122
+ # Complete values for this option.
123
+ #
124
+ # @parameter context [Completion::Context] The completion context.
125
+ # @returns [Completion::Result] The matching option value completions.
126
+ def suggestions(context)
127
+ suggestions = []
128
+ context = context.with_row(self)
129
+
130
+ if default?
131
+ suggestion = Completion::Provider.new(context, [default]).suggestions.first
132
+
133
+ suggestions << suggestion if suggestion
134
+ end
135
+
136
+ Completion::Provider.new(context, @completions).suggestions.each do |suggestion|
137
+ suggestions << suggestion unless suggestions.any?{|existing| existing.value == suggestion.value}
138
+ end
139
+
140
+ Completion::Result.new(suggestions)
141
+ end
142
+
85
143
  # Coerce the result to the specified type.
86
144
  #
87
145
  # @parameter result [Object] The value to coerce.
@@ -141,8 +199,8 @@ module Samovar
141
199
  #
142
200
  # @returns [Array] The usage array.
143
201
  def to_a
144
- if @default
145
- [@flags, @description, "(default: #{@default})"]
202
+ if default?
203
+ [@flags, @description, "(default: #{default})"]
146
204
  elsif @required
147
205
  [@flags, @description, "(required)"]
148
206
  else
@@ -4,12 +4,15 @@
4
4
  # Copyright, 2016-2025, by Samuel Williams.
5
5
 
6
6
  require_relative "option"
7
+ require_relative "completion"
7
8
 
8
9
  module Samovar
9
10
  # Represents a collection of command-line options.
10
11
  #
11
12
  # Options provide a DSL for defining multiple option flags in a single block.
12
13
  class Options
14
+ include Enumerable
15
+
13
16
  # Parse and create an options collection from a block.
14
17
  #
15
18
  # @parameter arguments [Array] The arguments for the options collection.
@@ -68,8 +71,10 @@ module Samovar
68
71
 
69
72
  # The default values for options.
70
73
  #
71
- # @attribute [Hash]
72
- attr :defaults
74
+ # @returns [Hash] The resolved default values.
75
+ def defaults
76
+ @defaults.transform_values(&:default)
77
+ end
73
78
 
74
79
  # Freeze this options collection.
75
80
  #
@@ -93,6 +98,21 @@ module Samovar
93
98
  @ordered.each(&block)
94
99
  end
95
100
 
101
+ # Find the option that matches the given flag token.
102
+ #
103
+ # @parameter token [String] The flag token to match.
104
+ # @returns [Option | Nil] The matching option.
105
+ def option_for(token)
106
+ @keyed[token]
107
+ end
108
+
109
+ # The possible flag prefixes for completion.
110
+ #
111
+ # @returns [Array(String)] The option flag prefixes and alternatives.
112
+ def completions
113
+ @ordered.flat_map{|option| option.flags.completions}
114
+ end
115
+
96
116
  # Check if this options collection is empty.
97
117
  #
98
118
  # @returns [Boolean] True if there are no options.
@@ -131,8 +151,8 @@ module Samovar
131
151
  end
132
152
  end
133
153
 
134
- if default = option.default
135
- @defaults[option.key] = option.default
154
+ if option.default?
155
+ @defaults[option.key] = option
136
156
  end
137
157
  end
138
158
 
@@ -143,7 +163,7 @@ module Samovar
143
163
  # @parameter default [Hash | Nil] Default values to use.
144
164
  # @returns [Hash] The parsed option values.
145
165
  def parse(input, parent = nil, default = nil)
146
- values = (default || @defaults).dup
166
+ values = (default || defaults).dup
147
167
 
148
168
  while option = @keyed[input.first]
149
169
  # prefix = input.first
@@ -161,7 +181,71 @@ module Samovar
161
181
  end
162
182
 
163
183
  return values
164
- end # Generate a string representation for usage output.
184
+ end
185
+
186
+ # Complete option flags or option values.
187
+ #
188
+ # @parameter input [Array(String)] Previously completed command-line arguments.
189
+ # @parameter context [Completion::Context] The completion context.
190
+ # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far.
191
+ # @returns [Completion::Result | Nil] A final completion result, or nil to continue.
192
+ def complete(input, context, collected)
193
+ result = consume(input, context)
194
+ return result if result
195
+
196
+ return nil unless input.empty?
197
+
198
+ flags = suggestions(context.current)
199
+
200
+ if context.current.start_with?("-") && flags.any?
201
+ return Completion::Result.new(flags)
202
+ elsif context.current.empty?
203
+ collected.concat(flags)
204
+ return nil
205
+ end
206
+
207
+ return nil
208
+ end
209
+
210
+ # Consume option tokens before the current completion position.
211
+ #
212
+ # @parameter input [Array(String)] Previously completed command-line arguments.
213
+ # @parameter context [Completion::Context] The completion context.
214
+ # @returns [Completion::Result | Nil] A completion result for an option value, or nil to continue.
215
+ def consume(input, context)
216
+ while token = input.first
217
+ break unless option = option_for(token)
218
+
219
+ flag = option.flag_for(token)
220
+ input.shift
221
+
222
+ if flag && !flag.boolean?
223
+ if input.any?
224
+ input.shift
225
+ else
226
+ return option.suggestions(context)
227
+ end
228
+ end
229
+ end
230
+
231
+ return nil
232
+ end
233
+
234
+ # Complete option flags for the given prefix.
235
+ #
236
+ # @parameter prefix [String] The option prefix being completed.
237
+ # @returns [Array(Completion::Suggestion)] The matching option flag suggestions.
238
+ def suggestions(prefix)
239
+ flat_map do |option|
240
+ option.flags.completions.collect do |value|
241
+ next unless value.start_with?(prefix)
242
+
243
+ Completion::Suggestion.new(value, description: option.description, type: :option)
244
+ end
245
+ end.compact
246
+ end
247
+
248
+ # Generate a string representation for usage output.
165
249
  #
166
250
  # @returns [String] The usage string.
167
251
  def to_s
data/lib/samovar/split.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2016-2025, by Samuel Williams.
5
5
 
6
+ require_relative "completion"
7
+
6
8
  module Samovar
7
9
  # Represents a split point in the command-line arguments.
8
10
  #
@@ -15,12 +17,14 @@ module Samovar
15
17
  # @parameter marker [String] The marker that indicates the split point.
16
18
  # @parameter default [Object] The default value if no split is present.
17
19
  # @parameter required [Boolean] Whether the split is required.
18
- def initialize(key, description, marker: "--", default: nil, required: false)
20
+ # @parameter completions [Array | Proc | Nil] Completions for split arguments.
21
+ def initialize(key, description, marker: "--", default: nil, required: false, completions: nil)
19
22
  @key = key
20
23
  @description = description
21
24
  @marker = marker
22
25
  @default = default
23
26
  @required = required
27
+ @completions = completions
24
28
  end
25
29
 
26
30
  # The name of the attribute to store the values after the split.
@@ -48,6 +52,11 @@ module Samovar
48
52
  # @attribute [Boolean]
49
53
  attr :required
50
54
 
55
+ # Completions for split arguments.
56
+ #
57
+ # @attribute [Array | Proc | Nil]
58
+ attr :completions
59
+
51
60
  # Generate a string representation for usage output.
52
61
  #
53
62
  # @returns [String] The usage string.
@@ -85,5 +94,40 @@ module Samovar
85
94
  raise MissingValueError.new(parent, @key)
86
95
  end
87
96
  end
97
+
98
+ # Complete the split marker or arguments after it.
99
+ #
100
+ # @parameter input [Array(String)] Previously completed command-line arguments.
101
+ # @parameter context [Completion::Context] The completion context.
102
+ # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far.
103
+ # @returns [Completion::Result | Nil] A final completion result, or nil to continue.
104
+ def complete(input, context, collected)
105
+ if offset = input.index(@marker)
106
+ input.shift(offset + 1)
107
+
108
+ if @completions == :executable && input.any?
109
+ return Completion::Result.new(collected + [
110
+ Completion::Suggestion.new(
111
+ input.first,
112
+ description: "Delegate completion",
113
+ type: :delegate,
114
+ index: context.words.index(@marker) + 1,
115
+ ),
116
+ ])
117
+ end
118
+
119
+ return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions
120
+ end
121
+
122
+ return Completion::Result.new(collected) unless input.empty?
123
+
124
+ suggestions = []
125
+
126
+ if @marker.start_with?(context.current)
127
+ suggestions << Completion::Suggestion.new(@marker, description: @description, type: :split)
128
+ end
129
+
130
+ return Completion::Result.new(collected + suggestions)
131
+ end
88
132
  end
89
133
  end
@@ -6,5 +6,5 @@
6
6
 
7
7
  # @namespace
8
8
  module Samovar
9
- VERSION = "2.4.2"
9
+ VERSION = "2.5.0"
10
10
  end
data/readme.md CHANGED
@@ -18,10 +18,16 @@ Please see the [project documentation](https://ioquatix.github.io/samovar/) for
18
18
 
19
19
  - [Getting Started](https://ioquatix.github.io/samovar/guides/getting-started/index) - This guide explains how to use `samovar` to build command-line tools and applications.
20
20
 
21
+ - [Completion](https://ioquatix.github.io/samovar/guides/completion/index) - This guide explains how to add shell completion to commands built with `samovar`.
22
+
21
23
  ## Releases
22
24
 
23
25
  Please see the [project releases](https://ioquatix.github.io/samovar/releases/index) for all releases.
24
26
 
27
+ ### v2.5.0
28
+
29
+ - Introduce `Command#complete` which provides a hook for command completion logic, allowing developers to implement custom tab-completion behavior for their commands.
30
+
25
31
  ### v2.4.2
26
32
 
27
33
  - Drop dependency on `mapping` gem.
@@ -106,12 +112,6 @@ command list -- --help
106
112
 
107
113
  In this case, do we show help? Some effort is required to disambiguate this. Initially, it makes sense to keep things as simple as possible. But, it might make sense for some options to be declared in a global scope, which are extracted before parsing begins. I'm not sure if this is really a good idea. It might just be better to give good error output in this case (you specified an option but it was in the wrong place).
108
114
 
109
- ### Shell Auto-completion
110
-
111
- Because of the structure of the Samovar command parser, it should be possible to generate a list of all possible tokens at each point. Therefore, semantically correct tab completion should be possible.
112
-
113
- As a secondary to this, it would be nice if `Samovar::One` and `Samovar::Many` could take a list of potential tokens so that auto-completion could give meaningful suggestions, and possibly improved validation.
114
-
115
115
  ### Short/Long Help
116
116
 
117
117
  It might be interesting to explore whether it's possible to have `-h` and `--help` do different things. This could include command specific help output, more detailed help output (similar to a man page), and other useful help related tasks.
data/releases.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Releases
2
2
 
3
+ ## v2.5.0
4
+
5
+ - Introduce `Command#complete` which provides a hook for command completion logic, allowing developers to implement custom tab-completion behavior for their commands.
6
+
3
7
  ## v2.4.2
4
8
 
5
9
  - Drop dependency on `mapping` gem.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samovar
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.2
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -58,10 +58,16 @@ executables: []
58
58
  extensions: []
59
59
  extra_rdoc_files: []
60
60
  files:
61
+ - context/completion.md
61
62
  - context/getting-started.md
62
63
  - context/index.yaml
63
64
  - lib/samovar.rb
64
65
  - lib/samovar/command.rb
66
+ - lib/samovar/completion.rb
67
+ - lib/samovar/completion/context.rb
68
+ - lib/samovar/completion/provider.rb
69
+ - lib/samovar/completion/result.rb
70
+ - lib/samovar/completion/suggestion.rb
65
71
  - lib/samovar/error.rb
66
72
  - lib/samovar/failure.rb
67
73
  - lib/samovar/flags.rb
metadata.gz.sig CHANGED
Binary file