diff --git a/docsite/source/index.html.md b/docsite/source/index.html.md index 67318aa..6d466e3 100644 --- a/docsite/source/index.html.md +++ b/docsite/source/index.html.md @@ -12,6 +12,7 @@ sections: - variadic-arguments - commands-with-subcommands-and-params - callbacks + - shell-completion --- `dry-cli` is a general-purpose framework for developing Command Line Interface (CLI) applications. It represents commands as objects that can be registered and offers support for arguments, options and forwarding variadic arguments to a sub-command. diff --git a/docsite/source/shell-completion.html.md b/docsite/source/shell-completion.html.md new file mode 100644 index 0000000..79144e9 --- /dev/null +++ b/docsite/source/shell-completion.html.md @@ -0,0 +1,43 @@ +--- +title: Shell completion +layout: gem-single +name: dry-cli +--- + +You can add shell completion to your CLI by registering the `Dry::CLI::ShellCompletion` command: + +```ruby +module Foo + module CLI + module Commands + extend Dry::CLI::Registry + + # ... + + register "complete", Dry::CLI::ShellCompletion + + # ... + end + end +end +``` + +Now your users need to configure their shell of choice. For Bash users, the configuration is very simple: + +```sh +complete -F get_foo_targets foo +function get_foo_targets() +{ + COMPREPLY=(`foo complete "${COMP_WORDS[@]:1}"`) +} + +``` + +When using your CLI, `` will trigger the completion: + +```sh +$ foo s +start stop +$ foo generate # The completion works for subcommands too. +config test +``` diff --git a/lib/dry/cli.rb b/lib/dry/cli.rb index 5f93860..fbb39f4 100644 --- a/lib/dry/cli.rb +++ b/lib/dry/cli.rb @@ -17,6 +17,7 @@ class CLI require "dry/cli/spell_checker" require "dry/cli/banner" require "dry/cli/inflector" + require "dry/cli/shell_completion" # Check if command # @@ -110,6 +111,7 @@ def perform_command(arguments) def perform_registry(arguments) result = registry.get(arguments) return spell_checker(result, arguments) unless result.found? + return shell_completion(registry, result.arguments) if result.command == Dry::CLI::ShellCompletion command, args = parse(result.command, result.arguments, result.names) @@ -171,6 +173,13 @@ def spell_checker(result, arguments) exit(1) end + # @since 1.3.0 + # @api private + def shell_completion(registry, prefixes) + puts registry.complete(prefixes) + exit(0) + end + # Handles Exit codes for signals # Fatal error signal "n". Say 130 = 128 + 2 (SIGINT) or 137 = 128 + 9 (SIGKILL) # diff --git a/lib/dry/cli/command_registry.rb b/lib/dry/cli/command_registry.rb index fa3881f..0f3d57d 100644 --- a/lib/dry/cli/command_registry.rb +++ b/lib/dry/cli/command_registry.rb @@ -75,6 +75,23 @@ def get(arguments) end # rubocop:enable Metrics/AbcSize + # Search for commands/subcommands that match the `prefixes` + # + # @param prefixes [Array] the prefixes to be matched + # + # @since 1.3.0 + # @api private + def complete(prefixes) + initial_lookup = get(prefixes) + + candidates = initial_lookup.children.keys + candidates += @root.aliases.keys if initial_lookup.names.empty? + + pending_prefix = prefixes != initial_lookup.names ? prefixes.last : nil + candidates.filter! {|c| c.start_with?(prefixes.last)} unless pending_prefix.nil? + candidates.join("\n") + end + # Node of the registry # # @since 0.1.0 diff --git a/lib/dry/cli/registry.rb b/lib/dry/cli/registry.rb index de1c2f6..ffdb176 100644 --- a/lib/dry/cli/registry.rb +++ b/lib/dry/cli/registry.rb @@ -270,6 +270,12 @@ def get(arguments) @commands.get(arguments) end + # @since 1.3.0 + # @api private + def complete(prefixes) + @commands.complete(prefixes) + end + private COMMAND_NAME_SEPARATOR = " " diff --git a/lib/dry/cli/shell_completion.rb b/lib/dry/cli/shell_completion.rb new file mode 100644 index 0000000..1db50d4 --- /dev/null +++ b/lib/dry/cli/shell_completion.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Dry + class CLI + # Help users to build a shell completion script by searching commands based on a prefix + # + # @since 1.3.0 + class ShellCompletion < Command + desc "Help you to build a shell completion script by searching commands based on a prefix" + + argument :prefix, desc: "Command name prefix", required: true + end + end +end diff --git a/spec/integration/rendering_spec.rb b/spec/integration/rendering_spec.rb index 3b0209d..3c1767b 100644 --- a/spec/integration/rendering_spec.rb +++ b/spec/integration/rendering_spec.rb @@ -8,6 +8,7 @@ expected = <<~DESC Commands: + foo -c PREFIX # Help you to build a shell completion script by searching commands based on a prefix foo assets [SUBCOMMAND] foo callbacks DIR # Command with callbacks foo console # Starts Foo console diff --git a/spec/integration/shell_completion_spec.rb b/spec/integration/shell_completion_spec.rb new file mode 100644 index 0000000..58a2bfd --- /dev/null +++ b/spec/integration/shell_completion_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "open3" + +RSpec.describe "Shell completion" do + it "returns all available commands when no prefix are provided" do + out, = Open3.capture3("foo -c") + + expected = <<~DESC + -c + assets + console + db + destroy + generate + new + routes + server + version + completion + exec + hello + greeting + sub + root-command + variadic + callbacks + d + g + s + v + -v + --version + DESC + expect(out).to eq(expected) + end + + it "returns all available commands that matches the provided prefix" do + out, = Open3.capture3("foo -c -") + + expected = <<~DESC + -c + -v + --version + DESC + expect(out).to eq(expected) + end + + it "returns nothing when the command is found" do + out, = Open3.capture3("foo -c console") + + expected = <<~DESC + + DESC + expect(out).to eq(expected) + end + + it "returns all subcommands when command is found but no prefix are provided" do + out, = Open3.capture3("foo -c db") + + expected = <<~DESC + apply + console + create + drop + migrate + prepare + version + rollback + DESC + expect(out).to eq(expected) + end + + it "returns all subcommands that matches the provided prefix" do + out, = Open3.capture3("foo -c db c") + + expected = <<~DESC + console + create + DESC + expect(out).to eq(expected) + end + + it "returns nothing when subcommand is found" do + out, = Open3.capture3("foo -c db create") + + expected = <<~DESC + + DESC + expect(out).to eq(expected) + end +end diff --git a/spec/integration/spell_checker_spec.rb b/spec/integration/spell_checker_spec.rb index 30ab94a..2cbc6c1 100644 --- a/spec/integration/spell_checker_spec.rb +++ b/spec/integration/spell_checker_spec.rb @@ -9,6 +9,7 @@ expected = <<~DESC I don't know how to 'routs'. Did you mean: 'routes' ? Commands: + foo -c PREFIX # Help you to build a shell completion script by searching commands based on a prefix foo assets [SUBCOMMAND] foo callbacks DIR # Command with callbacks foo console # Starts Foo console diff --git a/spec/support/fixtures/foo b/spec/support/fixtures/foo index bf37e0f..5931520 100755 --- a/spec/support/fixtures/foo +++ b/spec/support/fixtures/foo @@ -437,6 +437,7 @@ module Foo end end +Foo::CLI::Commands.register "-c", Dry::CLI::ShellCompletion Foo::CLI::Commands.register "assets precompile", Foo::CLI::Commands::Assets::Precompile Foo::CLI::Commands.register "console", Foo::CLI::Commands::Console Foo::CLI::Commands.register "db" do |prefix|