From 7162731c36aeabbd11f068bd1a57022366a85c62 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 14:19:38 +1200 Subject: [PATCH 01/36] Add shell completion support --- lib/samovar/command.rb | 18 ++- lib/samovar/completion.rb | 313 +++++++++++++++++++++++++++++++++++++ lib/samovar/flags.rb | 32 ++++ lib/samovar/many.rb | 9 +- lib/samovar/one.rb | 9 +- lib/samovar/option.rb | 24 ++- lib/samovar/options.rb | 17 ++ lib/samovar/split.rb | 9 +- readme.md | 38 ++++- test/samovar/completion.rb | 154 ++++++++++++++++++ 10 files changed, 612 insertions(+), 11 deletions(-) create mode 100644 lib/samovar/completion.rb create mode 100644 test/samovar/completion.rb diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 359c0d1..5ddaeb7 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. @@ -27,7 +28,12 @@ class Command # @parameter input [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) + def self.call(input = ARGV, output: $stderr, completion_output: $stdout) + if (index = ENV["SAMOVAR_COMPLETE"]) && !index.empty? + Completion.print(self.complete(input, index: index), completion_output) + return true + end + self.parse(input).call rescue Error => error error.command.print_usage(output: output) do |formatter| @@ -49,6 +55,16 @@ def self.parse(input) self.new(input) end + # Complete the command-line input without executing the command. + # + # @parameter input [Array(String)] The command-line arguments to complete. + # @parameter index [Integer] The zero-based application argument cursor index. + # @parameter environment [Hash] The environment for completion callbacks. + # @returns [Completion::Result] The completion result. + def self.complete(input = ARGV, index:, environment: ENV) + Completion.complete(self, input, index: index, environment: environment) + end + # Create a new command instance with the given arguments. # # This is a convenience method for creating command instances with explicit arguments. diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb new file mode 100644 index 0000000..7d66fe0 --- /dev/null +++ b/lib/samovar/completion.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Samovar + # Shell completion support for Samovar commands. + module Completion + # A single completion candidate. + Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do + def to_s + value.to_s + end + end + + # The result of a completion request. + class Result + include Enumerable + + def initialize(suggestions = []) + @suggestions = suggestions + end + + attr :suggestions + + def each(&block) + @suggestions.each(&block) + end + + def empty? + @suggestions.empty? + end + + def +(other) + self.class.new(@suggestions + other.suggestions) + end + end + + # The context provided to dynamic completion callbacks. + Context = Struct.new(:command_class, :argv, :index, :current, :row, :option, :environment, keyword_init: true) + + # Complete the command line for the given command class. + # + # @parameter command_class [Class] The command class to complete. + # @parameter argv [Array(String)] The application arguments. + # @parameter index [Integer] The zero-based application argument cursor index. + # @parameter environment [Hash] The environment for completion callbacks. + # @returns [Result] The completion result. + def self.complete(command_class, argv, index:, environment: ENV) + argv = argv.collect(&:to_s) + index = Integer(index) + + if index < 0 || index > argv.size + raise ArgumentError, "Completion index out of range: #{index}" + end + + current = index < argv.size ? argv[index] : "" + words = argv.take(index) + + context = Context.new( + command_class: command_class, + argv: argv, + index: index, + current: current, + environment: environment, + ) + + complete_command(command_class, words, context) + end + + # Print the completion result in a stable TSV format. + # + # @parameter result [Result] The result to print. + # @parameter output [IO] The output stream. + def self.print(result, output = $stdout) + result.each do |suggestion| + output.puts [ + escape(suggestion.value), + escape(suggestion.description), + escape(suggestion.type), + ].join("\t") + end + end + + # Generate a shell completion script for an executable. + # + # @parameter shell [String | Symbol] The shell name: bash, zsh, or fish. + # @parameter executable [String] The executable name. + # @returns [String] The shell completion script. + def self.script(shell:, executable:) + case shell.to_sym + when :bash + bash_script(executable) + when :zsh + zsh_script(executable) + when :fish + fish_script(executable) + else + raise ArgumentError, "Unsupported shell: #{shell.inspect}" + end + end + + def self.complete_command(command_class, words, context) + complete_rows(command_class, command_class.table.merged, words.dup, context) + end + + def self.complete_rows(command_class, table, input, context) + collected = [] + + table.each do |row| + case row + when Options + result = consume_options(row, input, context) + return result if result + + if input.empty? + flags = option_suggestions(row, context.current) + + if context.current.start_with?("-") && flags.any? + return Result.new(flags) + elsif context.current.empty? + collected.concat(flags) + end + end + when Nested + if input.empty? + result = nested_suggestions(row, context) + + if result.empty? && context.current.start_with?("-") && row.default + return Result.new(collected) + complete_command(row.commands.fetch(row.default), [], context) + end + + return Result.new(collected) + result + elsif command_class = row.commands[input.first] + input.shift + return complete_command(command_class, input, context) + else + return Result.new(collected) + end + when One + if input.empty? + return Result.new(collected) + provider_suggestions(row.completions, context, row: row) + elsif row.pattern =~ input.first + input.shift + else + return Result.new(collected) + end + when Many + if row.stop + input.shift while input.any? && !(row.stop === input.first) + + next if row.stop === context.current + else + input.clear + end + + if input.empty? + return Result.new(collected) + provider_suggestions(row.completions, context, row: row) + end + when Split + if offset = input.index(row.marker) + input.shift(offset + 1) + return Result.new(collected) + provider_suggestions(row.completions, context, row: row) + elsif input.empty? + suggestions = [] + + if row.marker.start_with?(context.current) + suggestions << Suggestion.new(value: row.marker, description: row.description, type: :split) + end + + return Result.new(collected + suggestions) + else + return Result.new(collected) + end + end + end + + Result.new(collected) + end + + def self.consume_options(options, input, context) + while token = input.first + option = options.option_for(token) + break unless option + + flag = option.flag_for(token) + input.shift + + if flag && !flag.boolean? + if input.any? + input.shift + else + return provider_suggestions(option.completions, context, row: options, option: option) + end + end + end + + nil + end + + def self.option_suggestions(options, prefix) + options.flat_map do |option| + option.flags.completions.collect do |value| + next unless value.start_with?(prefix) + + Suggestion.new(value: value, description: option.description, type: :option) + end + end.compact + end + + def self.nested_suggestions(nested, context) + suggestions = nested.commands.collect do |name, command_class| + next unless name.start_with?(context.current) + + Suggestion.new(value: name, description: command_class.description, type: :command) + end.compact + + Result.new(suggestions) + end + + def self.provider_suggestions(provider, context, row:, option: nil) + return Result.new unless provider + + context = context.dup + context.row = row + context.option = option + + values = provider.respond_to?(:call) ? provider.call(context) : provider + + Result.new(Array(values).filter_map do |value| + suggestion = wrap_suggestion(value) + + suggestion if suggestion.value.to_s.start_with?(context.current) + end) + end + + def self.wrap_suggestion(value) + case value + when Suggestion + value + when Hash + Suggestion.new(**value) + else + Suggestion.new(value: value) + end + end + + def self.escape(value) + value.to_s.gsub(/[\t\r\n]/, " ") + end + + def self.function_name(executable) + "_#{executable.gsub(/[^a-zA-Z0-9_]/, "_")}_completion" + end + + def self.bash_script(executable) + function = function_name(executable) + + <<~SCRIPT + #{function}() { + local index=$((COMP_CWORD - 1)) + local argv=("${COMP_WORDS[@]:1}") + COMPREPLY=() + + while IFS=$'\\t' read -r value description type; do + COMPREPLY+=("$value") + done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") + } + + complete -F #{function} #{executable} + SCRIPT + end + + def self.zsh_script(executable) + function = function_name(executable) + + <<~SCRIPT + #compdef #{executable} + + #{function}() { + local index=$((CURRENT - 2)) + local -a argv + argv=("${words[@]:1}") + + local -a completions + while IFS=$'\\t' read -r value description type; do + completions+=("${value}:${description}") + done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") + + _describe '#{executable}' completions + } + + #{function} + SCRIPT + end + + def self.fish_script(executable) + <<~SCRIPT + function __#{function_name(executable)} --description 'Complete #{executable}' + set -l argv (commandline -opc) + set -e argv[1] + set -l index (math (count $argv) - 1) + + SAMOVAR_COMPLETE="$index" #{executable} $argv | while read -l line + echo $line + end + end + + complete -c #{executable} -f -a "(__#{function_name(executable)})" + SCRIPT + 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..dfa945b 100644 --- a/lib/samovar/many.rb +++ b/lib/samovar/many.rb @@ -15,12 +15,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 +50,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. diff --git a/lib/samovar/one.rb b/lib/samovar/one.rb index b324e20..3d67d27 100644 --- a/lib/samovar/one.rb +++ b/lib/samovar/one.rb @@ -15,12 +15,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 +50,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. diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index 074802a..d4ae6d9 100644 --- a/lib/samovar/option.rb +++ b/lib/samovar/option.rb @@ -20,8 +20,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 +40,7 @@ def initialize(flags, description, key: nil, default: nil, value: nil, type: nil @type = type @required = required + @completions = completions @block = block end @@ -77,11 +79,31 @@ 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 + # Coerce the result to the specified type. # # @parameter result [Object] The value to coerce. diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index db30ae2..5bba7a2 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -10,6 +10,8 @@ module Samovar # # 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. @@ -93,6 +95,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. diff --git a/lib/samovar/split.rb b/lib/samovar/split.rb index 6e3ddbe..5861dac 100644 --- a/lib/samovar/split.rb +++ b/lib/samovar/split.rb @@ -15,12 +15,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 +50,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. diff --git a/readme.md b/readme.md index 5c8af50..b31a6b3 100644 --- a/readme.md +++ b/readme.md @@ -18,6 +18,38 @@ Please see the [project documentation](https://ioquatix.github.io/samovar/) for - [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. +### Shell Auto-completion + +Samovar can complete command lines using the same command grammar used for parsing. Static completions are generated automatically for options, option aliases, boolean negation flags, and nested command names. + +You can provide value completions for options and positional arguments using `completions:`: + +``` ruby +class Command < Samovar::Command + def self.path_completions(context) + Dir.glob("#{context.current}*") + end + + options do + option "--format ", "Output format.", completions: ["json", "text", "yaml"] + end + + one :path, "Path to process.", completions: method(:path_completions) +end +``` + +Completion mode is enabled by setting `SAMOVAR_COMPLETE` to the zero-based cursor index in the application arguments: + +``` shell +SAMOVAR_COMPLETE=1 command --for +``` + +Applications can also generate shell adapter scripts: + +``` ruby +puts Samovar::Completion.script(shell: :bash, executable: "command") +``` + ## Releases Please see the [project releases](https://ioquatix.github.io/samovar/releases/index) for all releases. @@ -104,12 +136,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..9c19915 --- /dev/null +++ b/test/samovar/completion.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "samovar" + +class CompletionLeaf < Samovar::Command + self.description = "Leaf command." + + def self.path_completions(context) + ["app.rb", "readme.md", "test.rb"] + end + + options do + option "--format ", "The output format.", completions: ["json", "text", "yaml"] + 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: ["--child"] +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 + def values(result) + result.collect(&:value) + end + + it "completes top-level option flags" do + result = CompletionTop.complete(["--ver"], index: 0) + + expect(values(result)).to be == ["--verbose"] + end + + it "completes top-level options and commands for an empty token" do + result = CompletionTop.complete([], index: 0) + + expect(values(result)).to be == ["--configuration", "-c", "--verbose", "-v", "leaf", "list"] + end + + it "completes nested command names" do + result = CompletionTop.complete(["le"], index: 0) + + expect(values(result)).to be == ["leaf"] + end + + it "completes nested command options" do + result = CompletionTop.complete(["leaf", "--no"], index: 1) + + expect(values(result)).to be == ["--no-color"] + end + + it "completes boolean flag variants" do + result = CompletionTop.complete(["leaf", "--"], index: 1) + + 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 = CompletionTop.complete(["leaf", "--format", "j"], index: 2) + + expect(values(result)).to be == ["json"] + end + + it "completes option values after a trailing option flag" do + result = CompletionTop.complete(["leaf", "--format"], index: 2) + + expect(values(result)).to be == ["json", "text", "yaml"] + end + + it "completes positional values using method completions" do + result = CompletionTop.complete(["leaf", "r"], index: 1) + + expect(values(result)).to be == ["readme.md"] + end + + it "completes many values using callable completions" do + result = CompletionTop.complete(["leaf", "app.rb", "e"], index: 2, 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 = CompletionTop.complete(["leaf", "app.rb", "--"], index: 2) + + expect(values(result)).to be == ["--"] + end + + it "completes split values after the marker" do + result = CompletionTop.complete(["leaf", "app.rb", "--", "--c"], index: 3) + + expect(values(result)).to be == ["--child"] + end + + it "uses default nested command for option-looking completions" do + result = CompletionTop.complete(["--no"], index: 0) + + expect(values(result)).to be == ["--no-color"] + end + + it "prints completion results as TSV" do + output = StringIO.new + result = CompletionTop.complete(["le"], index: 0) + + subject.print(result, output) + + expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" + end + + it "uses SAMOVAR_COMPLETE as the cursor index in call" do + output = StringIO.new + + begin + ENV["SAMOVAR_COMPLETE"] = "0" + result = CompletionTop.call(["le"], completion_output: output) + ensure + ENV.delete("SAMOVAR_COMPLETE") + end + + expect(result).to be == true + expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" + end + + it "generates shell completion scripts" do + expect(subject.script(shell: :bash, executable: "samovar")).to be(:include?, "SAMOVAR_COMPLETE") + expect(subject.script(shell: :zsh, executable: "samovar")).to be(:include?, "#compdef samovar") + expect(subject.script(shell: :fish, executable: "samovar")).to be(:include?, "complete -c samovar") + end +end From 34c44ff3f85e34c3581af92e0bc71a394eadba89 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 14:22:10 +1200 Subject: [PATCH 02/36] Move completion behavior onto parser rows --- lib/samovar/completion.rb | 73 ++++----------------------------------- lib/samovar/many.rb | 22 ++++++++++++ lib/samovar/nested.rb | 27 +++++++++++++++ lib/samovar/one.rb | 19 ++++++++++ lib/samovar/options.rb | 27 ++++++++++++++- lib/samovar/split.rb | 25 ++++++++++++++ 6 files changed, 125 insertions(+), 68 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 7d66fe0..74a8972 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -101,78 +101,17 @@ def self.script(shell:, executable:) end def self.complete_command(command_class, words, context) - complete_rows(command_class, command_class.table.merged, words.dup, context) + complete_rows(command_class.table.merged, words.dup, context) end - def self.complete_rows(command_class, table, input, context) + def self.complete_rows(table, input, context) collected = [] table.each do |row| - case row - when Options - result = consume_options(row, input, context) - return result if result - - if input.empty? - flags = option_suggestions(row, context.current) - - if context.current.start_with?("-") && flags.any? - return Result.new(flags) - elsif context.current.empty? - collected.concat(flags) - end - end - when Nested - if input.empty? - result = nested_suggestions(row, context) - - if result.empty? && context.current.start_with?("-") && row.default - return Result.new(collected) + complete_command(row.commands.fetch(row.default), [], context) - end - - return Result.new(collected) + result - elsif command_class = row.commands[input.first] - input.shift - return complete_command(command_class, input, context) - else - return Result.new(collected) - end - when One - if input.empty? - return Result.new(collected) + provider_suggestions(row.completions, context, row: row) - elsif row.pattern =~ input.first - input.shift - else - return Result.new(collected) - end - when Many - if row.stop - input.shift while input.any? && !(row.stop === input.first) - - next if row.stop === context.current - else - input.clear - end - - if input.empty? - return Result.new(collected) + provider_suggestions(row.completions, context, row: row) - end - when Split - if offset = input.index(row.marker) - input.shift(offset + 1) - return Result.new(collected) + provider_suggestions(row.completions, context, row: row) - elsif input.empty? - suggestions = [] - - if row.marker.start_with?(context.current) - suggestions << Suggestion.new(value: row.marker, description: row.description, type: :split) - end - - return Result.new(collected + suggestions) - else - return Result.new(collected) - end - end + next unless row.respond_to?(:complete) + + result = row.complete(input, context, collected) + return result if result end Result.new(collected) diff --git a/lib/samovar/many.rb b/lib/samovar/many.rb index dfa945b..e05c487 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. # @@ -94,5 +96,25 @@ 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 if @stop === context.current + else + input.clear + end + + if input.empty? + Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + end + end end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index d7c96d2..4118e19 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. # @@ -95,6 +97,31 @@ def parse(input, parent = nil, default = nil) 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 = Completion.nested_suggestions(self, context) + + if result.empty? && context.current.start_with?("-") && @default + return Completion::Result.new(collected) + Completion.complete_command(@commands.fetch(@default), [], context) + end + + return Completion::Result.new(collected) + result + end + + if command = @commands[input.first] + input.shift + Completion.complete_command(command, input, context) + else + Completion::Result.new(collected) + end + 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 3d67d27..25c656d 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. # @@ -92,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? + Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + elsif @pattern =~ input.first + input.shift + nil + else + Completion::Result.new(collected) + end + end end end diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index 5bba7a2..4321662 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -4,6 +4,7 @@ # Copyright, 2016-2025, by Samuel Williams. require_relative "option" +require_relative "completion" module Samovar # Represents a collection of command-line options. @@ -178,7 +179,31 @@ 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 = Completion.consume_options(self, input, context) + return result if result + + return unless input.empty? + + flags = Completion.option_suggestions(self, context.current) + + if context.current.start_with?("-") && flags.any? + Completion::Result.new(flags) + elsif context.current.empty? + collected.concat(flags) + nil + end + 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 5861dac..3e38f55 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. # @@ -92,5 +94,28 @@ 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) + return Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + end + + return Completion::Result.new(collected) unless input.empty? + + suggestions = [] + + if @marker.start_with?(context.current) + suggestions << Completion::Suggestion.new(value: @marker, description: @description, type: :split) + end + + Completion::Result.new(collected + suggestions) + end end end From dec9ba5f224349e1156ef7c240ef3cd84dcd4888 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 14:36:44 +1200 Subject: [PATCH 03/36] Add samovar completions command --- exe/samovar | 9 +++++++ lib/samovar/command/completions.rb | 39 +++++++++++++++++++++++++++++ lib/samovar/completion.rb | 6 +++-- lib/samovar/nested.rb | 4 +-- readme.md | 6 +++-- samovar.gemspec | 4 ++- test/samovar/command/completions.rb | 32 +++++++++++++++++++++++ test/samovar/nested.rb | 7 ++++++ 8 files changed, 100 insertions(+), 7 deletions(-) create mode 100755 exe/samovar create mode 100644 lib/samovar/command/completions.rb create mode 100644 test/samovar/command/completions.rb diff --git a/exe/samovar b/exe/samovar new file mode 100755 index 0000000..a206852 --- /dev/null +++ b/exe/samovar @@ -0,0 +1,9 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "samovar/command/completions" + +Samovar::CommandLine::Top.call diff --git a/lib/samovar/command/completions.rb b/lib/samovar/command/completions.rb new file mode 100644 index 0000000..5910798 --- /dev/null +++ b/lib/samovar/command/completions.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../command" + +module Samovar + module CommandLine + # Generate shell completion adapter scripts for Samovar-based commands. + class Completions < Command + self.description = "Generate shell completion adapter scripts." + + one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] + one :executable, "The command executable to complete.", required: true + + def call + output.puts Completion.script(shell: @shell.to_sym, executable: @executable) + end + end + + # The Samovar command-line interface. + class Top < Command + self.description = "Utilities for Samovar-based command-line applications." + + options do + option "-h/--help", "Print out help information." + end + + nested :command, { + "completions" => Completions, + }, required: true + + def call + @command.call + end + end + end +end diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 74a8972..f3c7deb 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -234,8 +234,10 @@ def self.zsh_script(executable) end def self.fish_script(executable) + function = function_name(executable) + <<~SCRIPT - function __#{function_name(executable)} --description 'Complete #{executable}' + function #{function} --description 'Complete #{executable}' set -l argv (commandline -opc) set -e argv[1] set -l index (math (count $argv) - 1) @@ -245,7 +247,7 @@ def self.fish_script(executable) end end - complete -c #{executable} -f -a "(__#{function_name(executable)})" + complete -c #{executable} -f -a "(#{function})" SCRIPT end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index 4118e19..dd566e2 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -87,11 +87,11 @@ 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 diff --git a/readme.md b/readme.md index b31a6b3..5af182e 100644 --- a/readme.md +++ b/readme.md @@ -46,8 +46,10 @@ SAMOVAR_COMPLETE=1 command --for Applications can also generate shell adapter scripts: -``` ruby -puts Samovar::Completion.script(shell: :bash, executable: "command") +``` shell +samovar completions bash command +samovar completions zsh command +samovar completions fish command ``` ## Releases diff --git a/samovar.gemspec b/samovar.gemspec index 6cdd325..7f0fae1 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -21,7 +21,9 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/ioquatix/samovar.git", } - spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) + spec.bindir = "exe" + spec.executables = ["samovar"] + spec.files = Dir.glob(["{context,exe,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.3" diff --git a/test/samovar/command/completions.rb b/test/samovar/command/completions.rb new file mode 100644 index 0000000..dc0f164 --- /dev/null +++ b/test/samovar/command/completions.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "samovar/command/completions" + +describe Samovar::CommandLine::Completions do + it "generates a shell completion adapter script" do + output = StringIO.new + command = subject.new(["zsh", "my-command"], output: output) + + command.call + + expect(output.string).to be(:include?, "#compdef my-command") + expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") + end + + it "can be invoked through the top-level command" do + output = StringIO.new + + Samovar::CommandLine::Top.new(["completions", "bash", "my-command"], output: output).call + + expect(output.string).to be(:include?, "complete -F _my_command_completion my-command") + end + + it "completes shell names" do + result = Samovar::CommandLine::Top.complete(["completions", "z"], index: 1) + + expect(result.collect(&:value)).to be == ["zsh"] + 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 From 4d5888b3ddbe7d381e462b1a6a5c5a0332a39c37 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 14:54:25 +1200 Subject: [PATCH 04/36] Use bin for samovar executable --- {exe => bin}/samovar | 0 samovar.gemspec | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) rename {exe => bin}/samovar (100%) diff --git a/exe/samovar b/bin/samovar similarity index 100% rename from exe/samovar rename to bin/samovar diff --git a/samovar.gemspec b/samovar.gemspec index 7f0fae1..085f361 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -21,9 +21,8 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/ioquatix/samovar.git", } - spec.bindir = "exe" spec.executables = ["samovar"] - spec.files = Dir.glob(["{context,exe,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) + spec.files = Dir.glob(["{bin,context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.3" From 2066af5f3c074970e7c61d4ae7dcf439dea8e033 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 15:18:24 +1200 Subject: [PATCH 05/36] Move CLI commands under internal command namespace --- bin/samovar | 4 +- lib/samovar/command/completions.rb | 39 ------------------- lib/samovar/internal/command/completion.rb | 24 ++++++++++++ lib/samovar/internal/command/top.rb | 30 ++++++++++++++ readme.md | 6 +-- .../command/completion.rb} | 8 ++-- 6 files changed, 63 insertions(+), 48 deletions(-) delete mode 100644 lib/samovar/command/completions.rb create mode 100644 lib/samovar/internal/command/completion.rb create mode 100644 lib/samovar/internal/command/top.rb rename test/samovar/{command/completions.rb => internal/command/completion.rb} (71%) diff --git a/bin/samovar b/bin/samovar index a206852..9565193 100755 --- a/bin/samovar +++ b/bin/samovar @@ -4,6 +4,6 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "samovar/command/completions" +require_relative "../lib/samovar/internal/command/top" -Samovar::CommandLine::Top.call +Samovar::Internal::Command::Top.call diff --git a/lib/samovar/command/completions.rb b/lib/samovar/command/completions.rb deleted file mode 100644 index 5910798..0000000 --- a/lib/samovar/command/completions.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "../command" - -module Samovar - module CommandLine - # Generate shell completion adapter scripts for Samovar-based commands. - class Completions < Command - self.description = "Generate shell completion adapter scripts." - - one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] - one :executable, "The command executable to complete.", required: true - - def call - output.puts Completion.script(shell: @shell.to_sym, executable: @executable) - end - end - - # The Samovar command-line interface. - class Top < Command - self.description = "Utilities for Samovar-based command-line applications." - - options do - option "-h/--help", "Print out help information." - end - - nested :command, { - "completions" => Completions, - }, required: true - - def call - @command.call - end - end - end -end diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb new file mode 100644 index 0000000..6b89759 --- /dev/null +++ b/lib/samovar/internal/command/completion.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../../command" + +module Samovar + module Internal + module Command + # Generate shell completion adapter scripts for Samovar-based commands. + class Completion < Samovar::Command + self.description = "Generate shell completion adapter scripts." + + one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] + one :executable, "The command executable to complete.", required: true + + def call + output.puts Samovar::Completion.script(shell: @shell.to_sym, executable: @executable) + end + end + end + end +end diff --git a/lib/samovar/internal/command/top.rb b/lib/samovar/internal/command/top.rb new file mode 100644 index 0000000..6ac4d46 --- /dev/null +++ b/lib/samovar/internal/command/top.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "../../command" +require_relative "completion" + +module Samovar + module Internal + module Command + # The Samovar command-line interface. + class Top < Samovar::Command + self.description = "Utilities for Samovar-based command-line applications." + + options do + option "-h/--help", "Print out help information." + end + + nested :command, { + "completion" => Completion, + }, required: true + + def call + @command.call + end + end + end + end +end diff --git a/readme.md b/readme.md index 5af182e..6826ee6 100644 --- a/readme.md +++ b/readme.md @@ -47,9 +47,9 @@ SAMOVAR_COMPLETE=1 command --for Applications can also generate shell adapter scripts: ``` shell -samovar completions bash command -samovar completions zsh command -samovar completions fish command +samovar completion bash command +samovar completion zsh command +samovar completion fish command ``` ## Releases diff --git a/test/samovar/command/completions.rb b/test/samovar/internal/command/completion.rb similarity index 71% rename from test/samovar/command/completions.rb rename to test/samovar/internal/command/completion.rb index dc0f164..9f4cf02 100644 --- a/test/samovar/command/completions.rb +++ b/test/samovar/internal/command/completion.rb @@ -3,9 +3,9 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "samovar/command/completions" +require "samovar/internal/command/top" -describe Samovar::CommandLine::Completions do +describe Samovar::Internal::Command::Completion do it "generates a shell completion adapter script" do output = StringIO.new command = subject.new(["zsh", "my-command"], output: output) @@ -19,13 +19,13 @@ it "can be invoked through the top-level command" do output = StringIO.new - Samovar::CommandLine::Top.new(["completions", "bash", "my-command"], output: output).call + Samovar::Internal::Command::Top.new(["completion", "bash", "my-command"], output: output).call expect(output.string).to be(:include?, "complete -F _my_command_completion my-command") end it "completes shell names" do - result = Samovar::CommandLine::Top.complete(["completions", "z"], index: 1) + result = Samovar::Internal::Command::Top.complete(["completion", "z"], index: 1) expect(result.collect(&:value)).to be == ["zsh"] end From 4f8fb6b5135304743807db148549559bcefcbc4d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 15:45:36 +1200 Subject: [PATCH 06/36] Add completion install command --- lib/samovar/internal/command/completion.rb | 91 +++++++++++++++++++-- lib/samovar/nested.rb | 2 +- readme.md | 1 + test/samovar/internal/command/completion.rb | 62 +++++++++++++- 4 files changed, 149 insertions(+), 7 deletions(-) diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb index 6b89759..1d45eee 100644 --- a/lib/samovar/internal/command/completion.rb +++ b/lib/samovar/internal/command/completion.rb @@ -4,19 +4,100 @@ # Copyright, 2026, by Samuel Williams. require_relative "../../command" +require_relative "../../failure" +require "fileutils" module Samovar module Internal module Command - # Generate shell completion adapter scripts for Samovar-based commands. + # Generate or install shell completion adapter scripts for Samovar-based commands. class Completion < Samovar::Command - self.description = "Generate shell completion adapter scripts." + # Generate shell completion adapter scripts for Samovar-based commands. + class Generate < Samovar::Command + self.description = "Generate shell completion adapter scripts." + + options do + option "--command ", "The command executable to complete." + end + + one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] + one :executable, "The command executable to complete.", default: nil + + def call + executable = @options[:command] || @executable + raise MissingValueError.new(self, :command) unless executable + + output.puts Samovar::Completion.script(shell: @shell.to_sym, executable: executable) + end + end + + # Install a shell completion adapter script to a user-local completion directory. + class Install < Samovar::Command + self.description = "Install a shell completion adapter script." + + options do + option "--shell ", "The shell to install completions for.", completions: ["bash", "zsh", "fish"] + option "--directory ", "The completion directory to install into." + option "--command ", "The command executable to complete." + end + + one :executable, "The command executable to complete.", default: nil + + def self.shell_name(path) + File.basename(path.to_s) + end + + def self.default_directory(shell) + case shell + when "bash" + File.expand_path("~/.local/share/bash-completion/completions") + when "fish" + File.expand_path("~/.config/fish/completions") + when "zsh" + File.expand_path("~/.zsh/completions") + else + raise Failure, "Unsupported shell: #{shell.inspect}" + end + end + + def self.file_name(shell, executable) + case shell + when "bash" + executable + when "fish" + "#{executable}.fish" + when "zsh" + "_#{executable}" + else + raise Failure, "Unsupported shell: #{shell.inspect}" + end + end + + def call + executable = @options[:command] || @executable + raise MissingValueError.new(self, :command) unless executable + + shell = @options[:shell] || self.class.shell_name(ENV["SHELL"]) + directory = @options[:directory] || self.class.default_directory(shell) + path = File.join(directory, self.class.file_name(shell, executable)) + script = Samovar::Completion.script(shell: shell.to_sym, executable: executable) + + FileUtils.mkdir_p(directory) + File.write(path, script) + + output.puts path + end + end + + self.description = "Generate or install shell completion adapter scripts." - one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] - one :executable, "The command executable to complete.", required: true + nested :command, { + "install" => Install, + "generate" => Generate, + }, default: "generate" def call - output.puts Samovar::Completion.script(shell: @shell.to_sym, executable: @executable) + @command.call end end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index dd566e2..146d0c3 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -107,7 +107,7 @@ def complete(input, context, collected) if input.empty? result = Completion.nested_suggestions(self, context) - if result.empty? && context.current.start_with?("-") && @default + if result.empty? && @default return Completion::Result.new(collected) + Completion.complete_command(@commands.fetch(@default), [], context) end diff --git a/readme.md b/readme.md index 6826ee6..bc61b4f 100644 --- a/readme.md +++ b/readme.md @@ -50,6 +50,7 @@ Applications can also generate shell adapter scripts: samovar completion bash command samovar completion zsh command samovar completion fish command +samovar completion install --command command ``` ## Releases diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb index 9f4cf02..8d6fac2 100644 --- a/test/samovar/internal/command/completion.rb +++ b/test/samovar/internal/command/completion.rb @@ -6,9 +6,29 @@ require "samovar/internal/command/top" describe Samovar::Internal::Command::Completion do + let(:temporary_root) {File.expand_path("../../../tmp", __dir__)} + + def temporary_path(*path) + File.join(temporary_root, *path) + end + + after do + FileUtils.rm_rf(temporary_root) + end + it "generates a shell completion adapter script" do output = StringIO.new - command = subject.new(["zsh", "my-command"], output: output) + command = subject.new(["generate", "zsh", "my-command"], output: output) + + command.call + + expect(output.string).to be(:include?, "#compdef my-command") + expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") + end + + it "generates a shell completion adapter script with --command" do + output = StringIO.new + command = subject.new(["--command", "my-command", "zsh"], output: output) command.call @@ -16,6 +36,40 @@ expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") end + it "installs a shell completion adapter script to an explicit directory" do + output = StringIO.new + directory = temporary_path("zsh") + command = subject.new(["install", "--shell", "zsh", "--directory", directory, "--command", "my-command"], output: output) + + command.call + + path = File.join(directory, "_my-command") + expect(output.string).to be == "#{path}\n" + expect(File.read(path)).to be(:include?, "#compdef my-command") + expect(File.read(path)).to be(:include?, "SAMOVAR_COMPLETE") + end + + it "infers shell and default directory when installing" do + output = StringIO.new + shell = ENV["SHELL"] + home = ENV["HOME"] + + begin + ENV["SHELL"] = "/bin/fish" + ENV["HOME"] = temporary_path("home") + + command = subject.new(["install", "--command", "my-command"], output: output) + command.call + ensure + ENV["SHELL"] = shell + ENV["HOME"] = home + end + + path = temporary_path("home", ".config", "fish", "completions", "my-command.fish") + expect(output.string).to be == "#{path}\n" + expect(File.read(path)).to be(:include?, "complete -c my-command") + end + it "can be invoked through the top-level command" do output = StringIO.new @@ -29,4 +83,10 @@ expect(result.collect(&:value)).to be == ["zsh"] end + + it "completes install shell option values" do + result = Samovar::Internal::Command::Top.complete(["completion", "install", "--shell", "f"], index: 3) + + expect(result.collect(&:value)).to be == ["fish"] + end end From 3c931eb2db2b3e811ba6cbca2b8a6b86bc807e81 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 15:53:06 +1200 Subject: [PATCH 07/36] Use shell option for completion command --- lib/samovar/internal/command/completion.rb | 76 +++++++++++---------- lib/samovar/nested.rb | 2 + readme.md | 6 +- test/samovar/internal/command/completion.rb | 24 +++++-- 4 files changed, 66 insertions(+), 42 deletions(-) diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb index 1d45eee..926c74e 100644 --- a/lib/samovar/internal/command/completion.rb +++ b/lib/samovar/internal/command/completion.rb @@ -12,22 +12,58 @@ module Internal module Command # Generate or install shell completion adapter scripts for Samovar-based commands. class Completion < Samovar::Command + def self.shell_name(path) + File.basename(path.to_s) + end + + def self.default_shell + shell_name(ENV["SHELL"]) + end + + def self.default_directory(shell) + case shell + when "bash" + File.expand_path("~/.local/share/bash-completion/completions") + when "fish" + File.expand_path("~/.config/fish/completions") + when "zsh" + File.expand_path("~/.zsh/completions") + else + raise Failure, "Unsupported shell: #{shell.inspect}" + end + end + + def self.file_name(shell, executable) + case shell + when "bash" + executable + when "fish" + "#{executable}.fish" + when "zsh" + "_#{executable}" + else + raise Failure, "Unsupported shell: #{shell.inspect}" + end + end + # Generate shell completion adapter scripts for Samovar-based commands. class Generate < Samovar::Command self.description = "Generate shell completion adapter scripts." options do + option "--shell ", "The shell to generate completions for.", completions: ["bash", "zsh", "fish"] option "--command ", "The command executable to complete." end - one :shell, "The shell to generate completions for.", pattern: /^(bash|zsh|fish)$/, required: true, completions: ["bash", "zsh", "fish"] one :executable, "The command executable to complete.", default: nil def call executable = @options[:command] || @executable raise MissingValueError.new(self, :command) unless executable - output.puts Samovar::Completion.script(shell: @shell.to_sym, executable: executable) + shell = @options[:shell] || Completion.default_shell + + output.puts Samovar::Completion.script(shell: shell.to_sym, executable: executable) end end @@ -43,43 +79,13 @@ class Install < Samovar::Command one :executable, "The command executable to complete.", default: nil - def self.shell_name(path) - File.basename(path.to_s) - end - - def self.default_directory(shell) - case shell - when "bash" - File.expand_path("~/.local/share/bash-completion/completions") - when "fish" - File.expand_path("~/.config/fish/completions") - when "zsh" - File.expand_path("~/.zsh/completions") - else - raise Failure, "Unsupported shell: #{shell.inspect}" - end - end - - def self.file_name(shell, executable) - case shell - when "bash" - executable - when "fish" - "#{executable}.fish" - when "zsh" - "_#{executable}" - else - raise Failure, "Unsupported shell: #{shell.inspect}" - end - end - def call executable = @options[:command] || @executable raise MissingValueError.new(self, :command) unless executable - shell = @options[:shell] || self.class.shell_name(ENV["SHELL"]) - directory = @options[:directory] || self.class.default_directory(shell) - path = File.join(directory, self.class.file_name(shell, executable)) + shell = @options[:shell] || Completion.default_shell + directory = @options[:directory] || Completion.default_directory(shell) + path = File.join(directory, Completion.file_name(shell, executable)) script = Samovar::Completion.script(shell: shell.to_sym, executable: executable) FileUtils.mkdir_p(directory) diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index 146d0c3..4a8a963 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -117,6 +117,8 @@ def complete(input, context, collected) if command = @commands[input.first] input.shift Completion.complete_command(command, input, context) + elsif @default + Completion.complete_command(@commands.fetch(@default), input, context) else Completion::Result.new(collected) end diff --git a/readme.md b/readme.md index bc61b4f..8734a18 100644 --- a/readme.md +++ b/readme.md @@ -47,10 +47,10 @@ SAMOVAR_COMPLETE=1 command --for Applications can also generate shell adapter scripts: ``` shell -samovar completion bash command -samovar completion zsh command -samovar completion fish command +samovar completion --command command +samovar completion --command command --shell zsh samovar completion install --command command +samovar completion install --command command --shell zsh ``` ## Releases diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb index 8d6fac2..f6042b1 100644 --- a/test/samovar/internal/command/completion.rb +++ b/test/samovar/internal/command/completion.rb @@ -18,7 +18,7 @@ def temporary_path(*path) it "generates a shell completion adapter script" do output = StringIO.new - command = subject.new(["generate", "zsh", "my-command"], output: output) + command = subject.new(["generate", "--shell", "zsh", "my-command"], output: output) command.call @@ -28,7 +28,7 @@ def temporary_path(*path) it "generates a shell completion adapter script with --command" do output = StringIO.new - command = subject.new(["--command", "my-command", "zsh"], output: output) + command = subject.new(["--shell", "zsh", "--command", "my-command"], output: output) command.call @@ -36,6 +36,22 @@ def temporary_path(*path) expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") end + it "infers shell when generating" do + output = StringIO.new + shell = ENV["SHELL"] + + begin + ENV["SHELL"] = "/bin/fish" + + command = subject.new(["--command", "my-command"], output: output) + command.call + ensure + ENV["SHELL"] = shell + end + + expect(output.string).to be(:include?, "complete -c my-command") + end + it "installs a shell completion adapter script to an explicit directory" do output = StringIO.new directory = temporary_path("zsh") @@ -73,13 +89,13 @@ def temporary_path(*path) it "can be invoked through the top-level command" do output = StringIO.new - Samovar::Internal::Command::Top.new(["completion", "bash", "my-command"], output: output).call + Samovar::Internal::Command::Top.new(["completion", "--shell", "bash", "my-command"], output: output).call expect(output.string).to be(:include?, "complete -F _my_command_completion my-command") end it "completes shell names" do - result = Samovar::Internal::Command::Top.complete(["completion", "z"], index: 1) + result = Samovar::Internal::Command::Top.complete(["completion", "--shell", "z"], index: 2) expect(result.collect(&:value)).to be == ["zsh"] end From b1e19ccabe89cdbea60ead6c8c872e4acea2144e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:16:38 +1200 Subject: [PATCH 08/36] Require command option for completion CLI --- lib/samovar/internal/command/completion.rb | 20 +++++--------------- test/samovar/internal/command/completion.rb | 10 ++++++++-- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb index 926c74e..d213537 100644 --- a/lib/samovar/internal/command/completion.rb +++ b/lib/samovar/internal/command/completion.rb @@ -52,18 +52,13 @@ class Generate < Samovar::Command options do option "--shell ", "The shell to generate completions for.", completions: ["bash", "zsh", "fish"] - option "--command ", "The command executable to complete." + option "--command ", "The command executable to complete.", required: true end - one :executable, "The command executable to complete.", default: nil - def call - executable = @options[:command] || @executable - raise MissingValueError.new(self, :command) unless executable - shell = @options[:shell] || Completion.default_shell - output.puts Samovar::Completion.script(shell: shell.to_sym, executable: executable) + output.puts Samovar::Completion.script(shell: shell.to_sym, executable: @options[:command]) end end @@ -74,19 +69,14 @@ class Install < Samovar::Command options do option "--shell ", "The shell to install completions for.", completions: ["bash", "zsh", "fish"] option "--directory ", "The completion directory to install into." - option "--command ", "The command executable to complete." + option "--command ", "The command executable to complete.", required: true end - one :executable, "The command executable to complete.", default: nil - def call - executable = @options[:command] || @executable - raise MissingValueError.new(self, :command) unless executable - shell = @options[:shell] || Completion.default_shell directory = @options[:directory] || Completion.default_directory(shell) - path = File.join(directory, Completion.file_name(shell, executable)) - script = Samovar::Completion.script(shell: shell.to_sym, executable: executable) + path = File.join(directory, Completion.file_name(shell, @options[:command])) + script = Samovar::Completion.script(shell: shell.to_sym, executable: @options[:command]) FileUtils.mkdir_p(directory) File.write(path, script) diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb index f6042b1..17c7f58 100644 --- a/test/samovar/internal/command/completion.rb +++ b/test/samovar/internal/command/completion.rb @@ -18,7 +18,7 @@ def temporary_path(*path) it "generates a shell completion adapter script" do output = StringIO.new - command = subject.new(["generate", "--shell", "zsh", "my-command"], output: output) + command = subject.new(["generate", "--shell", "zsh", "--command", "my-command"], output: output) command.call @@ -26,6 +26,12 @@ def temporary_path(*path) expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") end + it "requires the command name when generating" do + expect do + subject.new(["generate", "--shell", "zsh"]) + end.to raise_exception(Samovar::MissingValueError) + end + it "generates a shell completion adapter script with --command" do output = StringIO.new command = subject.new(["--shell", "zsh", "--command", "my-command"], output: output) @@ -89,7 +95,7 @@ def temporary_path(*path) it "can be invoked through the top-level command" do output = StringIO.new - Samovar::Internal::Command::Top.new(["completion", "--shell", "bash", "my-command"], output: output).call + Samovar::Internal::Command::Top.new(["completion", "--shell", "bash", "--command", "my-command"], output: output).call expect(output.string).to be(:include?, "complete -F _my_command_completion my-command") end From e3a422f124f39ca55fae95aec8c69891989e9a70 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:22:15 +1200 Subject: [PATCH 09/36] Fix zsh completion argv handling --- lib/samovar/completion.rb | 21 ++++++++++++------- test/samovar/completion.rb | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index f3c7deb..3e17707 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -189,11 +189,16 @@ def self.escape(value) end def self.function_name(executable) - "_#{executable.gsub(/[^a-zA-Z0-9_]/, "_")}_completion" + "_#{command_name(executable).gsub(/[^a-zA-Z0-9_]/, "_")}_completion" + end + + def self.command_name(executable) + File.basename(executable) end def self.bash_script(executable) function = function_name(executable) + command = command_name(executable) <<~SCRIPT #{function}() { @@ -206,27 +211,28 @@ def self.bash_script(executable) done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") } - complete -F #{function} #{executable} + complete -F #{function} #{command} SCRIPT end def self.zsh_script(executable) function = function_name(executable) + command = command_name(executable) <<~SCRIPT - #compdef #{executable} + #compdef #{command} #{function}() { local index=$((CURRENT - 2)) local -a argv - argv=("${words[@]:1}") + argv=("${words[2,-1]}") local -a completions while IFS=$'\\t' read -r value description type; do completions+=("${value}:${description}") done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") - _describe '#{executable}' completions + _describe '#{command}' completions } #{function} @@ -235,9 +241,10 @@ def self.zsh_script(executable) def self.fish_script(executable) function = function_name(executable) + command = command_name(executable) <<~SCRIPT - function #{function} --description 'Complete #{executable}' + function #{function} --description 'Complete #{command}' set -l argv (commandline -opc) set -e argv[1] set -l index (math (count $argv) - 1) @@ -247,7 +254,7 @@ def self.fish_script(executable) end end - complete -c #{executable} -f -a "(#{function})" + complete -c #{command} -f -a "(#{function})" SCRIPT end end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 9c19915..fd0582a 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -151,4 +151,45 @@ def values(result) expect(subject.script(shell: :zsh, executable: "samovar")).to be(:include?, "#compdef samovar") expect(subject.script(shell: :fish, executable: "samovar")).to be(:include?, "complete -c samovar") end + + it "uses zsh array indexing to remove the command word" do + script = subject.script(shell: :zsh, executable: "samovar") + + expect(script).to be(:include?, 'argv=("${words[2,-1]}")') + end + + it "passes application arguments from zsh completion" do + skip "zsh is not available" unless system("command -v zsh >/dev/null") + + path = "/tmp/samovar-completion-#{$$}" + + system({"TRACE" => path}, "zsh", "-fc", <<~SCRIPT) + samovar() { + print -r -- "$SAMOVAR_COMPLETE|$*" > "$TRACE" + print -r -- "completion\\tGenerate\\tcommand" + } + + _describe() { :; } + + words=(samovar completion --shell z) + CURRENT=4 + + source <(ruby -Ilib bin/samovar completion --command samovar --shell zsh) + SCRIPT + + expect(File.read(path)).to be == "2|completion --shell z\n" + ensure + File.delete(path) if path && File.exist?(path) + end + + it "uses the basename when registering completion scripts" do + zsh = subject.script(shell: :zsh, executable: "./samovar") + bash = subject.script(shell: :bash, executable: "./samovar") + fish = subject.script(shell: :fish, executable: "./samovar") + + expect(zsh).to be(:include?, "#compdef samovar") + expect(zsh).to be(:include?, "_samovar_completion()") + expect(bash).to be(:include?, "complete -F _samovar_completion samovar") + expect(fish).to be(:include?, "complete -c samovar") + end end From 3e0f72d18561c9a98e5eb4cc1195330c505dd953 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:28:47 +1200 Subject: [PATCH 10/36] Fix fish completion cursor handling --- lib/samovar/completion.rb | 12 +++++++-- test/samovar/completion.rb | 55 +++++++++++++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 3e17707..b029f3c 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -247,9 +247,17 @@ def self.fish_script(executable) function #{function} --description 'Complete #{command}' set -l argv (commandline -opc) set -e argv[1] - set -l index (math (count $argv) - 1) + set -l current (commandline -ct) + set -l index + + if test -n "$current" + set -a argv $current + set index (math (count $argv) - 1) + else + set index (count $argv) + end - SAMOVAR_COMPLETE="$index" #{executable} $argv | while read -l line + env SAMOVAR_COMPLETE="$index" #{executable} $argv | while read -l line echo $line end end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index fd0582a..390db2a 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require "samovar" +require "sus/fixtures/temporary_directory_context" class CompletionLeaf < Samovar::Command self.description = "Leaf command." @@ -46,6 +47,8 @@ class CompletionTop < Samovar::Command end describe Samovar::Completion do + include Sus::Fixtures::TemporaryDirectoryContext + def values(result) result.collect(&:value) end @@ -161,7 +164,7 @@ def values(result) it "passes application arguments from zsh completion" do skip "zsh is not available" unless system("command -v zsh >/dev/null") - path = "/tmp/samovar-completion-#{$$}" + path = File.join(root, "trace") system({"TRACE" => path}, "zsh", "-fc", <<~SCRIPT) samovar() { @@ -178,8 +181,54 @@ def values(result) SCRIPT expect(File.read(path)).to be == "2|completion --shell z\n" - ensure - File.delete(path) if path && File.exist?(path) + end + + it "passes application arguments from fish completion" do + skip "fish is not available" unless system("command -v fish >/dev/null") + + path = File.join(root, "fish-trace") + directory = File.join(root, "fish-command") + executable = File.join(directory, "samovar") + + Dir.mkdir(directory) + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) + + system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) + complete -e -c samovar + source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + complete --do-complete "samovar completion --shell z" >/dev/null + SCRIPT + + expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") + end + + it "passes an empty token from fish completion" do + skip "fish is not available" unless system("command -v fish >/dev/null") + + path = File.join(root, "fish-empty-trace") + directory = File.join(root, "fish-empty-command") + executable = File.join(directory, "samovar") + + Dir.mkdir(directory) + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) + + system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) + complete -e -c samovar + source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + complete --do-complete "samovar " >/dev/null + SCRIPT + + expect(File.readlines(path)).to be(:include?, "0|\n") end it "uses the basename when registering completion scripts" do From 4b5510411035347aabce6cae523f134279d816ec Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:30:36 +1200 Subject: [PATCH 11/36] Add bash completion adapter tests --- test/samovar/completion.rb | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 390db2a..abd6d98 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -161,6 +161,56 @@ def values(result) expect(script).to be(:include?, 'argv=("${words[2,-1]}")') end + it "passes application arguments from bash completion" do + skip "bash is not available" unless system("command -v bash >/dev/null") + + path = File.join(root, "bash-trace") + adapter = File.join(root, "samovar.bash") + + File.write(adapter, subject.script(shell: :bash, executable: "samovar")) + + system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) + samovar() { + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + } + + source "$ADAPTER" + + COMP_WORDS=(samovar completion --shell z) + COMP_CWORD=3 + + _samovar_completion + SCRIPT + + expect(File.read(path)).to be == "2|completion --shell z\n" + end + + it "passes an empty token from bash completion" do + skip "bash is not available" unless system("command -v bash >/dev/null") + + path = File.join(root, "bash-empty-trace") + adapter = File.join(root, "samovar-empty.bash") + + File.write(adapter, subject.script(shell: :bash, executable: "samovar")) + + system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) + samovar() { + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + } + + source "$ADAPTER" + + COMP_WORDS=(samovar "") + COMP_CWORD=1 + + _samovar_completion + SCRIPT + + expect(File.read(path)).to be == "0|\n" + end + it "passes application arguments from zsh completion" do skip "zsh is not available" unless system("command -v zsh >/dev/null") From a53d8010754425233eea0bdaa35706e5bb299bcd Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:33:11 +1200 Subject: [PATCH 12/36] Avoid env in fish completion adapter --- lib/samovar/completion.rb | 5 ++++- test/samovar/completion.rb | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index b029f3c..9692542 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -257,7 +257,10 @@ def self.fish_script(executable) set index (count $argv) end - env SAMOVAR_COMPLETE="$index" #{executable} $argv | while read -l line + begin + set -lx SAMOVAR_COMPLETE "$index" + #{executable} $argv + end | while read -l line echo $line end end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index abd6d98..9c9e685 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -251,6 +251,7 @@ def values(result) system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) complete -e -c samovar source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + set PATH #{root} complete --do-complete "samovar completion --shell z" >/dev/null SCRIPT @@ -275,6 +276,7 @@ def values(result) system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) complete -e -c samovar source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + set PATH #{root} complete --do-complete "samovar " >/dev/null SCRIPT From 4bcbf0b26ed7f345bd623d166b0497e0ee0cbd8b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 16:40:37 +1200 Subject: [PATCH 13/36] Complete option defaults first --- lib/samovar/completion.rb | 18 ++++++++++++++++- lib/samovar/internal/command/completion.rb | 10 ++++------ lib/samovar/option.rb | 21 ++++++++++++++++---- lib/samovar/options.rb | 12 ++++++----- test/samovar/completion.rb | 10 ++++++++-- test/samovar/internal/command/completion.rb | 14 +++++++++++++ test/samovar/options.rb | 22 +++++++++++++++++++-- 7 files changed, 87 insertions(+), 20 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 9692542..e6bf2f0 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -129,7 +129,7 @@ def self.consume_options(options, input, context) if input.any? input.shift else - return provider_suggestions(option.completions, context, row: options, option: option) + return option_value_suggestions(option, context, row: options) end end end @@ -157,6 +157,22 @@ def self.nested_suggestions(nested, context) Result.new(suggestions) end + def self.option_value_suggestions(option, context, row:) + suggestions = [] + + if option.default? + suggestion = wrap_suggestion(option.default) + + suggestions << suggestion if suggestion.value.to_s.start_with?(context.current) + end + + (result = provider_suggestions(option.completions, context, row: row, option: option)).each do |suggestion| + suggestions << suggestion unless suggestions.any?{|existing| existing.value == suggestion.value} + end + + Result.new(suggestions) + end + def self.provider_suggestions(provider, context, row:, option: nil) return Result.new unless provider diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb index d213537..312e85e 100644 --- a/lib/samovar/internal/command/completion.rb +++ b/lib/samovar/internal/command/completion.rb @@ -51,14 +51,12 @@ class Generate < Samovar::Command self.description = "Generate shell completion adapter scripts." options do - option "--shell ", "The shell to generate completions for.", completions: ["bash", "zsh", "fish"] + option "--shell ", "The shell to generate completions for.", default: Completion.method(:default_shell), completions: ["bash", "zsh", "fish"] option "--command ", "The command executable to complete.", required: true end def call - shell = @options[:shell] || Completion.default_shell - - output.puts Samovar::Completion.script(shell: shell.to_sym, executable: @options[:command]) + output.puts Samovar::Completion.script(shell: @options[:shell].to_sym, executable: @options[:command]) end end @@ -67,13 +65,13 @@ class Install < Samovar::Command self.description = "Install a shell completion adapter script." options do - option "--shell ", "The shell to install completions for.", completions: ["bash", "zsh", "fish"] + option "--shell ", "The shell to install completions for.", default: Completion.method(:default_shell), completions: ["bash", "zsh", "fish"] option "--directory ", "The completion directory to install into." option "--command ", "The command executable to complete.", required: true end def call - shell = @options[:shell] || Completion.default_shell + shell = @options[:shell] directory = @options[:directory] || Completion.default_directory(shell) path = File.join(directory, Completion.file_name(shell, @options[:command])) script = Samovar::Completion.script(shell: shell.to_sym, executable: @options[:command]) diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index d4ae6d9..1d815ab 100644 --- a/lib/samovar/option.rb +++ b/lib/samovar/option.rb @@ -61,8 +61,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. # @@ -163,8 +176,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 4321662..a04be64 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -71,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. # @@ -149,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 @@ -161,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 diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 9c9e685..43c8c54 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -14,7 +14,7 @@ def self.path_completions(context) end options do - option "--format ", "The output format.", completions: ["json", "text", "yaml"] + option "--format ", "The output format.", default: "text", completions: ["json", "text", "yaml"] option "--verbose | --quiet", "Verbosity of output for debugging.", key: :logging option "--[no]-color", "Enable or disable color output.", default: true end @@ -90,10 +90,16 @@ def values(result) expect(values(result)).to be == ["json"] end + it "completes option defaults before static completions" do + result = CompletionTop.complete(["leaf", "--format"], index: 2) + + expect(values(result)).to be == ["text", "json", "yaml"] + end + it "completes option values after a trailing option flag" do result = CompletionTop.complete(["leaf", "--format"], index: 2) - expect(values(result)).to be == ["json", "text", "yaml"] + expect(values(result)).to be == ["text", "json", "yaml"] end it "completes positional values using method completions" do diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb index 17c7f58..7355ebb 100644 --- a/test/samovar/internal/command/completion.rb +++ b/test/samovar/internal/command/completion.rb @@ -106,6 +106,20 @@ def temporary_path(*path) expect(result.collect(&:value)).to be == ["zsh"] end + it "completes the detected shell before other shell names" do + shell = ENV["SHELL"] + + begin + ENV["SHELL"] = "/bin/fish" + + result = Samovar::Internal::Command::Top.complete(["completion", "--shell"], index: 2) + ensure + ENV["SHELL"] = shell + end + + expect(result.collect(&:value)).to be == ["fish", "bash", "zsh"] + end + it "completes install shell option values" do result = Samovar::Internal::Command::Top.complete(["completion", "install", "--shell", "f"], index: 3) 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 - From 4d9c370ff36525b801511731a799456c5aafb707 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 17:10:40 +1200 Subject: [PATCH 14/36] Use completed command for completion requests --- lib/samovar/completion.rb | 9 ++++-- test/samovar/completion.rb | 63 +++++++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 24 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index e6bf2f0..9799a2e 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -219,12 +219,13 @@ def self.bash_script(executable) <<~SCRIPT #{function}() { local index=$((COMP_CWORD - 1)) + local command="${COMP_WORDS[0]}" local argv=("${COMP_WORDS[@]:1}") COMPREPLY=() while IFS=$'\\t' read -r value description type; do COMPREPLY+=("$value") - done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") + done < <(SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}") } complete -F #{function} #{command} @@ -240,13 +241,14 @@ def self.zsh_script(executable) #{function}() { local index=$((CURRENT - 2)) + local command="${words[1]}" local -a argv argv=("${words[2,-1]}") local -a completions while IFS=$'\\t' read -r value description type; do completions+=("${value}:${description}") - done < <(SAMOVAR_COMPLETE="$index" #{executable} "${argv[@]}") + done < <(SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}") _describe '#{command}' completions } @@ -262,6 +264,7 @@ def self.fish_script(executable) <<~SCRIPT function #{function} --description 'Complete #{command}' set -l argv (commandline -opc) + set -l command $argv[1] set -e argv[1] set -l current (commandline -ct) set -l index @@ -275,7 +278,7 @@ def self.fish_script(executable) begin set -lx SAMOVAR_COMPLETE "$index" - #{executable} $argv + $command $argv end | while read -l line echo $line end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 43c8c54..bc5431e 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -164,26 +164,41 @@ def values(result) it "uses zsh array indexing to remove the command word" do script = subject.script(shell: :zsh, executable: "samovar") + expect(script).to be(:include?, 'local command="${words[1]}"') expect(script).to be(:include?, 'argv=("${words[2,-1]}")') end + it "uses the completed command word as the executable" do + bash = subject.script(shell: :bash, executable: "samovar") + zsh = subject.script(shell: :zsh, executable: "samovar") + fish = subject.script(shell: :fish, executable: "samovar") + + expect(bash).to be(:include?, 'local command="${COMP_WORDS[0]}"') + expect(bash).to be(:include?, 'SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}"') + expect(zsh).to be(:include?, 'SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}"') + expect(fish).to be(:include?, "set -l command $argv[1]") + expect(fish).to be(:include?, "$command $argv") + end + it "passes application arguments from bash completion" do skip "bash is not available" unless system("command -v bash >/dev/null") path = File.join(root, "bash-trace") adapter = File.join(root, "samovar.bash") + executable = File.join(root, "samovar") File.write(adapter, subject.script(shell: :bash, executable: "samovar")) + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) - samovar() { - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - } - source "$ADAPTER" - COMP_WORDS=(samovar completion --shell z) + COMP_WORDS=(#{executable} completion --shell z) COMP_CWORD=3 _samovar_completion @@ -197,18 +212,20 @@ def values(result) path = File.join(root, "bash-empty-trace") adapter = File.join(root, "samovar-empty.bash") + executable = File.join(root, "samovar-empty") File.write(adapter, subject.script(shell: :bash, executable: "samovar")) + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) - samovar() { - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - } - source "$ADAPTER" - COMP_WORDS=(samovar "") + COMP_WORDS=(#{executable} "") COMP_CWORD=1 _samovar_completion @@ -221,16 +238,20 @@ def values(result) skip "zsh is not available" unless system("command -v zsh >/dev/null") path = File.join(root, "trace") + executable = File.join(root, "samovar") + + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) system({"TRACE" => path}, "zsh", "-fc", <<~SCRIPT) - samovar() { - print -r -- "$SAMOVAR_COMPLETE|$*" > "$TRACE" - print -r -- "completion\\tGenerate\\tcommand" - } _describe() { :; } - words=(samovar completion --shell z) + words=(#{executable} completion --shell z) CURRENT=4 source <(ruby -Ilib bin/samovar completion --command samovar --shell zsh) @@ -256,9 +277,9 @@ def values(result) system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) complete -e -c samovar - source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + source (ruby -Ilib bin/samovar completion --command samovar --shell fish | psub) set PATH #{root} - complete --do-complete "samovar completion --shell z" >/dev/null + complete --do-complete "#{executable} completion --shell z" >/dev/null SCRIPT expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") @@ -281,9 +302,9 @@ def values(result) system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) complete -e -c samovar - source (ruby -Ilib bin/samovar completion --command #{executable} --shell fish | psub) + source (ruby -Ilib bin/samovar completion --command samovar --shell fish | psub) set PATH #{root} - complete --do-complete "samovar " >/dev/null + complete --do-complete "#{executable} " >/dev/null SCRIPT expect(File.readlines(path)).to be(:include?, "0|\n") From 8f121004f4e622564c5d328546d49f24ac06040c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 17:18:10 +1200 Subject: [PATCH 15/36] Fix zsh completion argv expansion --- lib/samovar/completion.rb | 2 +- test/samovar/completion.rb | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 9799a2e..c198b0c 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -243,7 +243,7 @@ def self.zsh_script(executable) local index=$((CURRENT - 2)) local command="${words[1]}" local -a argv - argv=("${words[2,-1]}") + argv=("${(@)words[2,-1]}") local -a completions while IFS=$'\\t' read -r value description type; do diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index bc5431e..8229fe0 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -165,7 +165,7 @@ def values(result) script = subject.script(shell: :zsh, executable: "samovar") expect(script).to be(:include?, 'local command="${words[1]}"') - expect(script).to be(:include?, 'argv=("${words[2,-1]}")') + expect(script).to be(:include?, 'argv=("${(@)words[2,-1]}")') end it "uses the completed command word as the executable" do @@ -285,6 +285,32 @@ def values(result) expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") end + it "passes application arguments from fish completion using a relative executable path" do + skip "fish is not available" unless system("command -v fish >/dev/null") + + path = File.join(root, "fish-relative-trace") + directory = File.join(root, "bin") + executable = File.join(directory, "samovar") + + Dir.mkdir(directory) + File.write(executable, <<~SCRIPT) + #!/bin/sh + printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "completion\\tGenerate\\tcommand\\n" + SCRIPT + File.chmod(0o755, executable) + + system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) + cd #{root} + complete -e -c samovar + source (ruby -I#{Dir.pwd}/lib #{Dir.pwd}/bin/samovar completion --command samovar --shell fish | psub) + set PATH #{root} + complete --do-complete "bin/samovar completion --shell z" >/dev/null + SCRIPT + + expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") + end + it "passes an empty token from fish completion" do skip "fish is not available" unless system("command -v fish >/dev/null") From 3cb24c454619d930620e80d970701942f29dfa15 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 11 Jun 2026 18:11:33 +1200 Subject: [PATCH 16/36] Use generic completion protocol --- gems.rb | 1 + lib/samovar/command.rb | 4 +- lib/samovar/completion.rb | 144 ++------------------ samovar.gemspec | 1 + test/samovar/completion.rb | 27 ++-- test/samovar/internal/command/completion.rb | 6 +- 6 files changed, 29 insertions(+), 154 deletions(-) diff --git a/gems.rb b/gems.rb index 9527a19..020b1a9 100644 --- a/gems.rb +++ b/gems.rb @@ -6,6 +6,7 @@ source "https://rubygems.org" gemspec +gem "completion", path: "../completion" group :maintenance, optional: true do gem "bake-modernize" diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 5ddaeb7..3b93d9a 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -29,8 +29,8 @@ class Command # @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, completion_output: $stdout) - if (index = ENV["SAMOVAR_COMPLETE"]) && !index.empty? - Completion.print(self.complete(input, index: index), completion_output) + if index = Completion.extract_index(ENV) + self.complete(input, index: index).print(completion_output) return true end diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index c198b0c..98f72a8 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -3,38 +3,13 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require "completion" + module Samovar # Shell completion support for Samovar commands. module Completion - # A single completion candidate. - Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do - def to_s - value.to_s - end - end - - # The result of a completion request. - class Result - include Enumerable - - def initialize(suggestions = []) - @suggestions = suggestions - end - - attr :suggestions - - def each(&block) - @suggestions.each(&block) - end - - def empty? - @suggestions.empty? - end - - def +(other) - self.class.new(@suggestions + other.suggestions) - end - end + Suggestion = ::Completion::Candidate + Result = ::Completion::Result # The context provided to dynamic completion callbacks. Context = Struct.new(:command_class, :argv, :index, :current, :row, :option, :environment, keyword_init: true) @@ -68,18 +43,8 @@ def self.complete(command_class, argv, index:, environment: ENV) complete_command(command_class, words, context) end - # Print the completion result in a stable TSV format. - # - # @parameter result [Result] The result to print. - # @parameter output [IO] The output stream. - def self.print(result, output = $stdout) - result.each do |suggestion| - output.puts [ - escape(suggestion.value), - escape(suggestion.description), - escape(suggestion.type), - ].join("\t") - end + def self.extract_index(environment = ENV) + ::Completion::Index.extract(environment) end # Generate a shell completion script for an executable. @@ -88,16 +53,7 @@ def self.print(result, output = $stdout) # @parameter executable [String] The executable name. # @returns [String] The shell completion script. def self.script(shell:, executable:) - case shell.to_sym - when :bash - bash_script(executable) - when :zsh - zsh_script(executable) - when :fish - fish_script(executable) - else - raise ArgumentError, "Unsupported shell: #{shell.inspect}" - end + ::Completion::Shell.script(shell: shell, executable: executable) end def self.complete_command(command_class, words, context) @@ -200,92 +156,8 @@ def self.wrap_suggestion(value) end end - def self.escape(value) - value.to_s.gsub(/[\t\r\n]/, " ") - end - - def self.function_name(executable) - "_#{command_name(executable).gsub(/[^a-zA-Z0-9_]/, "_")}_completion" - end - def self.command_name(executable) - File.basename(executable) - end - - def self.bash_script(executable) - function = function_name(executable) - command = command_name(executable) - - <<~SCRIPT - #{function}() { - local index=$((COMP_CWORD - 1)) - local command="${COMP_WORDS[0]}" - local argv=("${COMP_WORDS[@]:1}") - COMPREPLY=() - - while IFS=$'\\t' read -r value description type; do - COMPREPLY+=("$value") - done < <(SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}") - } - - complete -F #{function} #{command} - SCRIPT - end - - def self.zsh_script(executable) - function = function_name(executable) - command = command_name(executable) - - <<~SCRIPT - #compdef #{command} - - #{function}() { - local index=$((CURRENT - 2)) - local command="${words[1]}" - local -a argv - argv=("${(@)words[2,-1]}") - - local -a completions - while IFS=$'\\t' read -r value description type; do - completions+=("${value}:${description}") - done < <(SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}") - - _describe '#{command}' completions - } - - #{function} - SCRIPT - end - - def self.fish_script(executable) - function = function_name(executable) - command = command_name(executable) - - <<~SCRIPT - function #{function} --description 'Complete #{command}' - set -l argv (commandline -opc) - set -l command $argv[1] - set -e argv[1] - set -l current (commandline -ct) - set -l index - - if test -n "$current" - set -a argv $current - set index (math (count $argv) - 1) - else - set index (count $argv) - end - - begin - set -lx SAMOVAR_COMPLETE "$index" - $command $argv - end | while read -l line - echo $line - end - end - - complete -c #{command} -f -a "(#{function})" - SCRIPT + ::Completion::Shell.command_name(executable) end end end diff --git a/samovar.gemspec b/samovar.gemspec index 085f361..04d2592 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -26,5 +26,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" + spec.add_dependency "completion", "~> 0.0" spec.add_dependency "console", "~> 1.0" end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 8229fe0..15a4611 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -136,19 +136,20 @@ def values(result) output = StringIO.new result = CompletionTop.complete(["le"], index: 0) - subject.print(result, output) + result.print(output) expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end - it "uses SAMOVAR_COMPLETE as the cursor index in call" do + it "uses COMPLETION_INDEX as the cursor index in call" do output = StringIO.new begin - ENV["SAMOVAR_COMPLETE"] = "0" + ENV["COMPLETION_INDEX"] = "0" result = CompletionTop.call(["le"], completion_output: output) + expect(ENV).not.to be(:key?, "COMPLETION_INDEX") ensure - ENV.delete("SAMOVAR_COMPLETE") + ENV.delete("COMPLETION_INDEX") end expect(result).to be == true @@ -156,7 +157,7 @@ def values(result) end it "generates shell completion scripts" do - expect(subject.script(shell: :bash, executable: "samovar")).to be(:include?, "SAMOVAR_COMPLETE") + expect(subject.script(shell: :bash, executable: "samovar")).to be(:include?, "COMPLETION_INDEX") expect(subject.script(shell: :zsh, executable: "samovar")).to be(:include?, "#compdef samovar") expect(subject.script(shell: :fish, executable: "samovar")).to be(:include?, "complete -c samovar") end @@ -174,8 +175,8 @@ def values(result) fish = subject.script(shell: :fish, executable: "samovar") expect(bash).to be(:include?, 'local command="${COMP_WORDS[0]}"') - expect(bash).to be(:include?, 'SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}"') - expect(zsh).to be(:include?, 'SAMOVAR_COMPLETE="$index" "$command" "${argv[@]}"') + expect(bash).to be(:include?, 'COMPLETION_INDEX="$index" "$command" "${argv[@]}"') + expect(zsh).to be(:include?, 'COMPLETION_INDEX="$index" "$command" "${argv[@]}"') expect(fish).to be(:include?, "set -l command $argv[1]") expect(fish).to be(:include?, "$command $argv") end @@ -190,7 +191,7 @@ def values(result) File.write(adapter, subject.script(shell: :bash, executable: "samovar")) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) @@ -217,7 +218,7 @@ def values(result) File.write(adapter, subject.script(shell: :bash, executable: "samovar")) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) @@ -242,7 +243,7 @@ def values(result) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" > "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) @@ -270,7 +271,7 @@ def values(result) Dir.mkdir(directory) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) @@ -295,7 +296,7 @@ def values(result) Dir.mkdir(directory) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) @@ -321,7 +322,7 @@ def values(result) Dir.mkdir(directory) File.write(executable, <<~SCRIPT) #!/bin/sh - printf "%s|%s\\n" "$SAMOVAR_COMPLETE" "$*" >> "$TRACE" + printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" printf "completion\\tGenerate\\tcommand\\n" SCRIPT File.chmod(0o755, executable) diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb index 7355ebb..58ca875 100644 --- a/test/samovar/internal/command/completion.rb +++ b/test/samovar/internal/command/completion.rb @@ -23,7 +23,7 @@ def temporary_path(*path) command.call expect(output.string).to be(:include?, "#compdef my-command") - expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") + expect(output.string).to be(:include?, "COMPLETION_INDEX") end it "requires the command name when generating" do @@ -39,7 +39,7 @@ def temporary_path(*path) command.call expect(output.string).to be(:include?, "#compdef my-command") - expect(output.string).to be(:include?, "SAMOVAR_COMPLETE") + expect(output.string).to be(:include?, "COMPLETION_INDEX") end it "infers shell when generating" do @@ -68,7 +68,7 @@ def temporary_path(*path) path = File.join(directory, "_my-command") expect(output.string).to be == "#{path}\n" expect(File.read(path)).to be(:include?, "#compdef my-command") - expect(File.read(path)).to be(:include?, "SAMOVAR_COMPLETE") + expect(File.read(path)).to be(:include?, "COMPLETION_INDEX") end it "infers shell and default directory when installing" do From fcefef7f2dde32ff4f987a5d36934ad96ce7c926 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 15:19:07 +1200 Subject: [PATCH 17/36] Use protocol completion --- gems.rb | 2 +- lib/samovar/completion.rb | 12 ++++++------ samovar.gemspec | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/gems.rb b/gems.rb index 020b1a9..de04794 100644 --- a/gems.rb +++ b/gems.rb @@ -6,7 +6,7 @@ source "https://rubygems.org" gemspec -gem "completion", path: "../completion" +gem "protocol-completion", path: "../protocol-completion" group :maintenance, optional: true do gem "bake-modernize" diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 98f72a8..ba19f4a 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -3,13 +3,13 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "completion" +require "protocol/completion" module Samovar # Shell completion support for Samovar commands. module Completion - Suggestion = ::Completion::Candidate - Result = ::Completion::Result + Suggestion = ::Protocol::Completion::Candidate + Result = ::Protocol::Completion::Result # The context provided to dynamic completion callbacks. Context = Struct.new(:command_class, :argv, :index, :current, :row, :option, :environment, keyword_init: true) @@ -44,7 +44,7 @@ def self.complete(command_class, argv, index:, environment: ENV) end def self.extract_index(environment = ENV) - ::Completion::Index.extract(environment) + ::Protocol::Completion::Index.extract(environment) end # Generate a shell completion script for an executable. @@ -53,7 +53,7 @@ def self.extract_index(environment = ENV) # @parameter executable [String] The executable name. # @returns [String] The shell completion script. def self.script(shell:, executable:) - ::Completion::Shell.script(shell: shell, executable: executable) + ::Protocol::Completion::Shell.script(shell: shell, executable: executable) end def self.complete_command(command_class, words, context) @@ -157,7 +157,7 @@ def self.wrap_suggestion(value) end def self.command_name(executable) - ::Completion::Shell.command_name(executable) + ::Protocol::Completion::Shell.command_name(executable) end end end diff --git a/samovar.gemspec b/samovar.gemspec index 04d2592..5bfdd2a 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -26,6 +26,6 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "completion", "~> 0.0" + spec.add_dependency "protocol-completion", "~> 0.0" spec.add_dependency "console", "~> 1.0" end From 7ffb08c82b61a9934d7dcd69e43e01e75791dd41 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 18:35:38 +1200 Subject: [PATCH 18/36] Move shell completion command out --- bin/samovar | 9 - lib/samovar/command.rb | 4 +- lib/samovar/completion.rb | 16 -- lib/samovar/internal/command/completion.rb | 99 ---------- lib/samovar/internal/command/top.rb | 30 --- readme.md | 14 +- samovar.gemspec | 1 - test/samovar/completion.rb | 192 -------------------- test/samovar/internal/command/completion.rb | 128 ------------- 9 files changed, 9 insertions(+), 484 deletions(-) delete mode 100755 bin/samovar delete mode 100644 lib/samovar/internal/command/completion.rb delete mode 100644 lib/samovar/internal/command/top.rb delete mode 100644 test/samovar/internal/command/completion.rb diff --git a/bin/samovar b/bin/samovar deleted file mode 100755 index 9565193..0000000 --- a/bin/samovar +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "../lib/samovar/internal/command/top" - -Samovar::Internal::Command::Top.call diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 3b93d9a..3772e5d 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -29,8 +29,8 @@ class Command # @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, completion_output: $stdout) - if index = Completion.extract_index(ENV) - self.complete(input, index: index).print(completion_output) + if request = Protocol::Completion::Request.extract(input) + self.complete(request.arguments, index: request.index).print(completion_output) return true end diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index ba19f4a..de93a5e 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -43,19 +43,6 @@ def self.complete(command_class, argv, index:, environment: ENV) complete_command(command_class, words, context) end - def self.extract_index(environment = ENV) - ::Protocol::Completion::Index.extract(environment) - end - - # Generate a shell completion script for an executable. - # - # @parameter shell [String | Symbol] The shell name: bash, zsh, or fish. - # @parameter executable [String] The executable name. - # @returns [String] The shell completion script. - def self.script(shell:, executable:) - ::Protocol::Completion::Shell.script(shell: shell, executable: executable) - end - def self.complete_command(command_class, words, context) complete_rows(command_class.table.merged, words.dup, context) end @@ -156,8 +143,5 @@ def self.wrap_suggestion(value) end end - def self.command_name(executable) - ::Protocol::Completion::Shell.command_name(executable) - end end end diff --git a/lib/samovar/internal/command/completion.rb b/lib/samovar/internal/command/completion.rb deleted file mode 100644 index 312e85e..0000000 --- a/lib/samovar/internal/command/completion.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "../../command" -require_relative "../../failure" -require "fileutils" - -module Samovar - module Internal - module Command - # Generate or install shell completion adapter scripts for Samovar-based commands. - class Completion < Samovar::Command - def self.shell_name(path) - File.basename(path.to_s) - end - - def self.default_shell - shell_name(ENV["SHELL"]) - end - - def self.default_directory(shell) - case shell - when "bash" - File.expand_path("~/.local/share/bash-completion/completions") - when "fish" - File.expand_path("~/.config/fish/completions") - when "zsh" - File.expand_path("~/.zsh/completions") - else - raise Failure, "Unsupported shell: #{shell.inspect}" - end - end - - def self.file_name(shell, executable) - case shell - when "bash" - executable - when "fish" - "#{executable}.fish" - when "zsh" - "_#{executable}" - else - raise Failure, "Unsupported shell: #{shell.inspect}" - end - end - - # Generate shell completion adapter scripts for Samovar-based commands. - class Generate < Samovar::Command - self.description = "Generate shell completion adapter scripts." - - options do - option "--shell ", "The shell to generate completions for.", default: Completion.method(:default_shell), completions: ["bash", "zsh", "fish"] - option "--command ", "The command executable to complete.", required: true - end - - def call - output.puts Samovar::Completion.script(shell: @options[:shell].to_sym, executable: @options[:command]) - end - end - - # Install a shell completion adapter script to a user-local completion directory. - class Install < Samovar::Command - self.description = "Install a shell completion adapter script." - - options do - option "--shell ", "The shell to install completions for.", default: Completion.method(:default_shell), completions: ["bash", "zsh", "fish"] - option "--directory ", "The completion directory to install into." - option "--command ", "The command executable to complete.", required: true - end - - def call - shell = @options[:shell] - directory = @options[:directory] || Completion.default_directory(shell) - path = File.join(directory, Completion.file_name(shell, @options[:command])) - script = Samovar::Completion.script(shell: shell.to_sym, executable: @options[:command]) - - FileUtils.mkdir_p(directory) - File.write(path, script) - - output.puts path - end - end - - self.description = "Generate or install shell completion adapter scripts." - - nested :command, { - "install" => Install, - "generate" => Generate, - }, default: "generate" - - def call - @command.call - end - end - end - end -end diff --git a/lib/samovar/internal/command/top.rb b/lib/samovar/internal/command/top.rb deleted file mode 100644 index 6ac4d46..0000000 --- a/lib/samovar/internal/command/top.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require_relative "../../command" -require_relative "completion" - -module Samovar - module Internal - module Command - # The Samovar command-line interface. - class Top < Samovar::Command - self.description = "Utilities for Samovar-based command-line applications." - - options do - option "-h/--help", "Print out help information." - end - - nested :command, { - "completion" => Completion, - }, required: true - - def call - @command.call - end - end - end - end -end diff --git a/readme.md b/readme.md index 8734a18..3fa85f6 100644 --- a/readme.md +++ b/readme.md @@ -38,19 +38,19 @@ class Command < Samovar::Command end ``` -Completion mode is enabled by setting `SAMOVAR_COMPLETE` to the zero-based cursor index in the application arguments: +Completion mode is enabled by setting `COMPLETION_INDEX` to the zero-based cursor index in the application arguments: ``` shell -SAMOVAR_COMPLETE=1 command --for +COMPLETION_INDEX=1 command --for ``` -Applications can also generate shell adapter scripts: +Shell adapter script generation and installation is provided by the `completion` gem: ``` shell -samovar completion --command command -samovar completion --command command --shell zsh -samovar completion install --command command -samovar completion install --command command --shell zsh +completion --command command +completion --command command --shell zsh +completion install --command command +completion install --command command --shell zsh ``` ## Releases diff --git a/samovar.gemspec b/samovar.gemspec index 5bfdd2a..376deb4 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -21,7 +21,6 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/ioquatix/samovar.git", } - spec.executables = ["samovar"] spec.files = Dir.glob(["{bin,context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.3" diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 15a4611..74559cf 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -155,196 +155,4 @@ def values(result) expect(result).to be == true expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end - - it "generates shell completion scripts" do - expect(subject.script(shell: :bash, executable: "samovar")).to be(:include?, "COMPLETION_INDEX") - expect(subject.script(shell: :zsh, executable: "samovar")).to be(:include?, "#compdef samovar") - expect(subject.script(shell: :fish, executable: "samovar")).to be(:include?, "complete -c samovar") - end - - it "uses zsh array indexing to remove the command word" do - script = subject.script(shell: :zsh, executable: "samovar") - - expect(script).to be(:include?, 'local command="${words[1]}"') - expect(script).to be(:include?, 'argv=("${(@)words[2,-1]}")') - end - - it "uses the completed command word as the executable" do - bash = subject.script(shell: :bash, executable: "samovar") - zsh = subject.script(shell: :zsh, executable: "samovar") - fish = subject.script(shell: :fish, executable: "samovar") - - expect(bash).to be(:include?, 'local command="${COMP_WORDS[0]}"') - expect(bash).to be(:include?, 'COMPLETION_INDEX="$index" "$command" "${argv[@]}"') - expect(zsh).to be(:include?, 'COMPLETION_INDEX="$index" "$command" "${argv[@]}"') - expect(fish).to be(:include?, "set -l command $argv[1]") - expect(fish).to be(:include?, "$command $argv") - end - - it "passes application arguments from bash completion" do - skip "bash is not available" unless system("command -v bash >/dev/null") - - path = File.join(root, "bash-trace") - adapter = File.join(root, "samovar.bash") - executable = File.join(root, "samovar") - - File.write(adapter, subject.script(shell: :bash, executable: "samovar")) - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) - source "$ADAPTER" - - COMP_WORDS=(#{executable} completion --shell z) - COMP_CWORD=3 - - _samovar_completion - SCRIPT - - expect(File.read(path)).to be == "2|completion --shell z\n" - end - - it "passes an empty token from bash completion" do - skip "bash is not available" unless system("command -v bash >/dev/null") - - path = File.join(root, "bash-empty-trace") - adapter = File.join(root, "samovar-empty.bash") - executable = File.join(root, "samovar-empty") - - File.write(adapter, subject.script(shell: :bash, executable: "samovar")) - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path, "ADAPTER" => adapter}, "bash", "-c", <<~SCRIPT) - source "$ADAPTER" - - COMP_WORDS=(#{executable} "") - COMP_CWORD=1 - - _samovar_completion - SCRIPT - - expect(File.read(path)).to be == "0|\n" - end - - it "passes application arguments from zsh completion" do - skip "zsh is not available" unless system("command -v zsh >/dev/null") - - path = File.join(root, "trace") - executable = File.join(root, "samovar") - - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" > "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path}, "zsh", "-fc", <<~SCRIPT) - - _describe() { :; } - - words=(#{executable} completion --shell z) - CURRENT=4 - - source <(ruby -Ilib bin/samovar completion --command samovar --shell zsh) - SCRIPT - - expect(File.read(path)).to be == "2|completion --shell z\n" - end - - it "passes application arguments from fish completion" do - skip "fish is not available" unless system("command -v fish >/dev/null") - - path = File.join(root, "fish-trace") - directory = File.join(root, "fish-command") - executable = File.join(directory, "samovar") - - Dir.mkdir(directory) - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) - complete -e -c samovar - source (ruby -Ilib bin/samovar completion --command samovar --shell fish | psub) - set PATH #{root} - complete --do-complete "#{executable} completion --shell z" >/dev/null - SCRIPT - - expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") - end - - it "passes application arguments from fish completion using a relative executable path" do - skip "fish is not available" unless system("command -v fish >/dev/null") - - path = File.join(root, "fish-relative-trace") - directory = File.join(root, "bin") - executable = File.join(directory, "samovar") - - Dir.mkdir(directory) - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) - cd #{root} - complete -e -c samovar - source (ruby -I#{Dir.pwd}/lib #{Dir.pwd}/bin/samovar completion --command samovar --shell fish | psub) - set PATH #{root} - complete --do-complete "bin/samovar completion --shell z" >/dev/null - SCRIPT - - expect(File.readlines(path)).to be(:include?, "2|completion --shell z\n") - end - - it "passes an empty token from fish completion" do - skip "fish is not available" unless system("command -v fish >/dev/null") - - path = File.join(root, "fish-empty-trace") - directory = File.join(root, "fish-empty-command") - executable = File.join(directory, "samovar") - - Dir.mkdir(directory) - File.write(executable, <<~SCRIPT) - #!/bin/sh - printf "%s|%s\\n" "$COMPLETION_INDEX" "$*" >> "$TRACE" - printf "completion\\tGenerate\\tcommand\\n" - SCRIPT - File.chmod(0o755, executable) - - system({"TRACE" => path}, "fish", "--no-config", "-c", <<~SCRIPT) - complete -e -c samovar - source (ruby -Ilib bin/samovar completion --command samovar --shell fish | psub) - set PATH #{root} - complete --do-complete "#{executable} " >/dev/null - SCRIPT - - expect(File.readlines(path)).to be(:include?, "0|\n") - end - - it "uses the basename when registering completion scripts" do - zsh = subject.script(shell: :zsh, executable: "./samovar") - bash = subject.script(shell: :bash, executable: "./samovar") - fish = subject.script(shell: :fish, executable: "./samovar") - - expect(zsh).to be(:include?, "#compdef samovar") - expect(zsh).to be(:include?, "_samovar_completion()") - expect(bash).to be(:include?, "complete -F _samovar_completion samovar") - expect(fish).to be(:include?, "complete -c samovar") - end end diff --git a/test/samovar/internal/command/completion.rb b/test/samovar/internal/command/completion.rb deleted file mode 100644 index 58ca875..0000000 --- a/test/samovar/internal/command/completion.rb +++ /dev/null @@ -1,128 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -require "samovar/internal/command/top" - -describe Samovar::Internal::Command::Completion do - let(:temporary_root) {File.expand_path("../../../tmp", __dir__)} - - def temporary_path(*path) - File.join(temporary_root, *path) - end - - after do - FileUtils.rm_rf(temporary_root) - end - - it "generates a shell completion adapter script" do - output = StringIO.new - command = subject.new(["generate", "--shell", "zsh", "--command", "my-command"], output: output) - - command.call - - expect(output.string).to be(:include?, "#compdef my-command") - expect(output.string).to be(:include?, "COMPLETION_INDEX") - end - - it "requires the command name when generating" do - expect do - subject.new(["generate", "--shell", "zsh"]) - end.to raise_exception(Samovar::MissingValueError) - end - - it "generates a shell completion adapter script with --command" do - output = StringIO.new - command = subject.new(["--shell", "zsh", "--command", "my-command"], output: output) - - command.call - - expect(output.string).to be(:include?, "#compdef my-command") - expect(output.string).to be(:include?, "COMPLETION_INDEX") - end - - it "infers shell when generating" do - output = StringIO.new - shell = ENV["SHELL"] - - begin - ENV["SHELL"] = "/bin/fish" - - command = subject.new(["--command", "my-command"], output: output) - command.call - ensure - ENV["SHELL"] = shell - end - - expect(output.string).to be(:include?, "complete -c my-command") - end - - it "installs a shell completion adapter script to an explicit directory" do - output = StringIO.new - directory = temporary_path("zsh") - command = subject.new(["install", "--shell", "zsh", "--directory", directory, "--command", "my-command"], output: output) - - command.call - - path = File.join(directory, "_my-command") - expect(output.string).to be == "#{path}\n" - expect(File.read(path)).to be(:include?, "#compdef my-command") - expect(File.read(path)).to be(:include?, "COMPLETION_INDEX") - end - - it "infers shell and default directory when installing" do - output = StringIO.new - shell = ENV["SHELL"] - home = ENV["HOME"] - - begin - ENV["SHELL"] = "/bin/fish" - ENV["HOME"] = temporary_path("home") - - command = subject.new(["install", "--command", "my-command"], output: output) - command.call - ensure - ENV["SHELL"] = shell - ENV["HOME"] = home - end - - path = temporary_path("home", ".config", "fish", "completions", "my-command.fish") - expect(output.string).to be == "#{path}\n" - expect(File.read(path)).to be(:include?, "complete -c my-command") - end - - it "can be invoked through the top-level command" do - output = StringIO.new - - Samovar::Internal::Command::Top.new(["completion", "--shell", "bash", "--command", "my-command"], output: output).call - - expect(output.string).to be(:include?, "complete -F _my_command_completion my-command") - end - - it "completes shell names" do - result = Samovar::Internal::Command::Top.complete(["completion", "--shell", "z"], index: 2) - - expect(result.collect(&:value)).to be == ["zsh"] - end - - it "completes the detected shell before other shell names" do - shell = ENV["SHELL"] - - begin - ENV["SHELL"] = "/bin/fish" - - result = Samovar::Internal::Command::Top.complete(["completion", "--shell"], index: 2) - ensure - ENV["SHELL"] = shell - end - - expect(result.collect(&:value)).to be == ["fish", "bash", "zsh"] - end - - it "completes install shell option values" do - result = Samovar::Internal::Command::Top.complete(["completion", "install", "--shell", "f"], index: 3) - - expect(result.collect(&:value)).to be == ["fish"] - end -end From 005d44f26929a313e73ca4813d16e0c7cd6be12b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 20:44:52 +1200 Subject: [PATCH 19/36] Require explicit completion mode --- lib/samovar/command.rb | 9 ++++++--- test/samovar/completion.rb | 10 ++-------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 3772e5d..6846dfe 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -28,9 +28,12 @@ class Command # @parameter input [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, completion_output: $stdout) - if request = Protocol::Completion::Request.extract(input) - self.complete(request.arguments, index: request.index).print(completion_output) + def self.call(input = ARGV, output: $stderr, completion_output: $stdout, completion: false) + if completion + request = Protocol::Completion::Request.extract(input) + index = request.arguments.size - 1 + + self.complete(request.arguments, index: index).print(completion_output) return true end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 74559cf..a127e74 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -141,16 +141,10 @@ def values(result) expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end - it "uses COMPLETION_INDEX as the cursor index in call" do + it "uses the final argument as the completion token in call" do output = StringIO.new - begin - ENV["COMPLETION_INDEX"] = "0" - result = CompletionTop.call(["le"], completion_output: output) - expect(ENV).not.to be(:key?, "COMPLETION_INDEX") - ensure - ENV.delete("COMPLETION_INDEX") - end + result = CompletionTop.call(["le"], completion: true, completion_output: output) expect(result).to be == true expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" From cff9e3762af99f9db6293d61131d2dafe4262bbd Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 21:08:39 +1200 Subject: [PATCH 20/36] Inline completion result support --- gems.rb | 1 - lib/samovar/command.rb | 7 +++--- lib/samovar/completion.rb | 51 +++++++++++++++++++++++++++++++++++--- samovar.gemspec | 1 - test/samovar/completion.rb | 10 ++++++++ 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/gems.rb b/gems.rb index de04794..9527a19 100644 --- a/gems.rb +++ b/gems.rb @@ -6,7 +6,6 @@ source "https://rubygems.org" gemspec -gem "protocol-completion", path: "../protocol-completion" group :maintenance, optional: true do gem "bake-modernize" diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 6846dfe..0deadf4 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -30,10 +30,11 @@ class Command # @returns [Object | Nil] The result of the command's call method, or nil if parsing/execution failed. def self.call(input = ARGV, output: $stderr, completion_output: $stdout, completion: false) if completion - request = Protocol::Completion::Request.extract(input) - index = request.arguments.size - 1 + arguments = input.collect(&:to_s) + arguments = [""] if arguments.empty? + index = arguments.size - 1 - self.complete(request.arguments, index: index).print(completion_output) + self.complete(arguments, index: index).print(completion_output) return true end diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index de93a5e..4305aaa 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -3,13 +3,56 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. -require "protocol/completion" - module Samovar # Shell completion support for Samovar commands. module Completion - Suggestion = ::Protocol::Completion::Candidate - Result = ::Protocol::Completion::Result + # A single completion suggestion. + Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do + def to_s + value.to_s + end + end + + # A collection of completion suggestions. + class Result + include Enumerable + + def initialize(suggestions = []) + @suggestions = suggestions + end + + attr :suggestions + + alias candidates suggestions + + def each(&block) + @suggestions.each(&block) + end + + def empty? + @suggestions.empty? + end + + def +(other) + self.class.new(@suggestions + other.suggestions) + end + + def print(output = $stdout) + each do |suggestion| + output.puts [ + escape(suggestion.value), + escape(suggestion.description), + escape(suggestion.type), + ].join("\t") + end + end + + private + + def escape(value) + value.to_s.gsub(/[\t\r\n]/, " ") + end + end # The context provided to dynamic completion callbacks. Context = Struct.new(:command_class, :argv, :index, :current, :row, :option, :environment, keyword_init: true) diff --git a/samovar.gemspec b/samovar.gemspec index 376deb4..30469c4 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -25,6 +25,5 @@ Gem::Specification.new do |spec| spec.required_ruby_version = ">= 3.3" - spec.add_dependency "protocol-completion", "~> 0.0" spec.add_dependency "console", "~> 1.0" end diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index a127e74..3a55ed1 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -149,4 +149,14 @@ def values(result) expect(result).to be == true expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end + + it "uses an empty token when completing with no arguments" do + output = StringIO.new + + result = CompletionTop.call([], completion: true, completion_output: output) + + expect(result).to be == true + expect(output.string).to be(:include?, "leaf\tLeaf command.\tcommand\n") + expect(output.string).to be(:include?, "--verbose\tEnable verbose output.\toption\n") + end end From 4674ea399ac2148b6993c3bd4446f50eca6f9d86 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 21:13:24 +1200 Subject: [PATCH 21/36] Separate command completion entry point --- lib/samovar/command.rb | 21 +-------------------- lib/samovar/completion.rb | 20 ++++++++++++++++++++ test/samovar/completion.rb | 10 +++++----- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 0deadf4..20803e1 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -28,16 +28,7 @@ class Command # @parameter input [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, completion_output: $stdout, completion: false) - if completion - arguments = input.collect(&:to_s) - arguments = [""] if arguments.empty? - index = arguments.size - 1 - - self.complete(arguments, index: index).print(completion_output) - return true - end - + def self.call(input = ARGV, output: $stderr) self.parse(input).call rescue Error => error error.command.print_usage(output: output) do |formatter| @@ -59,16 +50,6 @@ def self.parse(input) self.new(input) end - # Complete the command-line input without executing the command. - # - # @parameter input [Array(String)] The command-line arguments to complete. - # @parameter index [Integer] The zero-based application argument cursor index. - # @parameter environment [Hash] The environment for completion callbacks. - # @returns [Completion::Result] The completion result. - def self.complete(input = ARGV, index:, environment: ENV) - Completion.complete(self, input, index: index, environment: environment) - end - # Create a new command instance with the given arguments. # # This is a convenience method for creating command instances with explicit arguments. diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 4305aaa..fd61da6 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -4,6 +4,26 @@ # Copyright, 2026, by Samuel Williams. module Samovar + class Command + # Complete the command-line input without executing the command. + # + # @parameter input [Array(String)] The command-line arguments to complete. + # @parameter index [Integer | Nil] The zero-based application argument cursor index. + # @parameter environment [Hash] The environment for completion callbacks. + # @parameter output [IO | Nil] The output stream for printing completion results. + # @returns [Completion::Result] The completion result. + def self.complete(input = ARGV, index: nil, environment: ENV, output: nil) + arguments = input.collect(&:to_s) + arguments = [""] if arguments.empty? + index = arguments.size - 1 unless index + + result = Completion.complete(self, arguments, index: index, environment: environment) + result.print(output) if output + + return result + end + end + # Shell completion support for Samovar commands. module Completion # A single completion suggestion. diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 3a55ed1..07d1c3d 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -141,21 +141,21 @@ def values(result) expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end - it "uses the final argument as the completion token in call" do + it "uses the final argument as the completion token" do output = StringIO.new - result = CompletionTop.call(["le"], completion: true, completion_output: output) + result = CompletionTop.complete(["le"], output: output) - expect(result).to be == true + expect(values(result)).to be == ["leaf"] expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" end it "uses an empty token when completing with no arguments" do output = StringIO.new - result = CompletionTop.call([], completion: true, completion_output: output) + result = CompletionTop.complete([], output: output) - expect(result).to be == true + expect(values(result)).to be(:include?, "leaf") expect(output.string).to be(:include?, "leaf\tLeaf command.\tcommand\n") expect(output.string).to be(:include?, "--verbose\tEnable verbose output.\toption\n") end From 611447549344a97b541dc9cb77fdb8822d4382ef Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 21:29:18 +1200 Subject: [PATCH 22/36] Simplify completion input handling --- lib/samovar/completion.rb | 30 ++++++++---------------------- test/samovar/completion.rb | 34 +++++++++++++++++++--------------- 2 files changed, 27 insertions(+), 37 deletions(-) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index fd61da6..6491bdf 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -8,17 +8,12 @@ class Command # Complete the command-line input without executing the command. # # @parameter input [Array(String)] The command-line arguments to complete. - # @parameter index [Integer | Nil] The zero-based application argument cursor index. # @parameter environment [Hash] The environment for completion callbacks. - # @parameter output [IO | Nil] The output stream for printing completion results. + # @parameter output [IO] The output stream for printing completion results. # @returns [Completion::Result] The completion result. - def self.complete(input = ARGV, index: nil, environment: ENV, output: nil) - arguments = input.collect(&:to_s) - arguments = [""] if arguments.empty? - index = arguments.size - 1 unless index - - result = Completion.complete(self, arguments, index: index, environment: environment) - result.print(output) if output + def self.complete(input = ARGV, environment: ENV, output: $stdout) + result = Completion.complete(self, input, environment: environment) + result.print(output) return result end @@ -75,30 +70,21 @@ def escape(value) end # The context provided to dynamic completion callbacks. - Context = Struct.new(:command_class, :argv, :index, :current, :row, :option, :environment, keyword_init: true) + Context = Struct.new(:command_class, :argv, :current, :row, :option, :environment, keyword_init: true) # Complete the command line for the given command class. # # @parameter command_class [Class] The command class to complete. # @parameter argv [Array(String)] The application arguments. - # @parameter index [Integer] The zero-based application argument cursor index. # @parameter environment [Hash] The environment for completion callbacks. # @returns [Result] The completion result. - def self.complete(command_class, argv, index:, environment: ENV) - argv = argv.collect(&:to_s) - index = Integer(index) - - if index < 0 || index > argv.size - raise ArgumentError, "Completion index out of range: #{index}" - end - - current = index < argv.size ? argv[index] : "" - words = argv.take(index) + def self.complete(command_class, argv, environment: ENV) + current = argv.last || "" + words = argv.take(argv.size - 1) context = Context.new( command_class: command_class, argv: argv, - index: index, current: current, environment: environment, ) diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 07d1c3d..7f18e6d 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -53,88 +53,92 @@ 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 = CompletionTop.complete(["--ver"], index: 0) + result = complete(["--ver"]) expect(values(result)).to be == ["--verbose"] end it "completes top-level options and commands for an empty token" do - result = CompletionTop.complete([], index: 0) + result = complete([""]) expect(values(result)).to be == ["--configuration", "-c", "--verbose", "-v", "leaf", "list"] end it "completes nested command names" do - result = CompletionTop.complete(["le"], index: 0) + result = complete(["le"]) expect(values(result)).to be == ["leaf"] end it "completes nested command options" do - result = CompletionTop.complete(["leaf", "--no"], index: 1) + result = complete(["leaf", "--no"]) expect(values(result)).to be == ["--no-color"] end it "completes boolean flag variants" do - result = CompletionTop.complete(["leaf", "--"], index: 1) + 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 = CompletionTop.complete(["leaf", "--format", "j"], index: 2) + result = complete(["leaf", "--format", "j"]) expect(values(result)).to be == ["json"] end it "completes option defaults before static completions" do - result = CompletionTop.complete(["leaf", "--format"], index: 2) + result = complete(["leaf", "--format", ""]) expect(values(result)).to be == ["text", "json", "yaml"] end it "completes option values after a trailing option flag" do - result = CompletionTop.complete(["leaf", "--format"], index: 2) + result = complete(["leaf", "--format", ""]) expect(values(result)).to be == ["text", "json", "yaml"] end it "completes positional values using method completions" do - result = CompletionTop.complete(["leaf", "r"], index: 1) + result = complete(["leaf", "r"]) expect(values(result)).to be == ["readme.md"] end it "completes many values using callable completions" do - result = CompletionTop.complete(["leaf", "app.rb", "e"], index: 2, environment: {"EXTRA" => "env-extra"}) + 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 = CompletionTop.complete(["leaf", "app.rb", "--"], index: 2) + result = complete(["leaf", "app.rb", "--"]) expect(values(result)).to be == ["--"] end it "completes split values after the marker" do - result = CompletionTop.complete(["leaf", "app.rb", "--", "--c"], index: 3) + result = complete(["leaf", "app.rb", "--", "--c"]) expect(values(result)).to be == ["--child"] end it "uses default nested command for option-looking completions" do - result = CompletionTop.complete(["--no"], index: 0) + result = complete(["--no"]) expect(values(result)).to be == ["--no-color"] end it "prints completion results as TSV" do output = StringIO.new - result = CompletionTop.complete(["le"], index: 0) + result = complete(["le"]) result.print(output) @@ -153,7 +157,7 @@ def values(result) it "uses an empty token when completing with no arguments" do output = StringIO.new - result = CompletionTop.complete([], output: output) + result = CompletionTop.complete([""], output: output) expect(values(result)).to be(:include?, "leaf") expect(output.string).to be(:include?, "leaf\tLeaf command.\tcommand\n") From 896e4d0c95c6f1f9ddc4448affb8e1875a7ff6e1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 22:03:34 +1200 Subject: [PATCH 23/36] Support native path completion requests --- lib/samovar/completion.rb | 12 ++++++++++++ test/samovar/completion.rb | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 6491bdf..fa7003c 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -167,6 +167,7 @@ def self.option_value_suggestions(option, context, row:) def self.provider_suggestions(provider, context, row:, option: nil) return Result.new unless provider + return native_suggestions(provider, context) if provider.is_a?(Symbol) context = context.dup context.row = row @@ -181,6 +182,17 @@ def self.provider_suggestions(provider, context, row:, option: nil) end) end + def self.native_suggestions(provider, context) + case provider + when :path, :file + Result.new([Suggestion.new(value: context.current, description: "Path", type: :path)]) + when :directory + Result.new([Suggestion.new(value: context.current, description: "Directory", type: :directory)]) + else + Result.new + end + end + def self.wrap_suggestion(value) case value when Suggestion diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 7f18e6d..97c90dd 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -15,6 +15,8 @@ def self.path_completions(context) options do option "--format ", "The output format.", default: "text", completions: ["json", "text", "yaml"] + 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 @@ -94,6 +96,22 @@ def complete(input, **options) 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", ""]) From 4bbbdc23dcf74b544cb569416d29003ae4c8ad69 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 22:55:09 +1200 Subject: [PATCH 24/36] Split completion support into classes --- lib/samovar/completion.rb | 181 +-------------------------- lib/samovar/completion/context.rb | 47 +++++++ lib/samovar/completion/provider.rb | 60 +++++++++ lib/samovar/completion/result.rb | 49 ++++++++ lib/samovar/completion/suggestion.rb | 15 +++ lib/samovar/many.rb | 2 +- lib/samovar/nested.rb | 18 ++- lib/samovar/one.rb | 2 +- lib/samovar/option.rb | 18 +++ lib/samovar/options.rb | 34 ++++- lib/samovar/split.rb | 2 +- 11 files changed, 244 insertions(+), 184 deletions(-) create mode 100644 lib/samovar/completion/context.rb create mode 100644 lib/samovar/completion/provider.rb create mode 100644 lib/samovar/completion/result.rb create mode 100644 lib/samovar/completion/suggestion.rb diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index fa7003c..6325aa2 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -3,6 +3,11 @@ # 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 class Command # Complete the command-line input without executing the command. @@ -21,57 +26,6 @@ def self.complete(input = ARGV, environment: ENV, output: $stdout) # Shell completion support for Samovar commands. module Completion - # A single completion suggestion. - Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do - def to_s - value.to_s - end - end - - # A collection of completion suggestions. - class Result - include Enumerable - - def initialize(suggestions = []) - @suggestions = suggestions - end - - attr :suggestions - - alias candidates suggestions - - def each(&block) - @suggestions.each(&block) - end - - def empty? - @suggestions.empty? - end - - def +(other) - self.class.new(@suggestions + other.suggestions) - end - - def print(output = $stdout) - each do |suggestion| - output.puts [ - escape(suggestion.value), - escape(suggestion.description), - escape(suggestion.type), - ].join("\t") - end - end - - private - - def escape(value) - value.to_s.gsub(/[\t\r\n]/, " ") - end - end - - # The context provided to dynamic completion callbacks. - Context = Struct.new(:command_class, :argv, :current, :row, :option, :environment, keyword_init: true) - # Complete the command line for the given command class. # # @parameter command_class [Class] The command class to complete. @@ -79,130 +33,7 @@ def escape(value) # @parameter environment [Hash] The environment for completion callbacks. # @returns [Result] The completion result. def self.complete(command_class, argv, environment: ENV) - current = argv.last || "" - words = argv.take(argv.size - 1) - - context = Context.new( - command_class: command_class, - argv: argv, - current: current, - environment: environment, - ) - - complete_command(command_class, words, context) - end - - def self.complete_command(command_class, words, context) - complete_rows(command_class.table.merged, words.dup, context) - end - - def self.complete_rows(table, input, context) - collected = [] - - table.each do |row| - next unless row.respond_to?(:complete) - - result = row.complete(input, context, collected) - return result if result - end - - Result.new(collected) - end - - def self.consume_options(options, input, context) - while token = input.first - option = options.option_for(token) - break unless option - - flag = option.flag_for(token) - input.shift - - if flag && !flag.boolean? - if input.any? - input.shift - else - return option_value_suggestions(option, context, row: options) - end - end - end - - nil - end - - def self.option_suggestions(options, prefix) - options.flat_map do |option| - option.flags.completions.collect do |value| - next unless value.start_with?(prefix) - - Suggestion.new(value: value, description: option.description, type: :option) - end - end.compact - end - - def self.nested_suggestions(nested, context) - suggestions = nested.commands.collect do |name, command_class| - next unless name.start_with?(context.current) - - Suggestion.new(value: name, description: command_class.description, type: :command) - end.compact - - Result.new(suggestions) - end - - def self.option_value_suggestions(option, context, row:) - suggestions = [] - - if option.default? - suggestion = wrap_suggestion(option.default) - - suggestions << suggestion if suggestion.value.to_s.start_with?(context.current) - end - - (result = provider_suggestions(option.completions, context, row: row, option: option)).each do |suggestion| - suggestions << suggestion unless suggestions.any?{|existing| existing.value == suggestion.value} - end - - Result.new(suggestions) - end - - def self.provider_suggestions(provider, context, row:, option: nil) - return Result.new unless provider - return native_suggestions(provider, context) if provider.is_a?(Symbol) - - context = context.dup - context.row = row - context.option = option - - values = provider.respond_to?(:call) ? provider.call(context) : provider - - Result.new(Array(values).filter_map do |value| - suggestion = wrap_suggestion(value) - - suggestion if suggestion.value.to_s.start_with?(context.current) - end) - end - - def self.native_suggestions(provider, context) - case provider - when :path, :file - Result.new([Suggestion.new(value: context.current, description: "Path", type: :path)]) - when :directory - Result.new([Suggestion.new(value: context.current, description: "Directory", type: :directory)]) - else - Result.new - end - end - - def self.wrap_suggestion(value) - case value - when Suggestion - value - when Hash - Suggestion.new(**value) - else - Suggestion.new(value: value) - end + Context.for(command_class, argv, 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..8881888 --- /dev/null +++ b/lib/samovar/completion/context.rb @@ -0,0 +1,47 @@ +# 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. + Context = Struct.new(:command_class, :argv, :current, :row, :option, :environment, keyword_init: true) do + def self.for(command_class, argv, environment: ENV) + self.new( + command_class: command_class, + argv: argv, + current: argv.last || "", + environment: environment, + ) + end + + def words + argv.take(argv.size - 1) + end + + def complete + complete_command(command_class, words) + end + + def complete_command(command_class, words = []) + complete_rows(command_class.table.merged, words.dup) + end + + def complete_rows(table, input) + collected = [] + + table.each do |row| + next unless row.respond_to?(:complete) + + result = row.complete(input, self, collected) + return result if result + end + + 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..028ccb7 --- /dev/null +++ b/lib/samovar/completion/provider.rb @@ -0,0 +1,60 @@ +# 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 + def initialize(provider, context, row:, option: nil) + @provider = provider + @context = context + @row = row + @option = option + end + + def suggestions + return Result.new unless @provider + return native_suggestions if @provider.is_a?(Symbol) + + context = @context.dup + context.row = @row + context.option = @option + + values = @provider.respond_to?(:call) ? @provider.call(context) : @provider + + Result.new(Array(values).filter_map do |value| + suggestion = wrap(value) + + suggestion if suggestion.value.to_s.start_with?(@context.current) + end) + end + + def native_suggestions + case @provider + when :path, :file + Result.new([Suggestion.new(value: @context.current, description: "Path", type: :path)]) + when :directory + Result.new([Suggestion.new(value: @context.current, description: "Directory", type: :directory)]) + else + Result.new + end + end + + def wrap(value) + case value + when Suggestion + value + when Hash + Suggestion.new(**value) + else + Suggestion.new(value: value) + 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..1c632e8 --- /dev/null +++ b/lib/samovar/completion/result.rb @@ -0,0 +1,49 @@ +# 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 + + def initialize(suggestions = []) + @suggestions = suggestions + end + + attr :suggestions + + alias candidates suggestions + + def each(&block) + @suggestions.each(&block) + end + + def empty? + @suggestions.empty? + end + + def +(other) + self.class.new(@suggestions + other.suggestions) + end + + def print(output = $stdout) + each do |suggestion| + output.puts [ + escape(suggestion.value), + escape(suggestion.description), + escape(suggestion.type), + ].join("\t") + end + end + + private + + def escape(value) + value.to_s.gsub(/[\t\r\n]/, " ") + end + end + end +end diff --git a/lib/samovar/completion/suggestion.rb b/lib/samovar/completion/suggestion.rb new file mode 100644 index 0000000..b442659 --- /dev/null +++ b/lib/samovar/completion/suggestion.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module Samovar + module Completion + # A single completion suggestion. + Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do + def to_s + value.to_s + end + end + end +end diff --git a/lib/samovar/many.rb b/lib/samovar/many.rb index e05c487..0080ce5 100644 --- a/lib/samovar/many.rb +++ b/lib/samovar/many.rb @@ -113,7 +113,7 @@ def complete(input, context, collected) end if input.empty? - Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions end end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index 4a8a963..f3ef501 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -105,10 +105,10 @@ def parse(input, parent = nil, default = nil) # @returns [Completion::Result | Nil] A final completion result, or nil to continue. def complete(input, context, collected) if input.empty? - result = Completion.nested_suggestions(self, context) + result = suggestions(context) if result.empty? && @default - return Completion::Result.new(collected) + Completion.complete_command(@commands.fetch(@default), [], context) + return Completion::Result.new(collected) + context.complete_command(@commands.fetch(@default)) end return Completion::Result.new(collected) + result @@ -116,14 +116,24 @@ def complete(input, context, collected) if command = @commands[input.first] input.shift - Completion.complete_command(command, input, context) + context.complete_command(command, input) elsif @default - Completion.complete_command(@commands.fetch(@default), input, context) + context.complete_command(@commands.fetch(@default), input) else Completion::Result.new(collected) end end + def suggestions(context) + suggestions = @commands.collect do |name, command_class| + next unless name.start_with?(context.current) + + Completion::Suggestion.new(value: 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 25c656d..8d9f40e 100644 --- a/lib/samovar/one.rb +++ b/lib/samovar/one.rb @@ -103,7 +103,7 @@ def parse(input, parent = nil, default = nil) # @returns [Completion::Result | Nil] A final completion result, or nil to continue. def complete(input, context, collected) if input.empty? - Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions elsif @pattern =~ input.first input.shift nil diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index 1d815ab..c450e74 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. @@ -117,6 +119,22 @@ def value? @flags.any?{|flag| !flag.boolean?} end + def suggestions(context, row:) + suggestions = [] + + if default? + suggestion = Completion::Provider.new([default], context, row: row, option: self).suggestions.first + + suggestions << suggestion if suggestion + end + + Completion::Provider.new(@completions, context, row: row, option: self).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. diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index a04be64..4a9d748 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -190,12 +190,12 @@ def parse(input, parent = nil, default = nil) # @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 = Completion.consume_options(self, input, context) + result = consume(input, context) return result if result return unless input.empty? - flags = Completion.option_suggestions(self, context.current) + flags = suggestions(context.current) if context.current.start_with?("-") && flags.any? Completion::Result.new(flags) @@ -205,6 +205,36 @@ def complete(input, context, collected) end end + def consume(input, context) + while token = input.first + option = option_for(token) + break unless option + + flag = option.flag_for(token) + input.shift + + if flag && !flag.boolean? + if input.any? + input.shift + else + return option.suggestions(context, row: self) + end + end + end + + nil + end + + def suggestions(prefix) + flat_map do |option| + option.flags.completions.collect do |value| + next unless value.start_with?(prefix) + + Completion::Suggestion.new(value: value, description: option.description, type: :option) + end + end.compact + end + # Generate a string representation for usage output. # # @returns [String] The usage string. diff --git a/lib/samovar/split.rb b/lib/samovar/split.rb index 3e38f55..9064b72 100644 --- a/lib/samovar/split.rb +++ b/lib/samovar/split.rb @@ -104,7 +104,7 @@ def parse(input, parent = nil, default = nil) def complete(input, context, collected) if offset = input.index(@marker) input.shift(offset + 1) - return Completion::Result.new(collected) + Completion.provider_suggestions(@completions, context, row: self) + return Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions end return Completion::Result.new(collected) unless input.empty? From eab37031a5d4e0734d2d9b7241d4f691341cfc96 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 22:57:00 +1200 Subject: [PATCH 25/36] Move completion entry point to command --- lib/samovar/command.rb | 13 +++++++++++++ lib/samovar/completion.rb | 15 --------------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 20803e1..1c9d9d8 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -38,6 +38,19 @@ def self.call(input = ARGV, output: $stderr) return nil end + # Complete the command-line input without executing the command. + # + # @parameter input [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(input = ARGV, environment: ENV, output: $stdout) + result = Completion.complete(self, input, environment: environment) + result.print(output) + + return result + 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. diff --git a/lib/samovar/completion.rb b/lib/samovar/completion.rb index 6325aa2..b480745 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -9,21 +9,6 @@ require_relative "completion/suggestion" module Samovar - class Command - # Complete the command-line input without executing the command. - # - # @parameter input [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(input = ARGV, environment: ENV, output: $stdout) - result = Completion.complete(self, input, environment: environment) - result.print(output) - - return result - end - end - # Shell completion support for Samovar commands. module Completion # Complete the command line for the given command class. From b7c855635ab2fabd49f9463b61f3db9089fd035a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 23:17:52 +1200 Subject: [PATCH 26/36] Clarify completion control flow --- lib/samovar/completion/context.rb | 2 +- lib/samovar/many.rb | 6 ++++-- lib/samovar/nested.rb | 6 +++--- lib/samovar/one.rb | 6 +++--- lib/samovar/options.rb | 10 ++++++---- lib/samovar/split.rb | 2 +- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/lib/samovar/completion/context.rb b/lib/samovar/completion/context.rb index 8881888..31a730b 100644 --- a/lib/samovar/completion/context.rb +++ b/lib/samovar/completion/context.rb @@ -40,7 +40,7 @@ def complete_rows(table, input) return result if result end - Result.new(collected) + return Result.new(collected) end end end diff --git a/lib/samovar/many.rb b/lib/samovar/many.rb index 0080ce5..cf7a9f6 100644 --- a/lib/samovar/many.rb +++ b/lib/samovar/many.rb @@ -107,14 +107,16 @@ def complete(input, context, collected) if @stop input.shift while input.any? && !(@stop === input.first) - return if @stop === context.current + return nil if @stop === context.current else input.clear end if input.empty? - Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions + return Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions end + + return nil end end end diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index f3ef501..512daa8 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -116,11 +116,11 @@ def complete(input, context, collected) if command = @commands[input.first] input.shift - context.complete_command(command, input) + return context.complete_command(command, input) elsif @default - context.complete_command(@commands.fetch(@default), input) + return context.complete_command(@commands.fetch(@default), input) else - Completion::Result.new(collected) + return Completion::Result.new(collected) end end diff --git a/lib/samovar/one.rb b/lib/samovar/one.rb index 8d9f40e..4089130 100644 --- a/lib/samovar/one.rb +++ b/lib/samovar/one.rb @@ -103,12 +103,12 @@ def parse(input, parent = nil, default = nil) # @returns [Completion::Result | Nil] A final completion result, or nil to continue. def complete(input, context, collected) if input.empty? - Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions + return Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions elsif @pattern =~ input.first input.shift - nil + return nil else - Completion::Result.new(collected) + return Completion::Result.new(collected) end end end diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index 4a9d748..118edbf 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -193,16 +193,18 @@ def complete(input, context, collected) result = consume(input, context) return result if result - return unless input.empty? + return nil unless input.empty? flags = suggestions(context.current) if context.current.start_with?("-") && flags.any? - Completion::Result.new(flags) + return Completion::Result.new(flags) elsif context.current.empty? collected.concat(flags) - nil + return nil end + + return nil end def consume(input, context) @@ -222,7 +224,7 @@ def consume(input, context) end end - nil + return nil end def suggestions(prefix) diff --git a/lib/samovar/split.rb b/lib/samovar/split.rb index 9064b72..06afb90 100644 --- a/lib/samovar/split.rb +++ b/lib/samovar/split.rb @@ -115,7 +115,7 @@ def complete(input, context, collected) suggestions << Completion::Suggestion.new(value: @marker, description: @description, type: :split) end - Completion::Result.new(collected + suggestions) + return Completion::Result.new(collected + suggestions) end end end From 3817461707b6a3ef6c248e9e9f5806d401112bba Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 16 Jun 2026 23:30:58 +1200 Subject: [PATCH 27/36] Document completion implementation --- guides/completion/readme.md | 196 +++++++++++++++++++++++++++++++ guides/links.yaml | 2 + lib/samovar/completion/result.rb | 2 +- readme.md | 26 ++-- test/samovar/completion.rb | 8 +- 5 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 guides/completion/readme.md diff --git a/guides/completion/readme.md b/guides/completion/readme.md new file mode 100644 index 0000000..87e2306 --- /dev/null +++ b/guides/completion/readme.md @@ -0,0 +1,196 @@ +# 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(ARGV) # 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 +~~~ + +## 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. +- `argv`: The full truncated argument list. +- `environment`: The environment hash passed to `complete`. +- `row`: The parser row requesting completions. +- `option`: The option requesting completions, when completing an option value. + +Providers can return strings, hashes, or `Samovar::Completion::Suggestion` instances: + +~~~ ruby +option "--mode ", "The mode.", + completions: [ + {value: "development", description: "Local development", type: :value}, + {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. + +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. + +## 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(ARGV) +~~~ + +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 falcon +~~~ + +Install an adapter script into the default directory for the current shell: + +~~~ bash +$ completion install falcon +~~~ + +You can specify the shell and directory explicitly: + +~~~ bash +$ completion install --shell fish --directory ~/.config/fish/completions falcon +~~~ + +The installed adapter registers completion for the command name and 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/completion/result.rb b/lib/samovar/completion/result.rb index 1c632e8..1114cd3 100644 --- a/lib/samovar/completion/result.rb +++ b/lib/samovar/completion/result.rb @@ -32,9 +32,9 @@ def +(other) def print(output = $stdout) each do |suggestion| output.puts [ + escape(suggestion.type), escape(suggestion.value), escape(suggestion.description), - escape(suggestion.type), ].join("\t") end end diff --git a/readme.md b/readme.md index 3fa85f6..d6146e7 100644 --- a/readme.md +++ b/readme.md @@ -20,38 +20,28 @@ Please see the [project documentation](https://ioquatix.github.io/samovar/) for ### Shell Auto-completion -Samovar can complete command lines using the same command grammar used for parsing. Static completions are generated automatically for options, option aliases, boolean negation flags, and nested command names. +Samovar can complete command lines using the same command grammar used for parsing. It can complete option flags, boolean flag variants, nested command names, option values, positional arguments, split arguments, and native shell path completions. -You can provide value completions for options and positional arguments using `completions:`: +You can provide completions for options and positional arguments using `completions:`: ``` ruby class Command < Samovar::Command - def self.path_completions(context) - Dir.glob("#{context.current}*") - end - options do option "--format ", "Output format.", completions: ["json", "text", "yaml"] + option "--output ", "Output path.", completions: :path end - one :path, "Path to process.", completions: method(:path_completions) + one :path, "Path to process.", completions: :file end ``` -Completion mode is enabled by setting `COMPLETION_INDEX` to the zero-based cursor index in the application arguments: +Completion is exposed through a dedicated entry point: -``` shell -COMPLETION_INDEX=1 command --for +``` ruby +Command.complete(ARGV) ``` -Shell adapter script generation and installation is provided by the `completion` gem: - -``` shell -completion --command command -completion --command command --shell zsh -completion install --command command -completion install --command command --shell zsh -``` +Shell adapter script generation and installation is provided by the `completion` gem. See the [Completion guide](https://ioquatix.github.io/samovar/guides/completion/index) for the complete setup, including the `completion-` executable convention. ## Releases diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 97c90dd..06bb241 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -160,7 +160,7 @@ def complete(input, **options) result.print(output) - expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" + expect(output.string).to be == "command\tleaf\tLeaf command.\n" end it "uses the final argument as the completion token" do @@ -169,7 +169,7 @@ def complete(input, **options) result = CompletionTop.complete(["le"], output: output) expect(values(result)).to be == ["leaf"] - expect(output.string).to be == "leaf\tLeaf command.\tcommand\n" + expect(output.string).to be == "command\tleaf\tLeaf command.\n" end it "uses an empty token when completing with no arguments" do @@ -178,7 +178,7 @@ def complete(input, **options) result = CompletionTop.complete([""], output: output) expect(values(result)).to be(:include?, "leaf") - expect(output.string).to be(:include?, "leaf\tLeaf command.\tcommand\n") - expect(output.string).to be(:include?, "--verbose\tEnable verbose output.\toption\n") + 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 From a95174552c6121dbffc77700d7aefa59af3eae9d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 00:19:23 +1200 Subject: [PATCH 28/36] Document generic completion adapters --- guides/completion/readme.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/guides/completion/readme.md b/guides/completion/readme.md index 87e2306..3e92865 100644 --- a/guides/completion/readme.md +++ b/guides/completion/readme.md @@ -160,22 +160,28 @@ Shell adapter generation and installation is provided by the `completion` gem. Generate an adapter script: ~~~ bash -$ completion generate --shell zsh falcon +$ completion generate --shell zsh --command falcon ~~~ -Install an adapter script into the default directory for the current shell: +Install a generic adapter script into the default directory for the current shell: ~~~ bash -$ completion install falcon +$ 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 falcon +$ completion install --shell fish --directory ~/.config/fish/completions --command falcon ~~~ -The installed adapter registers completion for the command name and calls the matching `completion-` executable when completion is requested. +The installed adapter calls the matching `completion-` executable when completion is requested. ## Testing Completion From c57978c8ae737d57960591e045eea556e982ae2e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 00:28:47 +1200 Subject: [PATCH 29/36] Exclude bin from packaged files --- samovar.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samovar.gemspec b/samovar.gemspec index 30469c4..6cdd325 100644 --- a/samovar.gemspec +++ b/samovar.gemspec @@ -21,7 +21,7 @@ Gem::Specification.new do |spec| "source_code_uri" => "https://github.com/ioquatix/samovar.git", } - spec.files = Dir.glob(["{bin,context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) + spec.files = Dir.glob(["{context,lib}/**/*", "*.md"], File::FNM_DOTMATCH, base: __dir__) spec.required_ruby_version = ">= 3.3" From 9637d3f1c5df01a19b3b6584128b870c2f838e39 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 00:38:03 +1200 Subject: [PATCH 30/36] Rename completion context argv --- guides/completion/readme.md | 2 +- lib/samovar/completion/context.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/guides/completion/readme.md b/guides/completion/readme.md index 3e92865..282406b 100644 --- a/guides/completion/readme.md +++ b/guides/completion/readme.md @@ -84,7 +84,7 @@ end The provider receives a `Samovar::Completion::Context` with: - `current`: The token being completed. -- `argv`: The full truncated argument list. +- `arguments`: The full truncated argument list. - `environment`: The environment hash passed to `complete`. - `row`: The parser row requesting completions. - `option`: The option requesting completions, when completing an option value. diff --git a/lib/samovar/completion/context.rb b/lib/samovar/completion/context.rb index 31a730b..a711e9a 100644 --- a/lib/samovar/completion/context.rb +++ b/lib/samovar/completion/context.rb @@ -8,18 +8,18 @@ module Samovar module Completion # The context provided to dynamic completion callbacks. - Context = Struct.new(:command_class, :argv, :current, :row, :option, :environment, keyword_init: true) do - def self.for(command_class, argv, environment: ENV) + Context = Struct.new(:command_class, :arguments, :current, :row, :option, :environment, keyword_init: true) do + def self.for(command_class, arguments, environment: ENV) self.new( command_class: command_class, - argv: argv, - current: argv.last || "", + arguments: arguments, + current: arguments.last || "", environment: environment, ) end def words - argv.take(argv.size - 1) + arguments.take(arguments.size - 1) end def complete From 8ffe364213d56c8b07d68723b43249b4749de8cc Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 00:44:15 +1200 Subject: [PATCH 31/36] Use default completion arguments in examples --- guides/completion/readme.md | 4 ++-- readme.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/guides/completion/readme.md b/guides/completion/readme.md index 282406b..2f02605 100644 --- a/guides/completion/readme.md +++ b/guides/completion/readme.md @@ -10,7 +10,7 @@ Commands expose a completion entry point alongside the normal execution entry po ~~~ ruby Application.call(ARGV) # Parse and execute the command. -Application.complete(ARGV) # Print completion candidates. +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: @@ -141,7 +141,7 @@ The completion executable can be very small: require_relative "../lib/my/application" -My::Application.complete(ARGV) +My::Application.complete ~~~ When the user completes a command by path, the shell adapter resolves the completion executable next to that command: diff --git a/readme.md b/readme.md index d6146e7..258208b 100644 --- a/readme.md +++ b/readme.md @@ -38,7 +38,7 @@ end Completion is exposed through a dedicated entry point: ``` ruby -Command.complete(ARGV) +Command.complete ``` Shell adapter script generation and installation is provided by the `completion` gem. See the [Completion guide](https://ioquatix.github.io/samovar/guides/completion/index) for the complete setup, including the `completion-` executable convention. From 64fcfeaff11ba9c9a31882cd05a53e3ae46ab361 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 00:49:49 +1200 Subject: [PATCH 32/36] Move completion details to guide --- readme.md | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/readme.md b/readme.md index 258208b..bf094a5 100644 --- a/readme.md +++ b/readme.md @@ -17,31 +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. - -### Shell Auto-completion - -Samovar can complete command lines using the same command grammar used for parsing. It can complete option flags, boolean flag variants, nested command names, option values, positional arguments, split arguments, and native shell path completions. - -You can provide completions for options and positional arguments using `completions:`: - -``` ruby -class Command < Samovar::Command - options do - option "--format ", "Output format.", completions: ["json", "text", "yaml"] - option "--output ", "Output path.", completions: :path - end - - one :path, "Path to process.", completions: :file -end -``` - -Completion is exposed through a dedicated entry point: - -``` ruby -Command.complete -``` - -Shell adapter script generation and installation is provided by the `completion` gem. See the [Completion guide](https://ioquatix.github.io/samovar/guides/completion/index) for the complete setup, including the `completion-` executable convention. + - [Completion](https://ioquatix.github.io/samovar/guides/completion/index) - This guide explains how to add shell completion to commands built with `samovar`. ## Releases From 87ed641aa4cce8f9721a225b3ce951e29565be0c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 01:08:23 +1200 Subject: [PATCH 33/36] Document completion APIs --- lib/samovar/completion/provider.rb | 16 ++++++++++++++++ lib/samovar/completion/result.rb | 16 ++++++++++++++++ lib/samovar/nested.rb | 4 ++++ lib/samovar/option.rb | 5 +++++ lib/samovar/options.rb | 9 +++++++++ 5 files changed, 50 insertions(+) diff --git a/lib/samovar/completion/provider.rb b/lib/samovar/completion/provider.rb index 028ccb7..59eefe7 100644 --- a/lib/samovar/completion/provider.rb +++ b/lib/samovar/completion/provider.rb @@ -10,6 +10,12 @@ module Samovar module Completion # Expands static, dynamic, and native completion providers. class Provider + # Initialize a new completion provider. + # + # @parameter provider [Array | Proc | Symbol | Nil] The static, dynamic, or native provider. + # @parameter context [Context] The completion context. + # @parameter row [Object] The parser row requesting completions. + # @parameter option [Option | Nil] The option requesting completions. def initialize(provider, context, row:, option: nil) @provider = provider @context = context @@ -17,6 +23,9 @@ def initialize(provider, context, row:, option: nil) @option = option end + # Generate suggestions from the provider. + # + # @returns [Result] The matching completion suggestions. def suggestions return Result.new unless @provider return native_suggestions if @provider.is_a?(Symbol) @@ -34,6 +43,9 @@ def suggestions end) end + # Generate native shell completion requests. + # + # @returns [Result] The native completion request suggestions. def native_suggestions case @provider when :path, :file @@ -45,6 +57,10 @@ def native_suggestions end end + # Wrap a raw completion value in a suggestion. + # + # @parameter value [Suggestion | Hash | Object] The value to wrap. + # @returns [Suggestion] The normalized suggestion. def wrap(value) case value when Suggestion diff --git a/lib/samovar/completion/result.rb b/lib/samovar/completion/result.rb index 1114cd3..61a0491 100644 --- a/lib/samovar/completion/result.rb +++ b/lib/samovar/completion/result.rb @@ -9,6 +9,9 @@ module Completion class Result include Enumerable + # Initialize a new completion result. + # + # @parameter suggestions [Array(Suggestion)] The suggestions in this result. def initialize(suggestions = []) @suggestions = suggestions end @@ -17,18 +20,31 @@ def initialize(suggestions = []) alias candidates 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 [ diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index 512daa8..b2b8ea1 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -124,6 +124,10 @@ def complete(input, context, 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) diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index c450e74..398242c 100644 --- a/lib/samovar/option.rb +++ b/lib/samovar/option.rb @@ -119,6 +119,11 @@ def value? @flags.any?{|flag| !flag.boolean?} end + # Complete values for this option. + # + # @parameter context [Completion::Context] The completion context. + # @parameter row [Object] The parser row requesting completions. + # @returns [Completion::Result] The matching option value completions. def suggestions(context, row:) suggestions = [] diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index 118edbf..80361ed 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -207,6 +207,11 @@ def complete(input, context, collected) 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 option = option_for(token) @@ -227,6 +232,10 @@ def consume(input, context) 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| From 657749b0a2cf4ba8bf87265cdd89de22a3315927 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 02:00:03 +1200 Subject: [PATCH 34/36] Use tap for completion entry point --- lib/samovar/command.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 1c9d9d8..184af91 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -45,10 +45,9 @@ def self.call(input = ARGV, output: $stderr) # @parameter output [IO] The output stream for printing completion results. # @returns [Completion::Result] The completion result. def self.complete(input = ARGV, environment: ENV, output: $stdout) - result = Completion.complete(self, input, environment: environment) - result.print(output) - - return result + Completion.complete(self, input, environment: environment).tap do |result| + result.print(output) + end end # Parse the command-line input and create a command instance. From c959caf3fc9b98ac6a90be580cf2b66889f78269 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 12:05:16 +1200 Subject: [PATCH 35/36] Document indexed completion context methods. --- lib/samovar/completion/context.rb | 22 ++++++++++++++++++++++ lib/samovar/completion/suggestion.rb | 3 +++ 2 files changed, 25 insertions(+) diff --git a/lib/samovar/completion/context.rb b/lib/samovar/completion/context.rb index a711e9a..21343fb 100644 --- a/lib/samovar/completion/context.rb +++ b/lib/samovar/completion/context.rb @@ -9,6 +9,12 @@ module Samovar module Completion # The context provided to dynamic completion callbacks. Context = Struct.new(:command_class, :arguments, :current, :row, :option, :environment, keyword_init: true) do + # Build a context for a command class and argument list. + # + # @parameter command_class [Class] The command class being completed. + # @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) self.new( command_class: command_class, @@ -18,18 +24,34 @@ def self.for(command_class, arguments, environment: ENV) ) 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_command(command_class, 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.dup) end + # Complete the rows in a command table. + # + # @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 = [] diff --git a/lib/samovar/completion/suggestion.rb b/lib/samovar/completion/suggestion.rb index b442659..7d26c7b 100644 --- a/lib/samovar/completion/suggestion.rb +++ b/lib/samovar/completion/suggestion.rb @@ -7,6 +7,9 @@ module Samovar module Completion # A single completion suggestion. Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do + # Convert the suggestion to its value. + # + # @returns [String] The suggestion value. def to_s value.to_s end From 8f7f4ca83d9ecea68b82b12ada58be2a3e53867c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 17 Jun 2026 12:20:29 +1200 Subject: [PATCH 36/36] Simplify completion provider context. --- guides/completion/readme.md | 12 ++-- lib/samovar/command.rb | 26 +++++---- lib/samovar/completion.rb | 6 +- lib/samovar/completion/context.rb | 82 ++++++++++++++++++++------ lib/samovar/completion/provider.rb | 69 +++++++++++----------- lib/samovar/completion/result.rb | 15 +---- lib/samovar/completion/suggestion.rb | 82 +++++++++++++++++++++++++- lib/samovar/many.rb | 2 +- lib/samovar/nested.rb | 2 +- lib/samovar/one.rb | 2 +- lib/samovar/option.rb | 8 +-- lib/samovar/options.rb | 7 +-- lib/samovar/split.rb | 16 ++++- test/samovar/completion.rb | 87 ++++++++++++++++++++++++++-- 14 files changed, 311 insertions(+), 105 deletions(-) diff --git a/guides/completion/readme.md b/guides/completion/readme.md index 2f02605..1bb50a1 100644 --- a/guides/completion/readme.md +++ b/guides/completion/readme.md @@ -22,9 +22,11 @@ Application.complete(["serve", "--bind", ""]) Completion candidates are printed as tab-separated values: ~~~ text -type value description +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:`. @@ -86,15 +88,14 @@ 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 requesting completions. -- `option`: The option requesting completions, when completing an option value. +- `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}, + {value: "development", description: "Local development", type: :value, suffix: " "}, {value: "production", description: "Production", type: :value} ] ~~~ @@ -119,9 +120,12 @@ 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. diff --git a/lib/samovar/command.rb b/lib/samovar/command.rb index 184af91..1816065 100644 --- a/lib/samovar/command.rb +++ b/lib/samovar/command.rb @@ -24,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) @@ -39,13 +40,14 @@ def self.call(input = ARGV, output: $stderr) 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 input [Array(String)] The command-line arguments to complete. + # @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(input = ARGV, environment: ENV, output: $stdout) - Completion.complete(self, input, environment: environment).tap do |result| + def self.complete(arguments = ARGV, environment: ENV, output: $stdout) + Completion.complete(self, arguments, environment: environment).tap do |result| result.print(output) end end @@ -55,22 +57,22 @@ def self.complete(input = ARGV, environment: ENV, output: $stdout) # 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 index b480745..fdc56ef 100644 --- a/lib/samovar/completion.rb +++ b/lib/samovar/completion.rb @@ -14,11 +14,11 @@ module Completion # Complete the command line for the given command class. # # @parameter command_class [Class] The command class to complete. - # @parameter argv [Array(String)] The application arguments. + # @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, argv, environment: ENV) - Context.for(command_class, argv, environment: environment).complete + 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 index 21343fb..602cbaf 100644 --- a/lib/samovar/completion/context.rb +++ b/lib/samovar/completion/context.rb @@ -8,47 +8,92 @@ module Samovar module Completion # The context provided to dynamic completion callbacks. - Context = Struct.new(:command_class, :arguments, :current, :row, :option, :environment, keyword_init: true) do + class Context # Build a context for a command class and argument list. - # - # @parameter command_class [Class] The command class being completed. + # + # @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) - self.new( - command_class: command_class, - arguments: arguments, - current: arguments.last || "", + 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) + @arguments.take(@arguments.size - 1) end # Complete the current command class. - # + # # @returns [Result] The completion result. def complete - complete_command(command_class, words) + 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.dup) + 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. @@ -56,10 +101,11 @@ def complete_rows(table, input) collected = [] table.each do |row| - next unless row.respond_to?(:complete) - - result = row.complete(input, self, collected) - return result if result + if row.respond_to?(:complete) + if result = row.complete(input, self, collected) + return result + end + end end return Result.new(collected) diff --git a/lib/samovar/completion/provider.rb b/lib/samovar/completion/provider.rb index 59eefe7..f90bb6c 100644 --- a/lib/samovar/completion/provider.rb +++ b/lib/samovar/completion/provider.rb @@ -12,65 +12,64 @@ module Completion class Provider # Initialize a new completion provider. # - # @parameter provider [Array | Proc | Symbol | Nil] The static, dynamic, or native provider. # @parameter context [Context] The completion context. - # @parameter row [Object] The parser row requesting completions. - # @parameter option [Option | Nil] The option requesting completions. - def initialize(provider, context, row:, option: nil) - @provider = provider + # @parameter completions [Array | Proc | Symbol | Nil] The static, dynamic, or native completions. + def initialize(context, completions) @context = context - @row = row - @option = option + @completions = completions end # Generate suggestions from the provider. # # @returns [Result] The matching completion suggestions. def suggestions - return Result.new unless @provider - return native_suggestions if @provider.is_a?(Symbol) - - context = @context.dup - context.row = @row - context.option = @option + 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 - values = @provider.respond_to?(:call) ? @provider.call(context) : @provider + if values.respond_to?(:call) + values = values.call(@context) + end - Result.new(Array(values).filter_map do |value| - suggestion = wrap(value) + values = Array(values).filter_map do |value| + suggestion = Suggestion.wrap(value) - suggestion if suggestion.value.to_s.start_with?(@context.current) - end) + 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 @provider + case @completions when :path, :file - Result.new([Suggestion.new(value: @context.current, description: "Path", type: :path)]) + Result.new([Suggestion.new(@context.current, description: "Path", type: :path)]) when :directory - Result.new([Suggestion.new(value: @context.current, description: "Directory", type: :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 - # Wrap a raw completion value in a suggestion. - # - # @parameter value [Suggestion | Hash | Object] The value to wrap. - # @returns [Suggestion] The normalized suggestion. - def wrap(value) - case value - when Suggestion - value - when Hash - Suggestion.new(**value) - else - Suggestion.new(value: value) - end - end end end end diff --git a/lib/samovar/completion/result.rb b/lib/samovar/completion/result.rb index 61a0491..887437d 100644 --- a/lib/samovar/completion/result.rb +++ b/lib/samovar/completion/result.rb @@ -16,10 +16,9 @@ def initialize(suggestions = []) @suggestions = suggestions end + # @attribute [Array(Suggestion)] The suggestions in this result. attr :suggestions - alias candidates suggestions - # Iterate over each suggestion. # # @yields {|suggestion| ...} The block to call for each suggestion. @@ -47,19 +46,9 @@ def +(other) # @parameter output [IO] The output stream to write to. def print(output = $stdout) each do |suggestion| - output.puts [ - escape(suggestion.type), - escape(suggestion.value), - escape(suggestion.description), - ].join("\t") + output.puts suggestion.to_record end end - - private - - def escape(value) - value.to_s.gsub(/[\t\r\n]/, " ") - end end end end diff --git a/lib/samovar/completion/suggestion.rb b/lib/samovar/completion/suggestion.rb index 7d26c7b..100ee8d 100644 --- a/lib/samovar/completion/suggestion.rb +++ b/lib/samovar/completion/suggestion.rb @@ -6,12 +6,88 @@ module Samovar module Completion # A single completion suggestion. - Suggestion = Struct.new(:value, :description, :type, keyword_init: true) do + 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 + @value.to_s + end + + private + + def escape(value) + value.to_s.gsub(/[\t\r\n]/, " ") end end end diff --git a/lib/samovar/many.rb b/lib/samovar/many.rb index cf7a9f6..53ea9ca 100644 --- a/lib/samovar/many.rb +++ b/lib/samovar/many.rb @@ -113,7 +113,7 @@ def complete(input, context, collected) end if input.empty? - return Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions + return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions end return nil diff --git a/lib/samovar/nested.rb b/lib/samovar/nested.rb index b2b8ea1..5ef03ea 100644 --- a/lib/samovar/nested.rb +++ b/lib/samovar/nested.rb @@ -132,7 +132,7 @@ def suggestions(context) suggestions = @commands.collect do |name, command_class| next unless name.start_with?(context.current) - Completion::Suggestion.new(value: name, description: command_class.description, type: :command) + Completion::Suggestion.new(name, description: command_class.description, type: :command) end.compact Completion::Result.new(suggestions) diff --git a/lib/samovar/one.rb b/lib/samovar/one.rb index 4089130..7ea6ae1 100644 --- a/lib/samovar/one.rb +++ b/lib/samovar/one.rb @@ -103,7 +103,7 @@ def parse(input, parent = nil, default = nil) # @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(@completions, context, row: self).suggestions + return Completion::Result.new(collected) + Completion::Provider.new(context.with_row(self), @completions).suggestions elsif @pattern =~ input.first input.shift return nil diff --git a/lib/samovar/option.rb b/lib/samovar/option.rb index 398242c..17b13c0 100644 --- a/lib/samovar/option.rb +++ b/lib/samovar/option.rb @@ -122,18 +122,18 @@ def value? # Complete values for this option. # # @parameter context [Completion::Context] The completion context. - # @parameter row [Object] The parser row requesting completions. # @returns [Completion::Result] The matching option value completions. - def suggestions(context, row:) + def suggestions(context) suggestions = [] + context = context.with_row(self) if default? - suggestion = Completion::Provider.new([default], context, row: row, option: self).suggestions.first + suggestion = Completion::Provider.new(context, [default]).suggestions.first suggestions << suggestion if suggestion end - Completion::Provider.new(@completions, context, row: row, option: self).suggestions.each do |suggestion| + Completion::Provider.new(context, @completions).suggestions.each do |suggestion| suggestions << suggestion unless suggestions.any?{|existing| existing.value == suggestion.value} end diff --git a/lib/samovar/options.rb b/lib/samovar/options.rb index 80361ed..84e9227 100644 --- a/lib/samovar/options.rb +++ b/lib/samovar/options.rb @@ -214,8 +214,7 @@ def complete(input, context, collected) # @returns [Completion::Result | Nil] A completion result for an option value, or nil to continue. def consume(input, context) while token = input.first - option = option_for(token) - break unless option + break unless option = option_for(token) flag = option.flag_for(token) input.shift @@ -224,7 +223,7 @@ def consume(input, context) if input.any? input.shift else - return option.suggestions(context, row: self) + return option.suggestions(context) end end end @@ -241,7 +240,7 @@ def suggestions(prefix) option.flags.completions.collect do |value| next unless value.start_with?(prefix) - Completion::Suggestion.new(value: value, description: option.description, type: :option) + Completion::Suggestion.new(value, description: option.description, type: :option) end end.compact end diff --git a/lib/samovar/split.rb b/lib/samovar/split.rb index 06afb90..2e5543e 100644 --- a/lib/samovar/split.rb +++ b/lib/samovar/split.rb @@ -104,7 +104,19 @@ def parse(input, parent = nil, default = nil) def complete(input, context, collected) if offset = input.index(@marker) input.shift(offset + 1) - return Completion::Result.new(collected) + Completion::Provider.new(@completions, context, row: self).suggestions + + 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? @@ -112,7 +124,7 @@ def complete(input, context, collected) suggestions = [] if @marker.start_with?(context.current) - suggestions << Completion::Suggestion.new(value: @marker, description: @description, type: :split) + suggestions << Completion::Suggestion.new(@marker, description: @description, type: :split) end return Completion::Result.new(collected + suggestions) diff --git a/test/samovar/completion.rb b/test/samovar/completion.rb index 06bb241..4b2b17f 100644 --- a/test/samovar/completion.rb +++ b/test/samovar/completion.rb @@ -13,8 +13,15 @@ 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: ["json", "text", "yaml"] + 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 @@ -23,7 +30,7 @@ def self.path_completions(context) 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: ["--child"] + split :argv, "Additional arguments.", completions: :executable end class CompletionList < Samovar::Command @@ -143,9 +150,23 @@ def complete(input, **options) end it "completes split values after the marker" do - result = complete(["leaf", "app.rb", "--", "--c"]) + result = complete(["leaf", "app.rb", "--", "ru"]) + suggestion = result.first - expect(values(result)).to be == ["--child"] + 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 @@ -163,6 +184,64 @@ def complete(input, **options) 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