├── .rspec ├── spec ├── foo-cli ├── dry │ └── cli │ │ ├── generator │ │ ├── shell_unknown_spec.rb │ │ ├── shell_zsh_spec.rb │ │ └── shell_bash_with_alias_spec.rb │ │ ├── command_spec.rb │ │ └── input_spec.rb ├── spec_helper.rb └── foo.rb ├── .rubocop.yml ├── .gitignore ├── lib └── dry │ └── cli │ ├── completion │ ├── version.rb │ ├── generator.rb │ ├── command.rb │ └── input.rb │ └── completion.rb ├── CHANGELOG.md ├── sig └── dry │ └── cli │ └── completion.rbs ├── Gemfile ├── Dockerfile.development ├── Makefile ├── .github └── workflows │ ├── lint-test-build.yml │ └── build-release.yml ├── LICENSE ├── dry-cli-completion.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /spec/foo-cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require_relative "./foo" 5 | 6 | Dry::CLI.new(Foo::CLI::Commands).call 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: standard 2 | 3 | inherit_gem: 4 | standard: config/base.yml 5 | 6 | AllCops: 7 | TargetRubyVersion: 2.7 8 | Exclude: 9 | - 'vendor/**/*' 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | Gemfile.lock 13 | -------------------------------------------------------------------------------- /lib/dry/cli/completion/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class CLI 5 | module Completion 6 | VERSION = "1.0.0" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | ## 1.0.0 Inital release 3 | 4 | ## 1.0.0-beta Add support for subcommands 5 | 6 | ## 0.9.0-alpha First full fledged Implementation 7 | 8 | ## 0.3.0 Inital Skeleton 9 | -------------------------------------------------------------------------------- /sig/dry/cli/completion.rbs: -------------------------------------------------------------------------------- 1 | module Dry 2 | class CLI 3 | module Completion 4 | VERSION: String 5 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in dry-cli-completion.gemspec 6 | gemspec 7 | 8 | gem "rspec" 9 | gem "standard" 10 | gem "dry-cli" 11 | gem "pry" 12 | -------------------------------------------------------------------------------- /lib/dry/cli/completion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class CLI 5 | module Completion 6 | require_relative "completion/version" 7 | require_relative "completion/generator" 8 | 9 | SUPPORTED_SHELLS = [ 10 | BASH = "bash", 11 | ZSH = "zsh" 12 | ] 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/dry/cli/generator/shell_unknown_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../../foo" 4 | 5 | RSpec.describe Dry::CLI::Completion::Generator do 6 | subject do 7 | Dry::CLI::Completion::Generator.new(Foo::CLI::Commands).call( 8 | shell: "UNKNOWN" 9 | ) 10 | end 11 | 12 | it "raises ArgumentError" do 13 | expect { subject }.to raise_error(ArgumentError) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Dockerfile.development: -------------------------------------------------------------------------------- 1 | FROM ruby:2.7-slim 2 | 3 | RUN set -ex \ 4 | && apt update \ 5 | && apt install -y --no-install-recommends build-essential git 6 | 7 | RUN mkdir /app 8 | 9 | COPY Gemfile *.lock *.gemspec /app/ 10 | COPY lib/dry/cli/completion/version.rb /app/lib/dry/cli/completion/ 11 | 12 | WORKDIR /app 13 | RUN bundle install 14 | RUN bundle binstubs --all --path /bin 15 | 16 | COPY . /app 17 | 18 | ENV HISTCONTROL=ignoreboth:erasedups 19 | 20 | ENTRYPOINT ["bash"] 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/cli/completion" 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = ".rspec_status" 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.filter_run_when_matching :focus 13 | 14 | config.expect_with :rspec do |c| 15 | c.syntax = :expect 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dry/cli/generator/shell_zsh_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../../foo" 4 | 5 | RSpec.describe Dry::CLI::Completion::Generator do 6 | subject do 7 | Dry::CLI::Completion::Generator.new(Foo::CLI::Commands).call(shell: Dry::CLI::Completion::ZSH) 8 | end 9 | 10 | it "returns completion script for zsh" do 11 | is_expected.to include <<~SCRIPT 12 | # enable bash completion support, see https://github.com/dannyben/completely#completions-in-zsh 13 | autoload -Uz +X compinit && compinit 14 | autoload -Uz +X bashcompinit && bashcompinit 15 | SCRIPT 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PROJECT ?= rngtng/dry-cli-completion 2 | BUILD_TAG ?= dev 3 | REGISTRY_TAG = $(PROJECT):$(BUILD_TAG) 4 | 5 | .PHONY: help # List all documented targets 6 | help: 7 | @grep '^.PHONY: .* #' Makefile | sed 's/\.PHONY: \(.*\) # \(.*\)/\1 $(shell echo "\t") \2/' | sort | expand -t20 8 | 9 | .PHONY: build # Build docker image with `dev` tag 10 | build: 11 | docker build -t $(REGISTRY_TAG) -f Dockerfile.development . 12 | 13 | .PHONY: test-gh-action # Test github actions locally with https://github.com/nektos/act 14 | test-gh-action: 15 | echo '{ "inputs": { "tag": "0.1.0" } }' > /tmp/act.json 16 | act --artifact-server-path /tmp/artifacts workflow_dispatch -e /tmp/act.json 17 | 18 | .PHONY: dev # Build docker image, run and ssh inside 19 | dev: 20 | docker run --rm -it -v "$(shell pwd):/app" $(REGISTRY_TAG) 21 | -------------------------------------------------------------------------------- /.github/workflows/lint-test-build.yml: -------------------------------------------------------------------------------- 1 | name: Lint, test & build gem 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | 10 | jobs: 11 | lint-test-build: 12 | name: Ruby ${{ matrix.ruby }} 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '2.7.6' 18 | - '3.1.2' 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Set up Ruby 24 | uses: ruby/setup-ruby@v1 25 | with: 26 | ruby-version: ${{ matrix.ruby }} 27 | bundler-cache: true 28 | 29 | - name: Install dependencies 30 | run: bundle 31 | 32 | - name: Run linter 33 | run: bundle exec rubocop 34 | 35 | - name: Run tests 36 | run: bundle exec rspec 37 | 38 | - name: Build gem 39 | run: gem build *.gemspec 40 | -------------------------------------------------------------------------------- /lib/dry/cli/completion/generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "completely" 4 | 5 | module Dry 6 | class CLI 7 | module Completion 8 | class Generator 9 | require_relative "input" 10 | 11 | def initialize(registry, program_name: nil) 12 | @registry = registry 13 | @program_name = program_name || Dry::CLI::ProgramName.call 14 | end 15 | 16 | def call(shell:, include_aliases: false, out: StringIO.new) 17 | raise ArgumentError, "Unknown shell" unless SUPPORTED_SHELLS.include?(shell) 18 | if shell == ZSH 19 | out.puts "# enable bash completion support, see https://github.com/dannyben/completely#completions-in-zsh" 20 | out.puts "autoload -Uz +X compinit && compinit" 21 | out.puts "autoload -Uz +X bashcompinit && bashcompinit" 22 | end 23 | 24 | out.puts Completely::Completions.new( 25 | Input.new(@registry, @program_name).call(include_aliases: include_aliases) 26 | ).script 27 | out.string 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tobias Bielohlawek (rngtng) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /dry-cli-completion.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/dry/cli/completion/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "dry-cli-completion" 7 | spec.version = Dry::CLI::Completion::VERSION 8 | spec.authors = ["rngtng"] 9 | spec.email = ["tobi@rngtng.com"] 10 | spec.licenses = ["MIT"] 11 | 12 | spec.summary = "Dry::CLI Command to generate a completion script for bash/zsh" 13 | spec.description = "Extension Command for Dry::CLI which generates a completion script for bash/zsh." 14 | spec.homepage = "https://github.com/rngtng/dry-cli-completion" 15 | spec.metadata = { 16 | "homepage_uri" => spec.homepage, 17 | "source_code_uri" => "https://github.com/rngtng/dry-cli-completion", 18 | "changelog_uri" => "https://github.com/rngtng/dry-cli-completion/CHANGELOG.md", 19 | "github_repo" => "ssh://github.com/rngtng/dry-cli-completion", 20 | "rubygems_mfa_required" => "true" 21 | } 22 | 23 | spec.files = Dir["lib/**/*"] + %w[Gemfile LICENSE README.md CHANGELOG.md dry-cli-completion.gemspec] 24 | spec.require_paths = ["lib"] 25 | 26 | spec.required_ruby_version = ">= 2.7.6" 27 | 28 | spec.add_dependency "completely", "~> 0.5" 29 | end 30 | -------------------------------------------------------------------------------- /lib/dry/cli/completion/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class CLI 5 | module Completion 6 | require "dry/cli/completion" 7 | 8 | class Command < Dry::CLI::Command 9 | desc "Print tab completion script for given shell" 10 | 11 | example [ 12 | "bash # Print tab completion script for bash", 13 | "zsh # Print tab completion script for zsh", 14 | "-a bash # Include command aliases for bash" 15 | ] 16 | 17 | option :include_aliases, required: true, type: :boolean, default: false, aliases: ["a"], 18 | desc: "Include command aliases when true" 19 | argument :shell, required: false, type: :string, values: Dry::CLI::Completion::SUPPORTED_SHELLS, 20 | desc: "Shell for which to print the completion script" 21 | 22 | def call(shell:, include_aliases: false, **) 23 | puts Generator.new(self.class.registry).call( 24 | shell: shell, 25 | include_aliases: include_aliases 26 | ) 27 | end 28 | 29 | def self.[](registry) 30 | @registry = registry 31 | self 32 | end 33 | 34 | class << self 35 | attr_reader :registry 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/dry/cli/command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../foo" 4 | 5 | RSpec.describe Dry::CLI::Completion::Command do 6 | subject do 7 | Dry::CLI.new(Foo::CLI::Commands).call(arguments: arguments) 8 | end 9 | 10 | let(:arguments) { %w[completion bash] } 11 | 12 | it "prints script to stdout" do 13 | expect { subject }.to output(/'completion'\*'bash'/).to_stdout 14 | end 15 | 16 | it "prints NOT with zsh adjustments to stdout" do 17 | expect { subject }.to_not output(/autoload -Uz \+X bashcompinit && bashcompinit/).to_stdout 18 | end 19 | 20 | it "prints NOT script with aliases to stdout" do 21 | expect { subject }.to_not output(/'v'\*\)/).to_stdout 22 | end 23 | 24 | context "when zsh given" do 25 | let(:arguments) { %w[completion zsh] } 26 | 27 | it "prints script with zsh adjustments to stdout" do 28 | expect { subject }.to output(/autoload -Uz \+X bashcompinit && bashcompinit/).to_stdout 29 | end 30 | end 31 | 32 | context "when with include alises given" do 33 | let(:arguments) { %w[completion -a bash] } 34 | 35 | it "prints script with aliases to stdout" do 36 | expect { subject }.to output(/'v'\*\)/).to_stdout 37 | end 38 | end 39 | 40 | context "when no shell given" do 41 | let(:arguments) { %w[completion] } 42 | 43 | it "raises error" do 44 | expect { subject }.to raise_error(ArgumentError, "missing keyword: :shell") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and release gem 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | 8 | env: 9 | GEM_NAME: dry-cli-completion 10 | RUBY_VERSION: 3.1.2 11 | VERSION: ${{github.event.release.tag_name}} 12 | 13 | jobs: 14 | validate_tag: 15 | name: Validate Tagname 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: 'Validate tag pattern: ${{ env.VERSION }}' 19 | run: | 20 | if [[ $VERSION =~ v[0-9]+\.[0-9]+\.[0-9]+ ]]; then 21 | echo "Valid tagname, continue release" 22 | else 23 | echo "Invalid tagname, needs doesn't follow pattern: v" 24 | exit 1 25 | fi 26 | 27 | build: 28 | name: Build gem 29 | needs: validate_tag 30 | runs-on: ubuntu-latest 31 | permissions: 32 | contents: read 33 | steps: 34 | - uses: actions/checkout@v2 35 | 36 | - uses: ruby/setup-ruby@v1 37 | with: 38 | ruby-version: ${{env.RUBY_VERSION}} 39 | 40 | - name: Build ${{env.VERSION}} 41 | run: | 42 | gem build $GEM_NAME.gemspec 43 | 44 | - uses: actions/upload-artifact@v3 45 | with: 46 | name: ${{env.GEM_NAME}}-${{env.VERSION}} 47 | path: '*.gem' 48 | 49 | publish: 50 | name: Publish to RubyGems.org 51 | needs: build 52 | runs-on: ubuntu-latest 53 | steps: 54 | - uses: actions/download-artifact@v3 55 | with: 56 | name: ${{env.GEM_NAME}}-${{env.VERSION}} 57 | 58 | - uses: ruby/setup-ruby@v1 59 | with: 60 | ruby-version: ${{env.RUBY_VERSION}} 61 | 62 | - name: Publish ${{env.VERSION}} 63 | run: | 64 | gem push ${{env.GEM_NAME}}*.gem 65 | env: 66 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 67 | 68 | publish_gpr: 69 | name: Publish to GitHub Packages 70 | needs: publish 71 | runs-on: ubuntu-latest 72 | permissions: 73 | packages: write 74 | steps: 75 | - uses: actions/download-artifact@v3 76 | with: 77 | name: ${{env.GEM_NAME}}-${{env.VERSION}} 78 | 79 | - uses: ruby/setup-ruby@v1 80 | with: 81 | ruby-version: ${{env.RUBY_VERSION}} 82 | 83 | - name: Publish ${{env.VERSION}} 84 | run: | 85 | gem push ${{env.GEM_NAME}}*.gem 86 | env: 87 | GEM_HOST_API_KEY: Bearer ${{secrets.GITHUB_TOKEN}} 88 | RUBYGEMS_HOST: https://rubygems.pkg.github.com/${{github.repository_owner}} 89 | -------------------------------------------------------------------------------- /spec/foo.rb: -------------------------------------------------------------------------------- 1 | require "dry/cli" 2 | require "dry/cli/completion/command" 3 | 4 | module Foo 5 | class CLI 6 | module Commands 7 | extend Dry::CLI::Registry 8 | 9 | class Version < Dry::CLI::Command 10 | desc "Print version" 11 | 12 | def call(*) 13 | puts "1.0.0" 14 | end 15 | end 16 | 17 | class Echo < Dry::CLI::Command 18 | desc "Print input" 19 | 20 | argument :input, desc: "Input to print" 21 | 22 | example [ 23 | " # Prints 'wuh?'", 24 | "hello, folks # Prints 'hello, folks'" 25 | ] 26 | 27 | def call(input: nil, **) 28 | if input.nil? 29 | puts "wuh?" 30 | else 31 | puts input 32 | end 33 | end 34 | end 35 | 36 | class Start < Dry::CLI::Command 37 | desc "Start Foo machinery" 38 | 39 | argument :root, required: true, desc: "Root directory" 40 | 41 | example [ 42 | "path/to/root # Start Foo at root directory" 43 | ] 44 | 45 | def call(root:, **) 46 | puts "started - root: #{root}" 47 | end 48 | end 49 | 50 | class Stop < Dry::CLI::Command 51 | desc "Stop Foo machinery" 52 | 53 | option :graceful, type: :boolean, default: true, desc: "Graceful stop" 54 | 55 | def call(**options) 56 | puts "stopped - graceful: #{options.fetch(:graceful)}" 57 | end 58 | end 59 | 60 | class Exec < Dry::CLI::Command 61 | desc "Execute a task" 62 | 63 | argument :task, type: :string, required: true, desc: "Task to be executed" 64 | argument :dirs, type: :array, required: false, desc: "Optional directories" 65 | 66 | def call(task:, dirs: [], **) 67 | puts "exec - task: #{task}, dirs: #{dirs.inspect}" 68 | end 69 | end 70 | 71 | module Generate 72 | class Configuration < Dry::CLI::Command 73 | desc "Generate configuration" 74 | 75 | option :apps, type: :array, default: [], desc: "Generate configuration for specific apps" 76 | 77 | def call(apps:, **) 78 | puts "generated configuration for apps: #{apps.inspect}" 79 | end 80 | end 81 | 82 | class Test < Dry::CLI::Command 83 | desc "Generate tests" 84 | 85 | option :framework, default: "minitest", values: %w[minitest rspec] 86 | 87 | def call(framework:, **) 88 | puts "generated tests - framework: #{framework}" 89 | end 90 | end 91 | end 92 | 93 | register "version", Version, aliases: ["v", "-v", "--version"] 94 | register "echo", Echo 95 | register "start", Start 96 | register "stop", Stop 97 | register "exec", Exec 98 | register "completion", Dry::CLI::Completion::Command[self] 99 | 100 | register "generate", aliases: ["g"] do |prefix| 101 | prefix.register "config", Generate::Configuration 102 | prefix.register "test", Generate::Test 103 | end 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/dry/cli/completion/input.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Dry 4 | class CLI 5 | module Completion 6 | class Input 7 | def initialize(registry, program_name) 8 | @registry = registry 9 | @program_name = program_name 10 | end 11 | 12 | def call(include_aliases:) 13 | commands = extract_commands(root_node, include_aliases: include_aliases) 14 | commands.each_with_object({ 15 | @program_name => commands.keys.map(&:first).uniq + ["help"] 16 | }) do |(name, config), input| 17 | input_line(input, "#{@program_name} #{name.join(" ")}", config[:arguments].shift, config) 18 | end 19 | end 20 | 21 | private 22 | 23 | def extract_commands(parent_node, include_aliases:, prefix: []) 24 | nodes = parent_node.children.dup 25 | nodes.merge!(parent_node.aliases.dup) if include_aliases 26 | 27 | nodes.each_with_object({}) do |(name, sub_node), hash| 28 | key = prefix.dup << name 29 | hash[key] = if sub_node.command 30 | command(sub_node.command, include_aliases: include_aliases) 31 | elsif sub_node.children 32 | hash.merge!(extract_commands(sub_node, include_aliases: include_aliases, prefix: key)) 33 | { 34 | options: {}, 35 | arguments: [ 36 | ["subcommands", sub_node.children.keys] 37 | ] 38 | } 39 | end 40 | end 41 | end 42 | 43 | def root_node 44 | @registry.get({}).instance_variable_get(:@node) 45 | end 46 | 47 | def command(command, include_aliases: false) 48 | { 49 | options: { 50 | "--help" => [] 51 | }, 52 | arguments: command.arguments.map { |arg| 53 | [arg.name, argument_values(arg)] 54 | } 55 | }.tap do |hash| 56 | command.options.each do |opt| 57 | hash[:options]["--#{opt.name}"] = option_values(opt) 58 | next unless include_aliases 59 | opt.aliases.each { |arg| 60 | hash[:options]["-#{arg}"] = option_values(opt) 61 | } 62 | end 63 | end 64 | end 65 | 66 | def argument_values(argument) 67 | return [""] if argument.name.to_s.include?("path") 68 | return argument.values if argument.values 69 | nil 70 | end 71 | 72 | def option_values(option) 73 | return [""] if option.name.to_s.include?("path") 74 | return option.values if option.values 75 | return [] if option.type != :boolean 76 | nil 77 | end 78 | 79 | def input_line(input, name, argument, config) 80 | return if name.include?("") 81 | if argument 82 | arg_values = argument.last || [] 83 | input[name] = arg_values + config[:options].keys 84 | argument = config[:arguments].shift 85 | arg_values.each { |v| input_line(input, "#{name}*#{v}", argument, config) } 86 | else 87 | input[name] = config[:options].keys 88 | end 89 | end 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/dry/cli/generator/shell_bash_with_alias_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../../foo" 4 | 5 | RSpec.describe Dry::CLI::Completion::Generator do 6 | subject do 7 | Dry::CLI::Completion::Generator.new(Foo::CLI::Commands).call( 8 | shell: Dry::CLI::Completion::BASH, 9 | include_aliases: true 10 | ) 11 | end 12 | 13 | it "returns completion script for bash with aliases" do 14 | is_expected.to include <<~SCRIPT 15 | _rspec_completions() { 16 | local cur=${COMP_WORDS[COMP_CWORD]} 17 | local compwords=("${COMP_WORDS[@]:1:$COMP_CWORD-1}") 18 | local compline="${compwords[*]}" 19 | 20 | case "$compline" in 21 | 'completion'*'bash') 22 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --include_aliases -a")" -- "$cur" ) 23 | ;; 24 | 25 | 'generate config'*) 26 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --apps")" -- "$cur" ) 27 | ;; 28 | 29 | 'completion'*'zsh') 30 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --include_aliases -a")" -- "$cur" ) 31 | ;; 32 | 33 | 'generate test'*) 34 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --framework")" -- "$cur" ) 35 | ;; 36 | 37 | 'completion'*) 38 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "bash zsh --help --include_aliases -a")" -- "$cur" ) 39 | ;; 40 | 41 | '--version'*) 42 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 43 | ;; 44 | 45 | 'generate'*) 46 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "config test")" -- "$cur" ) 47 | ;; 48 | 49 | 'g config'*) 50 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --apps")" -- "$cur" ) 51 | ;; 52 | 53 | 'version'*) 54 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 55 | ;; 56 | 57 | 'g test'*) 58 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --framework")" -- "$cur" ) 59 | ;; 60 | 61 | 'start'*) 62 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 63 | ;; 64 | 65 | 'echo'*) 66 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 67 | ;; 68 | 69 | 'stop'*) 70 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help --graceful")" -- "$cur" ) 71 | ;; 72 | 73 | 'exec'*) 74 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 75 | ;; 76 | 77 | '-v'*) 78 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 79 | ;; 80 | 81 | 'v'*) 82 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "--help")" -- "$cur" ) 83 | ;; 84 | 85 | 'g'*) 86 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "config test")" -- "$cur" ) 87 | ;; 88 | 89 | *) 90 | while read -r; do COMPREPLY+=( "$REPLY" ); done < <( compgen -W "$(_rspec_completions_filter "version echo start stop exec completion generate v -v --version g help")" -- "$cur" ) 91 | ;; 92 | 93 | esac 94 | } && 95 | complete -F _rspec_completions rspec 96 | 97 | # ex: filetype=sh 98 | SCRIPT 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dry::CLI::Completion 2 | 3 | [![Gem Version](https://badge.fury.io/rb/dry-cli-completion.svg)](https://badge.fury.io/rb/dry-cli-completion) 4 | 5 | --- 6 | 7 | [Dry::CLI](https://github.com/dry-rb/dry-cli) extension & drop-in Command to generate a completion 8 | script for bash/zsh. It heavily relies on the great work of https://github.com/dannyben/completely. 9 | 10 | --- 11 | 12 | ## Installation 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'dry-cli-completion' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle install 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install dry-cli-completion 26 | 27 | ## Usage 28 | The gems architecture consits of three layers which each abstracts & simplifies the completion script generation and its usage: 29 | 30 | 1. Command layer - drop-in dry-cli command to print the completion script for given shell 31 | 2. Generator layer - the actual script generation leveraging [completely](https://github.com/dannyben/completely) 32 | 3. Input layer - interspecting the dry-cli registry to extract each command with it options, arguements and aliases 33 | 34 | ### 1. Command layer 35 | Simplest usage is to drop in the `Dry::CLI::Completion::Command` to your existing registry: 36 | 37 | ```ruby 38 | require "dry/cli/completion/command" 39 | 40 | module MyRegistry 41 | extend Dry::CLI::Registry 42 | 43 | register "cmd1", MyCmd1 44 | #.... 45 | 46 | register "completion", Dry::CLI::Completion::Command[self] 47 | end 48 | ``` 49 | 50 | or extend the registry subsequently: 51 | 52 | ```ruby 53 | #.... 54 | MyRegistry.register("completion", Dry::CLI::Completion::Command[MyRegistry]) 55 | ``` 56 | 57 | This will extend your cli for a new command `completion` with following usage: 58 | 59 | ```sh 60 | Usage: 61 | foo-cli completion [SHELL] 62 | 63 | Description: 64 | Print tab completion script for given shell 65 | 66 | Arguments: 67 | SHELL # Shell for which to print the completion script: (bash/zsh) 68 | 69 | Options: 70 | --[no-]include-aliases, -a # Include command aliases when true, default: false 71 | --help, -h # Print this help 72 | 73 | Examples: 74 | foo-cli completion bash # Print tab completion script for bash 75 | foo-cli completion zsh # Print tab completion script for zsh 76 | foo-cli completion -a bash # Include command aliases for bash 77 | ``` 78 | 79 | ### 2. Generator Layer 80 | In case you want to change/extend the completion script, create a custom command and leverage just the generator: 81 | 82 | ```ruby 83 | require "dry/cli/completion" 84 | 85 | class MyCommand < Dry::CLI::Command 86 | desc "Custom completion script command" 87 | 88 | def call(**) 89 | script = Dry::CLI::Completion::Generator.new(MyRegistry).call(shell: "bash") 90 | # ... further processing here ... 91 | puts script 92 | end 93 | end 94 | ``` 95 | 96 | ### 3. Input layer 97 | Lastly, if the script generation input needs to be adjusted, leverage the input layer. Here the commands, arguments, options and their values/types extracted and put in an input format for `completely`. See [completely Readme](https://github.com/dannyben/completely) for details. 98 | 99 | ```ruby 100 | require "dry/cli/completion" 101 | 102 | input = Dry::CLI::Completion::Input.new(MyRegistry, "foo").call(include_aliases: false) 103 | # ... further processing here ... 104 | puts Completely::Completions.new(input) 105 | ``` 106 | 107 | ## Development 108 | For local development, this project comes dockerized - with a Dockerimage and Makefile. After checking out the repo, run the following to enter development console: 109 | 110 | $ make build dev 111 | 112 | With that, any dependencies are maintained and no ruby version manager is required. 113 | 114 | ### Testing 115 | The gem comes with a full-fledged rspec testsuite. To execute all tests run in developer console: 116 | 117 | $ rspec 118 | 119 | ### Manual Testing 120 | The `Foo::CLI::Command` registry used in unit test is available as `spec/foo-cli` for manual testing. First source the completion script: 121 | 122 | $ source <(spec/foo-cli completion bash) 123 | 124 | Then try the tab completion yourself: 125 | 126 | ```sh 127 | $ spec/foo-cli 128 | completion echo exec help start stop version 129 | ``` 130 | 131 | ### Linting 132 | For liniting, the gem leverages the [standard Readme](https://github.com/testdouble/standard) and [rubocop](https://github.com/rubocop/rubocop) rules. To execute just run: 133 | 134 | $ rubocop 135 | 136 | ### Releasing 137 | To release a new version, update the version number in `version.rb`, push commits to GitHub and then create a release. This will create a git tag for the version, and push the `.gem` file to [rubygems.org](https://rubygems.org) as well as GitHub packages. 138 | See GitHub actions in `.github` for more. 139 | 140 | ## Supported Ruby versions 141 | 142 | This library officially supports the following Ruby versions: 143 | 144 | * MRI `>= 2.7.0` 145 | 146 | ## License 147 | 148 | See `LICENSE` file. 149 | -------------------------------------------------------------------------------- /spec/dry/cli/input_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Dry::CLI::Completion::Input do 4 | subject(:input) do 5 | Dry::CLI::Completion::Input.new(registry, program_name) 6 | end 7 | 8 | let(:registry) do 9 | Module.new.tap do |mod| 10 | mod.extend Dry::CLI::Registry 11 | end 12 | end 13 | let(:program_name) { "foo" } 14 | 15 | describe ".call" do 16 | subject do 17 | input.call(include_aliases: include_aliases) 18 | end 19 | 20 | let(:include_aliases) { false } 21 | 22 | it do 23 | is_expected.to eq({ 24 | program_name => ["help"] 25 | }) 26 | end 27 | 28 | context "when registry contains command" do 29 | let(:command) { Class.new(Dry::CLI::Command) } 30 | 31 | before do 32 | registry.register "command", command, aliases: ["alias"] 33 | end 34 | 35 | it do 36 | is_expected.to eq({ 37 | "foo" => ["command", "help"], 38 | "foo command" => ["--help"] 39 | }) 40 | end 41 | 42 | context "when include_aliases is true" do 43 | let(:include_aliases) { true } 44 | 45 | it do 46 | is_expected.to eq({ 47 | "foo" => ["command", "alias", "help"], 48 | "foo command" => ["--help"], 49 | "foo alias" => ["--help"] 50 | }) 51 | end 52 | end 53 | 54 | context "when command has argument" do 55 | let(:name) { "a1" } 56 | let(:values) { nil } 57 | 58 | before do 59 | command.argument name, type: :string, values: values, aliases: [:aa1] 60 | end 61 | 62 | it do 63 | is_expected.to eq({ 64 | "foo command" => ["--help"], 65 | "foo" => ["command", "help"] 66 | }) 67 | end 68 | 69 | context "when argument name is path" do 70 | let(:name) { "path" } 71 | 72 | it do 73 | is_expected.to eq({ 74 | "foo command" => ["", "--help"], 75 | "foo" => ["command", "help"] 76 | }) 77 | end 78 | end 79 | 80 | context "when argument values are given" do 81 | let(:values) { %w[v1 v2] } 82 | 83 | it do 84 | is_expected.to eq({ 85 | "foo command" => ["v1", "v2", "--help"], 86 | "foo command*v1" => ["--help"], 87 | "foo command*v2" => ["--help"], 88 | "foo" => ["command", "help"] 89 | }) 90 | end 91 | 92 | context "when multiple arguments with values are given" do 93 | before do 94 | command.argument "a2", type: :string, values: %w[v3 v4] 95 | end 96 | 97 | it do 98 | is_expected.to eq({ 99 | "foo command" => ["v1", "v2", "--help"], 100 | "foo command*v1" => ["v3", "v4", "--help"], 101 | "foo command*v1*v3" => ["--help"], 102 | "foo command*v1*v4" => ["--help"], 103 | "foo command*v2" => ["v3", "v4", "--help"], 104 | "foo command*v2*v3" => ["--help"], 105 | "foo command*v2*v4" => ["--help"], 106 | "foo" => ["command", "help"] 107 | }) 108 | end 109 | end 110 | end 111 | 112 | context "when include_aliases is true" do 113 | let(:include_aliases) { true } 114 | 115 | it do 116 | is_expected.to eq({ 117 | "foo alias" => ["--help"], 118 | "foo command" => ["--help"], 119 | "foo" => ["command", "alias", "help"] 120 | }) 121 | end 122 | end 123 | end 124 | 125 | context "when command has option" do 126 | let(:name) { "o1" } 127 | let(:type) { :string } 128 | let(:values) { nil } 129 | 130 | before do 131 | command.option name, type: type, values: values, aliases: [:ao1] 132 | end 133 | 134 | it do 135 | is_expected.to eq({ 136 | "foo command" => ["--help", "--o1"], 137 | "foo" => ["command", "help"] 138 | }) 139 | end 140 | 141 | context "when option name is path" do 142 | let(:name) { "path" } 143 | 144 | it do 145 | is_expected.to eq({ 146 | "foo command" => ["--help", "--path"], 147 | "foo" => ["command", "help"] 148 | }) 149 | end 150 | end 151 | 152 | context "when option type is boolean" do 153 | let(:type) { :boolean } 154 | 155 | it do 156 | is_expected.to eq({ 157 | "foo command" => ["--help", "--o1"], 158 | "foo" => ["command", "help"] 159 | }) 160 | end 161 | end 162 | 163 | context "when option values are given" do 164 | let(:values) { %w[v1 v2] } 165 | 166 | # TODO: implement me 167 | xit do 168 | is_expected.to eq({ 169 | "foo command" => ["--help", "--o1"], 170 | "foo command*--o1" => ["v1", "v2"], 171 | "foo command*--o1*v1" => ["--help"], 172 | "foo command*--o1*v2" => ["--help"], 173 | "foo" => ["command", "help"] 174 | }) 175 | end 176 | end 177 | 178 | context "when include_aliases is true" do 179 | let(:include_aliases) { true } 180 | 181 | it do 182 | is_expected.to eq({ 183 | "foo command" => ["--help", "--o1", "-ao1"], 184 | "foo alias" => ["--help", "--o1", "-ao1"], 185 | "foo" => ["command", "alias", "help"] 186 | }) 187 | end 188 | end 189 | end 190 | end 191 | 192 | context "when registry contains subcommands" do 193 | let(:subcommand) { Class.new(Dry::CLI::Command) } 194 | 195 | before do 196 | registry.register "prefix" do |prefix| 197 | prefix.register "subcommand", subcommand 198 | end 199 | end 200 | 201 | it do 202 | is_expected.to eq({ 203 | "foo" => ["prefix", "help"], 204 | "foo prefix" => ["subcommand"], 205 | "foo prefix subcommand" => ["--help"], 206 | "foo prefix*subcommand" => [] 207 | }) 208 | end 209 | end 210 | end 211 | end 212 | --------------------------------------------------------------------------------