Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ Layout/EmptyLinesAroundModuleBody:
Metrics/AbcSize:
Enabled: false

Metrics/ClassLength:
CountAsOne:
- array

Metrics/BlockLength:
Exclude:
- "spec/**/*"
Expand Down
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,50 @@ Options:
-h, --help print help
```

## Shell completion

Clamp can generate shell completion scripts for bash, zsh, and fish. This is an opt-in feature:

```ruby
require 'clamp/completion'
```

This adds a hidden `--shell-completions` option to all commands. Use it to generate a completion script:

```sh
$ myapp --shell-completions bash # or: zsh, fish
```

### Activating completions

For **bash**, add to your `~/.bashrc`:

```sh
eval "$(myapp --shell-completions bash)"
```

For **zsh**, add to your `~/.zshrc`:

```sh
eval "$(myapp --shell-completions zsh)"
```

For **fish**, add to your `~/.config/fish/config.fish`:

```sh
myapp --shell-completions fish | source
```

### Programmatic API

You can also generate completion scripts programmatically:

```ruby
script = MyCommand.generate_completion(:fish, "myapp")
```

This returns the completion script as a string, which you can write to a file or use however you like.

## Localization

Clamp comes with support for overriding strings with custom translations. You can use localization library of your choice and override the strings at startup.
Expand Down
1 change: 1 addition & 0 deletions examples/gitdown
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# Demonstrate how subcommands can be declared as classes

require "clamp"
require "clamp/completion"

module GitDown

Expand Down
163 changes: 163 additions & 0 deletions lib/clamp/completion.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# frozen_string_literal: true

require "clamp/command"
require "clamp/completion/bash_generator"
require "clamp/completion/fish_generator"
require "clamp/completion/zsh_generator"

module Clamp

# Shell completion script generation.
#
module Completion

GENERATORS = {
bash: Clamp::Completion::BashGenerator,
fish: Clamp::Completion::FishGenerator,
zsh: Clamp::Completion::ZshGenerator
}.freeze

# Raised when --shell-completions is used; caught by Command.run.
#
class Wanted < StandardError

def initialize(command, shell)
super("completion requested")
@command = command
@shell = shell
end

attr_reader :command, :shell

end

module_function

def generate(command_class, shell, executable_name)
generator_class = GENERATORS.fetch(shell) do
raise ArgumentError, "unsupported shell: #{shell.inspect}"
end
generator_class.new(command_class, executable_name).generate
end

# Return switches with --[no-]foo expanded to --foo and --no-foo.
def expanded_switches(option)
option.switches.flat_map do |switch|
if switch =~ /^--\[no-\](.*)/
["--#{Regexp.last_match(1)}", "--no-#{Regexp.last_match(1)}"]
else
switch
end
end
end

# Options visible in completion (excludes hidden).
def visible_options(command_class)
command_class.recognised_options.reject(&:hidden?)
end

# Walk the command tree depth-first, yielding (command_class, path, has_children).
# Path is an array of Subcommand::Definition objects.
# Always yields, even for revisited classes (with has_children=false).
def walk_command_tree(command_class, path = [], visited = Set.new, &block)
fresh = !visited.include?(command_class)
visited |= [command_class]
has_children = command_class.has_subcommands? && fresh
yield command_class, path, has_children
return unless has_children

command_class.recognised_subcommands.each do |sub|
walk_command_tree(sub.subcommand_class, path + [sub], visited, &block)
end
end

# Collect all subcommand names across the command tree.
def collect_subcommand_names(command_class)
names = []
walk_command_tree(command_class) do |cmd, _path, has_children|
cmd.recognised_subcommands.each { |sub| names.concat(sub.names) } if has_children
end
names.uniq
end

end
end

module Clamp

# Reopened to add completion support.
#
class Command

def self.generate_completion(shell, executable_name)
Clamp::Completion.generate(self, shell, executable_name)
end

# Adds --shell-completions option and handles the Wanted exception.
#
module RunWithCompletion

def run(invocation_path = File.basename($PROGRAM_NAME), arguments = ARGV, context = {})
context[:root_command_class] ||= self
super
rescue Clamp::Completion::Wanted => e
shell_name = File.basename(e.shell).to_sym
begin
puts generate_completion(shell_name, invocation_path)
rescue ArgumentError => ex
$stderr.puts "ERROR: #{ex.message}"
exit(1)
end
end

end

class << self

prepend RunWithCompletion

end

end

end

module Clamp
module Option

# Adds implicit --shell-completions option to all commands.
#
module Declaration

# Declares --shell-completions alongside other implicit options.
#
module WithCompletionOption

def recognised_options
unless @implicit_completion_option_declared
@implicit_completion_option_declared = true
declare_implicit_completion_option
end
super
end

private

def declare_implicit_completion_option
return if effective_options.find { |o| o.handles?("--shell-completions") }

option "--shell-completions", "SHELL",
"generate shell completion script",
hidden: true do |shell|
raise Clamp::Completion::Wanted.new(self, shell)
end
end

end

prepend WithCompletionOption

end

end
end
141 changes: 141 additions & 0 deletions lib/clamp/completion/bash_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# frozen_string_literal: true

module Clamp
module Completion

# Generates bash shell completion scripts.
#
class BashGenerator

def initialize(command_class, executable_name)
@command_class = command_class
@executable_name = executable_name
end

def generate
[
"# Bash completions for #{@executable_name}",
"# Generated by Clamp",
"",
takes_value_function,
"",
completion_function,
"",
"complete -F _#{function_name} #{@executable_name}"
].push("").join("\n")
end

private

def function_name
@executable_name.gsub(/[^a-zA-Z0-9_]/, "_")
end

def completion_function
fn = function_name
[
"_#{fn}() {",
" local cur prev",
" if type _init_completion &>/dev/null; then",
" _init_completion",
" else",
' cur="${COMP_WORDS[COMP_CWORD]}"',
' prev="${COMP_WORDS[COMP_CWORD-1]}"',
" fi",
"",
" local subcmd",
" subcmd=$(__#{fn}_find_subcmd)",
" if __#{fn}_takes_value \"$prev\" \"$subcmd\"; then",
" return",
" fi",
"",
completions_case("$subcmd"),
"}",
"",
find_subcmd_function
].join("\n")
end

def find_subcmd_function
fn = function_name
subcmds = Completion.collect_subcommand_names(@command_class).join("|")
[
"__#{fn}_find_subcmd() {",
" local i=1 word subcmd",
' while [ "$i" -lt "$COMP_CWORD" ]; do',
' word="${COMP_WORDS[$i]}"',
' case "$word" in',
" -*)",
" if __#{fn}_takes_value \"$word\" \"$subcmd\"; then",
" ((i++))",
" fi",
" ;;",
" *)",
' case "$word" in',
" #{subcmds})",
' if [ -z "$subcmd" ]; then',
' subcmd="$word"',
" else",
' subcmd="${subcmd}::${word}"',
" fi",
" ;;",
" esac",
" ;;",
" esac",
" ((i++))",
" done",
' echo "$subcmd"',
"}"
].join("\n")
end

def completions_case(var)
entries = {}
Completion.walk_command_tree(@command_class) do |cmd, path, has_children|
path_str = path.map { |sub| sub.names.first }.join("::")
words = Completion.visible_options(cmd).flat_map { |o| Completion.expanded_switches(o) }
cmd.recognised_subcommands.each { |sub| words.concat(sub.names) } if has_children
entries[path_str] = words.join(" ")
end
lines = [" case \"#{var}\" in"]
entries.each do |path, words|
pattern = path.empty? ? '""' : "\"#{path}\""
lines << " #{pattern})"
lines << " COMPREPLY=($(compgen -W \"#{words}\" -- \"$cur\"))"
lines << " ;;"
end
lines << " esac"
lines.join("\n")
end

def takes_value_function
entries = {}
Completion.walk_command_tree(@command_class) do |cmd, path, _has_children|
path_str = path.map { |sub| sub.names.first }.join("::")
entries[path_str] = Completion.visible_options(cmd).reject(&:flag?)
.flat_map { |o| Completion.expanded_switches(o) }
end
lines = [
"__#{function_name}_takes_value() {",
' local option="$1"',
' local subcmd="$2"',
' case "$subcmd" in'
]
entries.each do |path, switches|
next if switches.empty?

pattern = path.empty? ? '""' : "\"#{path}\""
lines << " #{pattern})"
lines << ' case "$option" in'
lines << " #{switches.join('|')}) return 0 ;;"
lines << " esac"
lines << " ;;"
end
lines.push(" esac", " return 1", "}")
lines.join("\n")
end

end

end
end
Loading