diff --git a/guides/completion/readme.md b/guides/completion/readme.md new file mode 100644 index 0000000..1bb50a1 --- /dev/null +++ b/guides/completion/readme.md @@ -0,0 +1,206 @@ +# Completion + +This guide explains how to add shell completion to commands built with `samovar`. + +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. + +## Command Entry Point + +Commands expose a completion entry point alongside the normal execution entry point: + +~~~ ruby +Application.call(ARGV) # Parse and execute the command. +Application.complete # Print completion candidates. +~~~ + +`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: + +~~~ ruby +Application.complete(["serve", "--bind", ""]) +~~~ + +Completion candidates are printed as tab-separated values: + +~~~ text +type value description key=value +~~~ + +The first three fields are always the completion type, value, and description. Additional fields are optional metadata entries encoded as `key=value`. + +## Static Completions + +Option flags and nested command names are completed automatically. You can add static completions for option values and positional arguments with `completions:`. + +~~~ ruby +require "samovar" + +class Serve < Samovar::Command + self.description = "Run the server." + + options do + option "--format ", "The output format.", default: "text", completions: ["json", "text", "yaml"] + end +end + +class Application < Samovar::Command + options do + option "-h/--help", "Print help." + end + + nested :command, { + "serve" => Serve + }, default: "serve" +end +~~~ + +Examples: + +~~~ ruby +Application.complete(["ser"]) +# command serve Run the server. + +Application.complete(["serve", "--format", "j"]) +# value json +~~~ + +If an option has a default value, the default is offered before other value completions. + +## Dynamic Completions + +Use a callable provider when completions depend on runtime state. + +~~~ ruby +class Serve < Samovar::Command + def self.host_completions(context) + ["localhost", "0.0.0.0"].select do |host| + host.start_with?(context.current) + end + end + + options do + option "--bind ", "The bind address.", completions: method(:host_completions) + end +end +~~~ + +The provider receives a `Samovar::Completion::Context` with: + +- `current`: The token being completed. +- `arguments`: The full truncated argument list. +- `environment`: The environment hash passed to `complete`. +- `row`: The parser row whose value is being completed. This can be an option or a positional argument. + +Providers can return strings, hashes, or `Samovar::Completion::Suggestion` instances: + +~~~ ruby +option "--mode ", "The mode.", + completions: [ + {value: "development", description: "Local development", type: :value, suffix: " "}, + {value: "production", description: "Production", type: :value} + ] +~~~ + +## Path Completion + +For path-like arguments, let the shell do native path expansion by using one of the native completion providers: + +~~~ ruby +class Process < Samovar::Command + options do + option "--output ", "The output path.", completions: :path + option "--root ", "The root directory.", completions: :directory + end + + one :input, "The input path.", completions: :file +end +~~~ + +Supported native providers: + +- `:path`: Complete files and directories using the shell. +- `:file`: Alias for `:path`. +- `:directory`: Complete directories using the shell. +- `:executable`: Complete executable commands using the shell. + +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. + +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. + +## Dedicated Completion Executable + +Shell adapters call a dedicated completion executable named `completion-`. This avoids running the normal command during completion. + +For a command named `falcon`, provide: + +~~~ text +bin/falcon +bin/completion-falcon +~~~ + +The completion executable can be very small: + +~~~ ruby +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/my/application" + +My::Application.complete +~~~ + +When the user completes a command by path, the shell adapter resolves the completion executable next to that command: + +~~~ text +falcon -> completion-falcon +bin/falcon -> bin/completion-falcon +./bin/falcon -> ./bin/completion-falcon +/path/falcon -> /path/completion-falcon +~~~ + +## Installing Shell Adapters + +Shell adapter generation and installation is provided by the `completion` gem. + +Generate an adapter script: + +~~~ bash +$ completion generate --shell zsh --command falcon +~~~ + +Install a generic adapter script into the default directory for the current shell: + +~~~ bash +$ completion install +~~~ + +The generic adapter checks whether a matching `completion-` executable exists before handling a command. You can install an adapter for a specific command instead: + +~~~ bash +$ completion install --command falcon +~~~ + +You can specify the shell and directory explicitly: + +~~~ bash +$ completion install --shell fish --directory ~/.config/fish/completions --command falcon +~~~ + +The installed adapter calls the matching `completion-` executable when completion is requested. + +## Testing Completion + +You can test completion directly without involving a shell: + +~~~ ruby +output = StringIO.new + +Application.complete(["serve", "--format", "j"], output: output) + +expect(output.string).to be == "value\tjson\t\n" +~~~ + +For a trailing-space completion, pass an empty final token: + +~~~ ruby +Application.complete(["serve", "--format", ""], output: output) +~~~ diff --git a/guides/links.yaml b/guides/links.yaml index 7f527b0..dfa73bf 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,2 +1,4 @@ getting-started: order: 1 +completion: + order: 2 diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 359c0d1..1816065 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -14,6 +14,7 @@ require_relative "output" require_relative "error" +require_relative "completion" module Samovar # Represents a command in the command-line interface. @@ -23,12 +24,13 @@ class Command # Parse and execute the command with the given input. # # This is the high-level entry point for CLI applications. It handles errors gracefully by printing usage and returning nil. + # The given arguments are passed to the parser, which consumes them as mutable input. # - # @parameter input [Array(String)] The command-line arguments to parse. + # @parameter arguments [Array(String)] The command-line arguments to parse. # @parameter output [IO] The output stream for error messages. # @returns [Object | Nil] The result of the command's call method, or nil if parsing/execution failed. - def self.call(input = ARGV, output: $stderr) - self.parse(input).call + def self.call(arguments = ARGV, output: $stderr) + self.parse(arguments).call rescue Error => error error.command.print_usage(output: output) do |formatter| formatter.map(error) @@ -37,27 +39,40 @@ def self.call(input = ARGV, output: $stderr) return nil end + # Complete the command-line input without executing the command. + # The given arguments are treated as the stable command-line boundary; completion internals consume derived input arrays. + # + # @parameter arguments [Array(String)] The command-line arguments to complete. + # @parameter environment [Hash] The environment for completion callbacks. + # @parameter output [IO] The output stream for printing completion results. + # @returns [Completion::Result] The completion result. + def self.complete(arguments = ARGV, environment: ENV, output: $stdout) + Completion.complete(self, arguments, environment: environment).tap do |result| + result.print(output) + end + end + # Parse the command-line input and create a command instance. # # This is the low-level parsing primitive. It raises {Error} exceptions on parsing failures. # For CLI applications, use {call} instead which handles errors gracefully. # - # @parameter input [Array(String)] The command-line arguments to parse. + # @parameter arguments [Array(String)] The command-line arguments to parse. # @returns [Command] The parsed command instance. # @raises [Error] If parsing fails. - def self.parse(input) - self.new(input) + def self.parse(arguments) + self.new(arguments) end # Create a new command instance with the given arguments. # # This is a convenience method for creating command instances with explicit arguments. # - # @parameter input [Array(String)] The command-line arguments to parse. + # @parameter arguments [Array(String)] The command-line arguments to parse. # @parameter options [Hash] Additional options to pass to the command. # @returns [Command] The command instance. - def self.[](*input, **options) - self.new(input, **options) + def self.[](*arguments, **options) + self.new(arguments, **options) end class << self diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb new file mode 100644 index 0000000..fdc56ef --- /dev/null +++ b/lib/samovar/completion.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "completion/context" +require_relative "completion/provider" +require_relative "completion/result" +require_relative "completion/suggestion" + +module Samovar + # Shell completion support for Samovar commands. + module Completion + # Complete the command line for the given command class. + # + # @parameter command_class [Class] The command class to complete. + # @parameter arguments [Array(String)] The application arguments. + # @parameter environment [Hash] The environment for completion callbacks. + # @returns [Result] The completion result. + def self.complete(command_class, arguments, environment: ENV) + Context.for(command_class, arguments, environment: environment).complete + end + end +end diff --git a/lib/samovar/completion/context.rb b/lib/samovar/completion/context.rb new file mode 100644 index 0000000..602cbaf --- /dev/null +++ b/lib/samovar/completion/context.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "result" + +module Samovar + module Completion + # The context provided to dynamic completion callbacks. + class Context + # Build a context for a command class and argument list. + # + # @parameter command_class [Class] The command class to complete. + # @parameter arguments [Array(String)] The truncated command-line arguments. + # @parameter environment [Hash] The environment for completion callbacks. + # @returns [Context] The completion context. + def self.for(command_class, arguments, environment: ENV) + return self.new( + command_class.table.merged, + arguments, + arguments.last || "", + environment: environment, + ) + end + + # Initialize a new completion context. + # + # @parameter table [Table] The command table to complete. + # @parameter arguments [Array(String)] The truncated command-line arguments. + # @parameter current [String] The token being completed. + # @parameter row [Object | Nil] The parser row whose value is being completed. + # @parameter environment [Hash] The environment for completion callbacks. + def initialize(table, arguments, current, row = nil, environment: ENV) + @table = table + @arguments = arguments + @current = current + @row = row + @environment = environment + end + + # @attribute [Table] The command table to complete. + attr :table + + # @attribute [Array(String)] The truncated command-line arguments. + attr :arguments + + # @attribute [String] The token being completed. + attr :current + + # @attribute [Object | Nil] The parser row whose value is being completed. + attr :row + + # @attribute [Hash] The environment for completion callbacks. + attr :environment + + # Create a context for completing the given parser row. + # + # @parameter row [Object] The parser row whose value is being completed. + # @returns [Context] The specialized completion context. + def with_row(row) + return self.class.new( + @table, + @arguments, + @current, + row, + environment: @environment, + ) + end + + # The completed words before the current token. + # + # @returns [Array(String)] The arguments before the token being completed. + def words + @arguments.take(@arguments.size - 1) + end + + # Complete the current command class. + # + # @returns [Result] The completion result. + def complete + complete_rows(@table, words) + end + + # Complete the given command class with completed words. + # + # @parameter command_class [Class] The command class to complete. + # @parameter words [Array(String)] The completed words before the current token. + # @returns [Result] The completion result. + def complete_command(command_class, words = []) + complete_rows(command_class.table.merged, words) + end + + # Complete the rows in a command table. + # The input array is mutable and may be consumed by parser rows. + # + # @parameter table [Table] The command table to complete. + # @parameter input [Array(String)] The mutable completed words to consume. + # @returns [Result] The completion result. + def complete_rows(table, input) + collected = [] + + table.each do |row| + if row.respond_to?(:complete) + if result = row.complete(input, self, collected) + return result + end + end + end + + return Result.new(collected) + end + end + end +end diff --git a/lib/samovar/completion/provider.rb b/lib/samovar/completion/provider.rb new file mode 100644 index 0000000..f90bb6c --- /dev/null +++ b/lib/samovar/completion/provider.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "result" +require_relative "suggestion" + +module Samovar + module Completion + # Expands static, dynamic, and native completion providers. + class Provider + # Initialize a new completion provider. + # + # @parameter context [Context] The completion context. + # @parameter completions [Array | Proc | Symbol | Nil] The static, dynamic, or native completions. + def initialize(context, completions) + @context = context + @completions = completions + end + + # Generate suggestions from the provider. + # + # @returns [Result] The matching completion suggestions. + def suggestions + case @completions + when nil + Result.new + when Symbol + native_suggestions + else + matching_suggestions + end + end + + protected + + # Generate matching suggestions from static or dynamic completions. + # + # @returns [Result] The matching completion suggestions. + def matching_suggestions + values = @completions + + if values.respond_to?(:call) + values = values.call(@context) + end + + values = Array(values).filter_map do |value| + suggestion = Suggestion.wrap(value) + + suggestion if suggestion.start_with?(@context.current) + end + + return Result.new(values) + end + + # Generate native shell completion requests. + # + # @returns [Result] The native completion request suggestions. + def native_suggestions + case @completions + when :path, :file + Result.new([Suggestion.new(@context.current, description: "Path", type: :path)]) + when :directory + Result.new([Suggestion.new(@context.current, description: "Directory", type: :directory)]) + when :executable + Result.new([Suggestion.new(@context.current, description: "Executable", type: :executable)]) + else + Result.new + end + end + + end + end +end diff --git a/lib/samovar/completion/result.rb b/lib/samovar/completion/result.rb new file mode 100644 index 0000000..887437d --- /dev/null +++ b/lib/samovar/completion/result.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Samovar + module Completion + # A collection of completion suggestions. + class Result + include Enumerable + + # Initialize a new completion result. + # + # @parameter suggestions [Array(Suggestion)] The suggestions in this result. + def initialize(suggestions = []) + @suggestions = suggestions + end + + # @attribute [Array(Suggestion)] The suggestions in this result. + attr :suggestions + + # Iterate over each suggestion. + # + # @yields {|suggestion| ...} The block to call for each suggestion. + def each(&block) + @suggestions.each(&block) + end + + # Whether this result contains no suggestions. + # + # @returns [Boolean] True if there are no suggestions. + def empty? + @suggestions.empty? + end + + # Combine this result with another result. + # + # @parameter other [Result] The other result to append. + # @returns [Result] The combined result. + def +(other) + self.class.new(@suggestions + other.suggestions) + end + + # Print suggestions as tab-separated completion records. + # + # @parameter output [IO] The output stream to write to. + def print(output = $stdout) + each do |suggestion| + output.puts suggestion.to_record + end + end + end + end +end diff --git a/lib/samovar/completion/suggestion.rb b/lib/samovar/completion/suggestion.rb new file mode 100644 index 0000000..100ee8d --- /dev/null +++ b/lib/samovar/completion/suggestion.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Samovar + module Completion + # A single completion suggestion. + class Suggestion + # Wrap a raw completion value in a suggestion. + # + # @parameter value [Suggestion | Hash | Object] The value to wrap. + # @returns [Suggestion] The normalized suggestion. + def self.wrap(value) + case value + when self + return value + when Hash + value = value.dup + suggestion = value.fetch(:value) + value.delete(:value) + + return self.new(suggestion, **value) + else + return self.new(value) + end + end + + # Initialize a new completion suggestion. + # + # @parameter value [Object] The completion value. + # @parameter type [Symbol | String | Nil] The completion type. + # @parameter description [String | Nil] The completion description. + # @parameter options [Hash] Additional completion metadata. + def initialize(value, type: nil, description: nil, **options) + @type = type + @value = value + @description = description + @options = options + end + + # @attribute [Symbol | String | Nil] The completion type. + attr :type + + # @attribute [Object] The completion value. + attr :value + + # @attribute [String | Nil] The completion description. + attr :description + + # @attribute [Hash] Additional completion metadata. + attr :options + + # Whether this suggestion starts with the given prefix. + # + # @parameter prefix [String] The prefix to check. + # @returns [Boolean] True if the suggestion starts with the given prefix. + def start_with?(prefix) + to_s.start_with?(prefix) + end + + # Convert the suggestion to a tab-separated completion record. + # + # @returns [String] The escaped completion record. + def to_record + fields = [ + escape(@type), + escape(@value), + escape(@description), + ] + @options.each do |key, value| + next if value.nil? + + fields << "#{escape(key)}=#{escape(value)}" + end + + return fields.join("\t") + end + + # Convert the suggestion to its value. + # + # @returns [String] The suggestion value. + def to_s + @value.to_s + end + + private + + def escape(value) + value.to_s.gsub(/[\t\r\n]/, " ") + end + end + end +end diff --git a/lib/samovar/flags.rb b/lib/samovar/flags.rb index c5eb7ec..41d3bf3 100644 --- a/lib/samovar/flags.rb +++ b/lib/samovar/flags.rb @@ -8,6 +8,8 @@ module Samovar # # Flags parse text like `-f/--flag ` into individual flag parsers. class Flags + include Enumerable + # Initialize a new flags parser. # # @parameter text [String] The flags specification string (e.g., `-f/--flag `). @@ -24,6 +26,21 @@ def each(&block) @ordered.each(&block) end + # Find the flag that matches the given token. + # + # @parameter token [String] The token to match. + # @returns [Flag | Nil] The matching flag. + def flag_for(token) + @ordered.find{|flag| flag.prefix?(token)} + end + + # The possible flag prefixes for completion. + # + # @returns [Array(String)] The flag prefixes and alternatives. + def completions + @ordered.flat_map(&:completions) + end + # Get the first flag. # # @returns [Flag] The first flag. @@ -132,6 +149,21 @@ def key def boolean? false end + + # Check if the token matches this flag. + # + # @parameter token [String] The token to check. + # @returns [Boolean] True if the token matches. + def prefix?(token) + @prefix == token or @alternatives&.include?(token) + end + + # The possible flag prefixes for completion. + # + # @returns [Array(String)] The flag prefix and alternatives. + def completions + [@prefix, *@alternatives] + end end # Represents a flag that accepts a value or acts as a boolean. diff --git a/lib/samovar/many.rb b/lib/samovar/many.rb index d40eb56..53ea9ca 100644 --- a/lib/samovar/many.rb +++ b/lib/samovar/many.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2016-2026, by Samuel Williams. +require_relative "completion" + module Samovar # Represents multiple positional arguments in a command. # @@ -15,12 +17,14 @@ class Many # @parameter stop [Regexp] A pattern that indicates the end of this argument list. # @parameter default [Object] The default value if no arguments are provided. # @parameter required [Boolean] Whether at least one argument is required. - def initialize(key, description = nil, stop: /^-/, default: nil, required: false) + # @parameter completions [Array | Proc | Nil] Completions for these arguments. + def initialize(key, description = nil, stop: /^-/, default: nil, required: false, completions: nil) @key = key @description = description @stop = stop @default = default @required = required + @completions = completions end # The name of the attribute to store the values in. @@ -48,6 +52,11 @@ def initialize(key, description = nil, stop: /^-/, default: nil, required: false # @attribute [Boolean] attr :required + # Completions for these arguments. + # + # @attribute [Array | Proc | Nil] + attr :completions + # Generate a string representation for usage output. # # @returns [String] The usage string. @@ -87,5 +96,27 @@ def parse(input, parent = nil, default = nil) raise MissingValueError.new(parent, @key) end end + + # Complete this repeating positional argument. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far. + # @returns [Completion::Result | Nil] A final completion result, or nil to continue. + def complete(input, context, collected) + if @stop + input.shift while input.any? && !(@stop === input.first) + + return nil if @stop === context.current + else + input.clear + end + + if input.empty? + return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions + end + + return nil + end end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index d7c96d2..5ef03ea 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2016-2026, by Samuel Williams. +require_relative "completion" + module Samovar # Represents nested sub-commands in a command. # @@ -85,16 +87,57 @@ def parse(input, parent = nil, default = nil) name = input.shift # puts "Instantiating #{command} with #{input}" - command.new(input, name: name, parent: parent) + command.new(input, name: name, parent: parent, output: parent&.output) elsif default return default elsif @default - @commands[@default].new(input, name: @default, parent: parent) + @commands[@default].new(input, name: @default, parent: parent, output: parent&.output) elsif @required raise MissingValueError.new(parent, @key) end end + # Complete nested command names or continue into a selected command. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far. + # @returns [Completion::Result | Nil] A final completion result, or nil to continue. + def complete(input, context, collected) + if input.empty? + result = suggestions(context) + + if result.empty? && @default + return Completion::Result.new(collected) + context.complete_command(@commands.fetch(@default)) + end + + return Completion::Result.new(collected) + result + end + + if command = @commands[input.first] + input.shift + return context.complete_command(command, input) + elsif @default + return context.complete_command(@commands.fetch(@default), input) + else + return Completion::Result.new(collected) + end + end + + # Complete nested command names for the current token. + # + # @parameter context [Completion::Context] The completion context. + # @returns [Completion::Result] The matching nested command suggestions. + def suggestions(context) + suggestions = @commands.collect do |name, command_class| + next unless name.start_with?(context.current) + + Completion::Suggestion.new(name, description: command_class.description, type: :command) + end.compact + + Completion::Result.new(suggestions) + end + # Generate usage information for this nested command. # # @parameter rows [Output::Rows] The rows to append usage information to. diff --git a/lib/samovar/one.rb b/lib/samovar/one.rb index b324e20..7ea6ae1 100644 --- a/lib/samovar/one.rb +++ b/lib/samovar/one.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2016-2026, by Samuel Williams. +require_relative "completion" + module Samovar # Represents a single positional argument in a command. # @@ -15,12 +17,14 @@ class One # @parameter pattern [Regexp] A pattern to match valid values. # @parameter default [Object] The default value if no argument is provided. # @parameter required [Boolean] Whether the argument is required. - def initialize(key, description, pattern: //, default: nil, required: false) + # @parameter completions [Array | Proc | Nil] Completions for this argument. + def initialize(key, description, pattern: //, default: nil, required: false, completions: nil) @key = key @description = description @pattern = pattern @default = default @required = required + @completions = completions end # The name of the attribute to store the value in. @@ -48,6 +52,11 @@ def initialize(key, description, pattern: //, default: nil, required: false) # @attribute [Boolean] attr :required + # Completions for this argument. + # + # @attribute [Array | Proc | Nil] + attr :completions + # Generate a string representation for usage output. # # @returns [String] The usage string. @@ -85,5 +94,22 @@ def parse(input, parent = nil, default = nil) raise MissingValueError.new(parent, @key) end end + + # Complete this positional argument. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far. + # @returns [Completion::Result | Nil] A final completion result, or nil to continue. + def complete(input, context, collected) + if input.empty? + return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions + elsif @pattern =~ input.first + input.shift + return nil + else + return Completion::Result.new(collected) + end + end end end diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index 074802a..17b13c0 100644 --- a/lib/samovar/option.rb +++ b/lib/samovar/option.rb @@ -5,6 +5,8 @@ require_relative "flags" require_relative "error" +require_relative "completion/provider" +require_relative "completion/result" module Samovar # Represents a single command-line option. @@ -20,8 +22,9 @@ class Option # @parameter value [Object | Nil] A fixed value to use regardless of user input. # @parameter type [Class | Proc | Nil] The type to coerce the value to. # @parameter required [Boolean] Whether the option is required. + # @parameter completions [Array | Proc | Nil] Completions for option values. # @yields {|value| ...} An optional block to transform the parsed value. - def initialize(flags, description, key: nil, default: nil, value: nil, type: nil, required: false, &block) + def initialize(flags, description, key: nil, default: nil, value: nil, type: nil, required: false, completions: nil, &block) @flags = Flags.new(flags) @description = description @@ -39,6 +42,7 @@ def initialize(flags, description, key: nil, default: nil, value: nil, type: nil @type = type @required = required + @completions = completions @block = block end @@ -59,8 +63,21 @@ def initialize(flags, description, key: nil, default: nil, value: nil, type: nil # The default value if the option is not provided. # - # @attribute [Object] - attr :default + # @returns [Object | Nil] The resolved default value. + def default + if @default.respond_to?(:call) + @default.call + else + @default + end + end + + # Whether this option has a default value. + # + # @returns [Boolean] True if the option has a default value. + def default? + !@default.nil? + end # A fixed value to use regardless of user input. # @@ -77,11 +94,52 @@ def initialize(flags, description, key: nil, default: nil, value: nil, type: nil # @attribute [Boolean] attr :required + # Completions for option values. + # + # @attribute [Array | Proc | Nil] + attr :completions + # An optional block to transform the parsed value. # # @attribute [Proc | Nil] attr :block + # Find the flag that matches the given token. + # + # @parameter token [String] The token to match. + # @returns [Flag | Nil] The matching flag. + def flag_for(token) + @flags.flag_for(token) + end + + # Whether this option consumes a value after the flag. + # + # @returns [Boolean] True if any flag for this option consumes a value. + def value? + @flags.any?{|flag| !flag.boolean?} + end + + # Complete values for this option. + # + # @parameter context [Completion::Context] The completion context. + # @returns [Completion::Result] The matching option value completions. + def suggestions(context) + suggestions = [] + context = context.with_row(self) + + if default? + suggestion = Completion::Provider.new(context, [default]).suggestions.first + + suggestions << suggestion if suggestion + end + + Completion::Provider.new(context, @completions).suggestions.each do |suggestion| + suggestions << suggestion unless suggestions.any?{|existing| existing.value == suggestion.value} + end + + Completion::Result.new(suggestions) + end + # Coerce the result to the specified type. # # @parameter result [Object] The value to coerce. @@ -141,8 +199,8 @@ def to_s # # @returns [Array] The usage array. def to_a - if @default - [@flags, @description, "(default: #{@default})"] + if default? + [@flags, @description, "(default: #{default})"] elsif @required [@flags, @description, "(required)"] else diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index db30ae2..84e9227 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -4,12 +4,15 @@ # Copyright, 2016-2025, by Samuel Williams. require_relative "option" +require_relative "completion" module Samovar # Represents a collection of command-line options. # # Options provide a DSL for defining multiple option flags in a single block. class Options + include Enumerable + # Parse and create an options collection from a block. # # @parameter arguments [Array] The arguments for the options collection. @@ -68,8 +71,10 @@ def initialize_dup(source) # The default values for options. # - # @attribute [Hash] - attr :defaults + # @returns [Hash] The resolved default values. + def defaults + @defaults.transform_values(&:default) + end # Freeze this options collection. # @@ -93,6 +98,21 @@ def each(&block) @ordered.each(&block) end + # Find the option that matches the given flag token. + # + # @parameter token [String] The flag token to match. + # @returns [Option | Nil] The matching option. + def option_for(token) + @keyed[token] + end + + # The possible flag prefixes for completion. + # + # @returns [Array(String)] The option flag prefixes and alternatives. + def completions + @ordered.flat_map{|option| option.flags.completions} + end + # Check if this options collection is empty. # # @returns [Boolean] True if there are no options. @@ -131,8 +151,8 @@ def << option end end - if default = option.default - @defaults[option.key] = option.default + if option.default? + @defaults[option.key] = option end end @@ -143,7 +163,7 @@ def << option # @parameter default [Hash | Nil] Default values to use. # @returns [Hash] The parsed option values. def parse(input, parent = nil, default = nil) - values = (default || @defaults).dup + values = (default || defaults).dup while option = @keyed[input.first] # prefix = input.first @@ -161,7 +181,71 @@ def parse(input, parent = nil, default = nil) end return values - end # Generate a string representation for usage output. + end + + # Complete option flags or option values. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far. + # @returns [Completion::Result | Nil] A final completion result, or nil to continue. + def complete(input, context, collected) + result = consume(input, context) + return result if result + + return nil unless input.empty? + + flags = suggestions(context.current) + + if context.current.start_with?("-") && flags.any? + return Completion::Result.new(flags) + elsif context.current.empty? + collected.concat(flags) + return nil + end + + return nil + end + + # Consume option tokens before the current completion position. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @returns [Completion::Result | Nil] A completion result for an option value, or nil to continue. + def consume(input, context) + while token = input.first + break unless option = option_for(token) + + flag = option.flag_for(token) + input.shift + + if flag && !flag.boolean? + if input.any? + input.shift + else + return option.suggestions(context) + end + end + end + + return nil + end + + # Complete option flags for the given prefix. + # + # @parameter prefix [String] The option prefix being completed. + # @returns [Array(Completion::Suggestion)] The matching option flag suggestions. + def suggestions(prefix) + flat_map do |option| + option.flags.completions.collect do |value| + next unless value.start_with?(prefix) + + Completion::Suggestion.new(value, description: option.description, type: :option) + end + end.compact + end + + # Generate a string representation for usage output. # # @returns [String] The usage string. def to_s diff --git a/lib/samovar/split.rb b/lib/samovar/split.rb index 6e3ddbe..2e5543e 100644 --- a/lib/samovar/split.rb +++ b/lib/samovar/split.rb @@ -3,6 +3,8 @@ # Released under the MIT License. # Copyright, 2016-2025, by Samuel Williams. +require_relative "completion" + module Samovar # Represents a split point in the command-line arguments. # @@ -15,12 +17,14 @@ class Split # @parameter marker [String] The marker that indicates the split point. # @parameter default [Object] The default value if no split is present. # @parameter required [Boolean] Whether the split is required. - def initialize(key, description, marker: "--", default: nil, required: false) + # @parameter completions [Array | Proc | Nil] Completions for split arguments. + def initialize(key, description, marker: "--", default: nil, required: false, completions: nil) @key = key @description = description @marker = marker @default = default @required = required + @completions = completions end # The name of the attribute to store the values after the split. @@ -48,6 +52,11 @@ def initialize(key, description, marker: "--", default: nil, required: false) # @attribute [Boolean] attr :required + # Completions for split arguments. + # + # @attribute [Array | Proc | Nil] + attr :completions + # Generate a string representation for usage output. # # @returns [String] The usage string. @@ -85,5 +94,40 @@ def parse(input, parent = nil, default = nil) raise MissingValueError.new(parent, @key) end end + + # Complete the split marker or arguments after it. + # + # @parameter input [Array(String)] Previously completed command-line arguments. + # @parameter context [Completion::Context] The completion context. + # @parameter collected [Array(Completion::Suggestion)] Suggestions collected so far. + # @returns [Completion::Result | Nil] A final completion result, or nil to continue. + def complete(input, context, collected) + if offset = input.index(@marker) + input.shift(offset + 1) + + if @completions == :executable && input.any? + return Completion::Result.new(collected + [ + Completion::Suggestion.new( + input.first, + description: "Delegate completion", + type: :delegate, + index: context.words.index(@marker) + 1, + ), + ]) + end + + return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions + end + + return Completion::Result.new(collected) unless input.empty? + + suggestions = [] + + if @marker.start_with?(context.current) + suggestions << Completion::Suggestion.new(@marker, description: @description, type: :split) + end + + return Completion::Result.new(collected + suggestions) + end end end diff --git a/readme.md b/readme.md index 7d88272..c189532 100644 --- a/readme.md +++ b/readme.md @@ -17,6 +17,7 @@ One of the other issues I had with existing frameworks is testability. Most fram Please see the [project documentation](https://ioquatix.github.io/samovar/) for more details. - [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. + - [Completion](https://ioquatix.github.io/samovar/guides/completion/index) - This guide explains how to add shell completion to commands built with `samovar`. ## Releases @@ -106,12 +107,6 @@ command list -- --help 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). -### Shell Auto-completion - -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. - -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. - ### Short/Long Help 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. diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb new file mode 100644 index 0000000..4b2b17f --- /dev/null +++ b/test/samovar/completion.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "samovar" +require "sus/fixtures/temporary_directory_context" + +class CompletionLeaf < Samovar::Command + self.description = "Leaf command." + + def self.path_completions(context) + ["app.rb", "readme.md", "test.rb"] + end + + format_completions = lambda do |context| + if context.row.is_a?(Samovar::Option) && context.row.key == :format + return ["json", "text", "yaml"] + end + return [] + end + + options do + option "--format ", "The output format.", default: "text", completions: format_completions + option "--output ", "The output path.", completions: :path + option "--root ", "The root directory.", completions: :directory + option "--verbose | --quiet", "Verbosity of output for debugging.", key: :logging + option "--[no]-color", "Enable or disable color output.", default: true + end + + one :path, "The path to process.", completions: method(:path_completions) + many :extras, "Extra values.", completions: ->(context){["extra-a", "extra-b", context.environment["EXTRA"]].compact} + split :argv, "Additional arguments.", completions: :executable +end + +class CompletionList < Samovar::Command + self.description = "List things." + + options do + option "--all", "List all things." + end +end + +class CompletionTop < Samovar::Command + self.description = "Top command." + + options do + option "-c/--configuration ", "Specify a configuration." + option "-v/--verbose", "Enable verbose output." + end + + nested :command, { + "leaf" => CompletionLeaf, + "list" => CompletionList, + }, default: "leaf" +end + +describe Samovar::Completion do + include Sus::Fixtures::TemporaryDirectoryContext + + def values(result) + result.collect(&:value) + end + + def complete(input, **options) + CompletionTop.complete(input, output: StringIO.new, **options) + end + + it "completes top-level option flags" do + result = complete(["--ver"]) + + expect(values(result)).to be == ["--verbose"] + end + + it "completes top-level options and commands for an empty token" do + result = complete([""]) + + expect(values(result)).to be == ["--configuration", "-c", "--verbose", "-v", "leaf", "list"] + end + + it "completes nested command names" do + result = complete(["le"]) + + expect(values(result)).to be == ["leaf"] + end + + it "completes nested command options" do + result = complete(["leaf", "--no"]) + + expect(values(result)).to be == ["--no-color"] + end + + it "completes boolean flag variants" do + result = complete(["leaf", "--"]) + + expect(values(result)).to be(:include?, "--color") + expect(values(result)).to be(:include?, "--no-color") + end + + it "completes option values using static completions" do + result = complete(["leaf", "--format", "j"]) + + expect(values(result)).to be == ["json"] + end + + it "requests native path completion for option values" do + result = complete(["leaf", "--output", "tmp/"]) + suggestion = result.first + + expect(suggestion.value).to be == "tmp/" + expect(suggestion.type).to be == :path + end + + it "requests native directory completion for option values" do + result = complete(["leaf", "--root", "tmp/"]) + suggestion = result.first + + expect(suggestion.value).to be == "tmp/" + expect(suggestion.type).to be == :directory + end + + it "completes option defaults before static completions" do + result = complete(["leaf", "--format", ""]) + + expect(values(result)).to be == ["text", "json", "yaml"] + end + + it "completes option values after a trailing option flag" do + result = complete(["leaf", "--format", ""]) + + expect(values(result)).to be == ["text", "json", "yaml"] + end + + it "completes positional values using method completions" do + result = complete(["leaf", "r"]) + + expect(values(result)).to be == ["readme.md"] + end + + it "completes many values using callable completions" do + result = complete(["leaf", "app.rb", "e"], environment: {"EXTRA" => "env-extra"}) + + expect(values(result)).to be == ["extra-a", "extra-b", "env-extra"] + end + + it "completes split marker before many consumes option-looking tokens" do + result = complete(["leaf", "app.rb", "--"]) + + expect(values(result)).to be == ["--"] + end + + it "completes split values after the marker" do + result = complete(["leaf", "app.rb", "--", "ru"]) + suggestion = result.first + + expect(suggestion.value).to be == "ru" + expect(suggestion.type).to be == :executable + end + + it "delegates split completion after the executable" do + output = StringIO.new + + result = CompletionTop.complete(["leaf", "app.rb", "--", "ruby", "--ver"], output: output) + suggestion = result.first + + expect(suggestion.value).to be == "ruby" + expect(suggestion.type).to be == :delegate + expect(suggestion.options).to be == {index: 3} + expect(output.string).to be == "delegate\truby\tDelegate completion\tindex=3\n" + end + + it "uses default nested command for option-looking completions" do + result = complete(["--no"]) + + expect(values(result)).to be == ["--no-color"] + end + + it "prints completion results as TSV" do + output = StringIO.new + result = complete(["le"]) + + result.print(output) + + expect(output.string).to be == "command\tleaf\tLeaf command.\n" + end + + it "prints completion metadata as trailing TSV fields" do + output = StringIO.new + result = Samovar::Completion::Result.new([ + Samovar::Completion::Suggestion.new( + "tmp\nfile", + description: "A\tpath", + type: :path, + suffix: "/", + empty: nil, + ), + ]) + result.print(output) + expect(output.string).to be == "path\ttmp file\tA path\tsuffix=/\n" + end + + it "wraps existing suggestions" do + suggestion = Samovar::Completion::Suggestion.new("value", type: :value) + + expect(Samovar::Completion::Suggestion.wrap(suggestion)).to be == suggestion + end + + it "wraps hash suggestions" do + suggestion = Samovar::Completion::Suggestion.wrap( + value: "value", + description: "Description", + type: :value, + suffix: " ", + ) + + expect(suggestion.value).to be == "value" + expect(suggestion.description).to be == "Description" + expect(suggestion.type).to be == :value + expect(suggestion.options).to be == {suffix: " "} + end + + it "returns no suggestions for missing completions" do + context = Samovar::Completion::Context.for(CompletionTop, [""]) + provider = Samovar::Completion::Provider.new(context, nil) + + expect(provider.suggestions).to be(:empty?) + end + + it "returns no suggestions for unknown native completions" do + context = Samovar::Completion::Context.for(CompletionTop, [""]) + provider = Samovar::Completion::Provider.new(context, :unknown) + + expect(provider.suggestions).to be(:empty?) + end + + it "returns collected suggestions after completing all rows" do + context = Samovar::Completion::Context.for(CompletionTop, [""]) + table = [Object.new] + + result = context.complete_rows(table, []) + + expect(result).to be(:empty?) + end + + it "uses the final argument as the completion token" do + output = StringIO.new + + result = CompletionTop.complete(["le"], output: output) + + expect(values(result)).to be == ["leaf"] + expect(output.string).to be == "command\tleaf\tLeaf command.\n" + end + + it "uses an empty token when completing with no arguments" do + output = StringIO.new + + result = CompletionTop.complete([""], output: output) + + expect(values(result)).to be(:include?, "leaf") + expect(output.string).to be(:include?, "command\tleaf\tLeaf command.\n") + expect(output.string).to be(:include?, "option\t--verbose\tEnable verbose output.\n") + end +end diff --git a/test/samovar/nested.rb b/test/samovar/nested.rb index d9a03d7..c3b648e 100644 --- a/test/samovar/nested.rb +++ b/test/samovar/nested.rb @@ -131,6 +131,13 @@ class Outer < Samovar::Command expect(outer.command.parent).to be_equal(outer) end + it "passes output to nested commands" do + output = StringIO.new + outer = Outer.new(["inner-a"], output: output) + + expect(outer.command.output).to be_equal(output) + end + # it "should parse help option at outer level" do # outer = Outer['inner-a', '--help'] # expect(outer.options[:help]).to_be truthy diff --git a/test/samovar/options.rb b/test/samovar/options.rb index df477d4..fb2ba18 100644 --- a/test/samovar/options.rb +++ b/test/samovar/options.rb @@ -3,7 +3,7 @@ # Released under the MIT License. # Copyright, 2017-2026, by Samuel Williams. -require "samovar/options" +require "samovar" describe Samovar::Options do let(:options) do @@ -20,6 +20,25 @@ expect(values).to be == {x: 2} end + it "should resolve callable defaults" do + count = 0 + + options = subject.parse do + option "--value ", "The value", default: ->{count += 1} + end + + expect(options.parse([], nil, nil)).to be == {value: 1} + expect(options.parse([], nil, nil)).to be == {value: 2} + end + + it "should preserve false defaults" do + options = subject.parse do + option "--enabled", "Whether enabled", default: false + end + + expect(options.parse([], nil, nil)).to be == {enabled: false} + end + it "should preserve current values" do values = options.parse([], nil, {x: 1, y: 2, z: 3}) expect(values).to be == {x: 1, y: 2, z: 3} @@ -161,4 +180,3 @@ def initialize(value) end end end -