Skip to content

Add built-in shell completion #141

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docsite/source/index.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions docsite/source/shell-completion.html.md
Original file line number Diff line number Diff line change
@@ -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, `<TAB>` will trigger the completion:

```sh
$ foo s<TAB>
start stop
$ foo generate <TAB> # The completion works for subcommands too.
config test
```
9 changes: 9 additions & 0 deletions lib/dry/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
#
Expand Down
17 changes: 17 additions & 0 deletions lib/dry/cli/command_registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ def get(arguments)
end
# rubocop:enable Metrics/AbcSize

# Search for commands/subcommands that match the `prefixes`
#
# @param prefixes [Array<String>] 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
Expand Down
6 changes: 6 additions & 0 deletions lib/dry/cli/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = " "
Expand Down
14 changes: 14 additions & 0 deletions lib/dry/cli/shell_completion.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/integration/rendering_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 92 additions & 0 deletions spec/integration/shell_completion_spec.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions spec/integration/spell_checker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/support/fixtures/foo
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down