dry-cli-autocomplete 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 38de4ac84fa86c4333894c4e0adc744df06379bebd399fa64353e358c5f1c522
4
+ data.tar.gz: adc97f1a4a65f9f606fd121e4b3aa47e91aae1c0c5e6e26c16e8894b7ebf753b
5
+ SHA512:
6
+ metadata.gz: 88a9c575b4fef1827c3be3322fb5603b07a0634395ca967b0329fafd7d53349856d226f9eb5479a3bedb4b31a021d95167d87e560e4e0a14e2cdb31ef82943fe
7
+ data.tar.gz: b93e2fccd489c023889a5e4fe791864140c87fd1bf44e1032685fa9ad218c73425538c0202850aaabe2fff1322e506a9e1c025cc20ede67543ecb1f50beba62e
@@ -0,0 +1,16 @@
1
+ # Local overrides on top of the synced hanakai-rb config.
2
+
3
+ # The emitters are largely lists of literal shell lines. Splitting them up
4
+ # to satisfy a line count would make them harder to read, not easier, and
5
+ # the zsh one is the largest piece of the gem by design (spec.md §8).
6
+ Metrics/MethodLength:
7
+ Exclude:
8
+ - "lib/dry/cli/autocomplete/emitters/*.rb"
9
+
10
+ Metrics/ClassLength:
11
+ Exclude:
12
+ - "lib/dry/cli/autocomplete/emitters/*.rb"
13
+
14
+ Metrics/BlockLength:
15
+ Exclude:
16
+ - "spec/**/*"
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-08-22
4
+
5
+ - Initial release
data/CLAUDE.md ADDED
@@ -0,0 +1,88 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code (claude.ai/code) working in this repository.
4
+
5
+ ## What this is
6
+
7
+ A Ruby gem that generates static shell completion scripts for any `Dry::CLI` application. A host registers one command; `mycli completion bash` prints a script; the user evaluates it from a shell profile.
8
+
9
+ **Read `SPECIFICATION.md` first.** It carries the design decisions, the measurements behind them, and the acceptance criteria. This file covers how to work in the repository; that one covers what to build and why.
10
+
11
+ The gem is a skeleton from `bundle gem` with no implementation yet. Nothing in `lib/` does anything.
12
+
13
+ ## Environment
14
+
15
+ Ruby is managed by rbenv. Prefix every Ruby command:
16
+
17
+ ```bash
18
+ eval "$(rbenv init -)" && bundle exec rspec
19
+ ```
20
+
21
+ ```bash
22
+ bundle install
23
+ bundle exec rspec # the suite
24
+ bundle exec rubocop # the linter
25
+ bundle exec rubocop -a # autocorrect
26
+ bundle exec rake # both, and the default task
27
+ bin/console # IRB with the gem loaded
28
+ ```
29
+
30
+ The gemspec sets `required_ruby_version >= 3.2.0` and `.rubocop.yml` sets `TargetRubyVersion: 3.2`. Keep the two in step: raising one without the other produces a linter that permits syntax the gemspec claims to support, or the reverse.
31
+
32
+ ## The trap that has already bitten this repository once
33
+
34
+ `bundle gem dry-cli-autocomplete` generates `module Dry; module Cli`. **dry-cli declares `Dry::CLI`, and it is a class, not a module.** Reopening a class as a module raises `TypeError` the moment both are loaded, and the error names neither file usefully.
35
+
36
+ Six files were generated wrong and have been fixed. If you add a file under `lib/dry/cli/`, nest it as:
37
+
38
+ ```ruby
39
+ module Dry
40
+ class CLI # class, and CLI is an acronym
41
+ module Autocomplete
42
+ ```
43
+
44
+ `lib/dry/cli/autocomplete/version.rb` deliberately does **not** `require "dry/cli"`, because the gemspec loads it at build time when the dependency may not be installed. It reopens `class CLI` on its own. dry-cli's `CLI` inherits from `Object`, so an empty reopening is compatible whichever loads first.
45
+
46
+ For deriving names at runtime, use `dry-inflector`. `Dry::Inflector.new { |i| i.acronym("CLI") }` handles both the casing above and the `underscore` needed for shell function identifiers. `Dry::CLI::Inflector` ships with dry-cli but only has `dasherize` and is marked `@api private`; do not depend on it.
47
+
48
+ ## Architecture
49
+
50
+ Four pieces, and the boundary between the first and the rest is load-bearing.
51
+
52
+ | File | Role |
53
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | `lib/dry/cli/autocomplete/command.rb` | The shim a host registers. Defines the command class and nothing else. `require`s the generator **inside `#call`**, never at the top. |
55
+ | `lib/dry/cli/autocomplete/spec_builder.rb` | Walks a registry through public API and returns a shell-agnostic description of every completion. |
56
+ | `lib/dry/cli/autocomplete/emitters/bash.rb` | Turns that description into a `complete -F` script. |
57
+ | `lib/dry/cli/autocomplete/emitters/zsh.rb` | Turns it into a native `#compdef` script with per-option descriptions. |
58
+
59
+ Two rules hold this shape together, both measured rather than assumed:
60
+
61
+ - **The command shim loads no emitter.** A host pays nothing at boot for a command run once per shell. `SPECIFICATION.md` §2.4.
62
+ - **The spec builder touches only the registry.** It must be safe to run at shell startup, because it runs at every shell startup. `SPECIFICATION.md` §2.2.
63
+
64
+ The emitters take the same description and share no code. A fourth shell should be a new emitter class, never a branch inside an existing one.
65
+
66
+ ## Conventions
67
+
68
+ - **The generator is not a hot path.** It runs in 0.067ms against a 27-command registry. Do not optimise it, do not add native extensions, and do not cache anything. The reasoning is in `SPECIFICATION.md` §2.3.
69
+ - **Read a registry through its methods, not its ivars.** `registry.get(path)` returns a result exposing `command`, `children` and `names`. `instance_variable_get(:@node)` is what the gem this one replaces does, and it will break on a dry-cli release. Do not mistake this for a public API: in 1.4.1 `Registry#get`, all of `CommandRegistry`, every `LookupResult` reader and every `Node` reader carry `@api private`. There is no public way to enumerate a registry, so an upgrade can break the walk and the fixture suite is what catches it.
70
+ - **Test against registries this project did not write.** A generator tested against one CLI encodes that CLI's shape. `SPECIFICATION.md` §5.
71
+ - **Validate generated shell with the shell.** `bash -n` and `zsh -n` parse without executing. A regex over generated output proves nothing about whether it runs.
72
+ - **Commit messages**: imperative mood, 50-character subject, no full stop. A body only where the change needs explaining, saying what and why.
73
+ - **Writing prose here**: no em dashes, active voice, plain words. Say what a thing does, not how it feels. If a sentence could appear unchanged in another project's README, it says nothing about this one and should go.
74
+
75
+ ## Repository layout
76
+
77
+ | Path | What it is |
78
+ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
79
+ | `SPECIFICATION.md` | What to build, why, and what "done" means |
80
+ | `lib/dry/cli/autocomplete.rb` | Entry point |
81
+ | `lib/dry/cli/autocomplete/version.rb` | Version, loaded standalone by the gemspec |
82
+ | `sig/` | RBS signatures, generated by `bundle gem` and not yet real |
83
+ | `exe/dry-cli-autocomplete` | Generated by `bundle gem`. **Probably should be deleted**: this is a library, and a host provides the executable. |
84
+ | `.github/workflows/main.yml` | CI |
85
+
86
+ ## Before the first release
87
+
88
+ The gemspec still carries `bundle gem` TODOs that will refuse to build: `spec.summary`, `spec.description`, and `spec.metadata["allowed_push_host"]`. The `dry-` prefix and the `Dry::CLI::Autocomplete` namespace imply an affiliation with dry-rb that does not exist, so say so in the README, or ask them first.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Konstantin Gredeskoul
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # dry-cli-autocomplete
2
+
3
+ [![Ruby](https://github.com/kigster/dry-cli-autocomplete/actions/workflows/main.yml/badge.svg)](https://github.com/kigster/dry-cli-autocomplete/actions/workflows/main.yml)
4
+ ![Coverage](docs/img/badge.svg)
5
+
6
+ Shell completion for [dry-cli](https://github.com/dry-rb/dry-cli) applications, with no Ruby in the TAB path.
7
+
8
+ > [!NOTE]
9
+ >
10
+ > For the specification of this gem see [SPECIFICATION](SPECIFICATION.md)
11
+
12
+ Your CLI knows its own commands, options, aliases and enum values. The shell does not. This gem walks your registry once, prints a bash or zsh script, and you source it from your profile. Pressing TAB then spawns nothing and costs nothing, because every completion the script will ever offer is already inside it.
13
+
14
+ ```bash
15
+ mycli completion bash > /usr/local/etc/bash_completion.d/mycli
16
+ ```
17
+
18
+ > **Status:** not yet released to RubyGems. The registry walk, both emitters and the command shim are written and tested, against three fixture registries, with every generated script checked by `bash -n` or `zsh -n`. Install it from git until there is a released version.
19
+
20
+ ## The problem
21
+
22
+ A dry-cli app with nested subcommands gives the shell nothing to work with. `mycli db <TAB>` completes filenames from the current directory, which is never what you wanted.
23
+
24
+ [`rngtng/dry-cli-completion`](https://github.com/rngtng/dry-cli-completion) already solves part of this, and it is worth reading before you reach for this gem. It falls short in four ways, and each one is an acceptance criterion here.
25
+
26
+ **A group that has both a command and children loses the children.** Register an overview command at a group's bare name so `mycli db --help` can explain the group, and its subcommands stop completing:
27
+
28
+ ```ruby
29
+ register "db", DbStatus # the node now has a command
30
+ register "db migrate", Migrate # ...and children, which never get walked
31
+ ```
32
+
33
+ `mycli db <TAB>` then offers `--help` and nothing else. This is what any app does when it wants group-level help.
34
+
35
+ **File arguments vanish.** `Input#input_line` returns early on `<file>`, so a command with a path argument produces no `compgen -f`, no `-o default`, no `_filedir`. Completing a path is the single most common thing a user wants from a CLI, and it is the one thing that does not work.
36
+
37
+ **The entry point is not free.** `command.rb` opens with a require that pulls the generator, which pulls `completely`. Every host pays for that at boot. Measured: `require "dry/cli"` costs 160ms, and adding the completion gem takes it to 190ms. Thirty milliseconds on every single invocation, for a command that runs once per shell.
38
+
39
+ **zsh is a bashcompinit shim.** It emits `autoload -Uz +X bashcompinit && bashcompinit` followed by bash. That works, but zsh users get no per-option descriptions and none of the behaviour they expect from a native completion.
40
+
41
+ There is a dependency argument too. `completely` pulls `colsole`, `docopt_ng` and `mister_bin`, and `mister_bin` is itself a CLI framework. That is four gems, one of them a second CLI framework, to print a shell script. This gem depends on `dry-cli` and `dry-inflector`, and nothing else.
42
+
43
+ ## What it generates
44
+
45
+ Given this registry:
46
+
47
+ ```ruby
48
+ register "version", Version
49
+ register "deploy", Deploy
50
+ register "db", DbStatus do |prefix|
51
+ prefix.register "migrate", DbMigrate
52
+ end
53
+ register "secret", Secret, hidden: true
54
+ ```
55
+
56
+ `mycli completion bash` prints a `complete -F` function that dispatches on the command path:
57
+
58
+ ```bash
59
+ _mycli_completions() {
60
+ # ...walks COMP_WORDS to find the current command path...
61
+ words=""
62
+ case "$path" in
63
+ "") words="version deploy db" ;;
64
+ "version") words="--format" ;;
65
+ "deploy") words="--force -f" ;;
66
+ "db") words="migrate --verbose" ;;
67
+ esac
68
+
69
+ COMPREPLY=($(compgen -W "$words" -- "$cur"))
70
+ case "$path" in
71
+ "db migrate") COMPREPLY+=($(compgen -f -- "$cur")) ;;
72
+ esac
73
+ }
74
+ complete -F _mycli_completions mycli
75
+ ```
76
+
77
+ Read what that output proves. `db` offers `migrate` alongside its own `--verbose`, so a group with both a command and children keeps both. `db migrate` gets real file completion. `secret` is absent, because hidden commands stay hidden. The `-f` alias on `deploy` is there because you declared it.
78
+
79
+ `mycli completion zsh` prints a native `#compdef` script built on `_arguments` and `_describe`, carrying each option's `desc` as help text next to it.
80
+
81
+ Enum values declared on an option or argument come through at no cost:
82
+
83
+ ```ruby
84
+ option :format, values: %w[json yaml table] # completes json yaml table
85
+ argument :component, values: %w[major minor] # completes major minor
86
+ ```
87
+
88
+ ## Installation
89
+
90
+ Not yet on RubyGems. Until it is, point at the repository:
91
+
92
+ ```ruby
93
+ gem "dry-cli-autocomplete", github: "kigster/dry-cli-autocomplete"
94
+ ```
95
+
96
+ Register the command in your CLI:
97
+
98
+ ```ruby
99
+ require "dry/cli/autocomplete/command"
100
+
101
+ module MyCLI
102
+ extend Dry::CLI::Registry
103
+
104
+ register "version", Version
105
+ register "deploy", Deploy
106
+ register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
107
+ end
108
+ ```
109
+
110
+ That require pulls in the command class and nothing else. No emitter loads until someone actually runs `mycli completion`.
111
+
112
+ Then have your users write the script once and source it. For bash:
113
+
114
+ ```bash
115
+ mycli completion bash > /usr/local/etc/bash_completion.d/mycli
116
+ ```
117
+
118
+ For zsh, put it anywhere on your `$fpath`:
119
+
120
+ ```bash
121
+ mycli completion zsh > "${fpath[1]}/_mycli"
122
+ ```
123
+
124
+ Regenerate it when you add or rename commands. Nothing watches for changes, by design.
125
+
126
+ ## Why the script is static
127
+
128
+ Cobra and clap route every TAB press to a hidden `__complete` subcommand. That is the right call for a Go or Rust binary that starts in 10ms. It is the wrong call here.
129
+
130
+ | Measurement | Time |
131
+ | --------------------------------------------------------- | -------------: |
132
+ | Bare `ruby -e ''` | 100ms |
133
+ | `require "dry/cli"` | 160ms |
134
+ | `require "dry/cli"` + `dry-cli-completion` + `completely` | 190ms |
135
+ | `require "tax_engine"` (a heavy host) | 520ms |
136
+ | First touch of that host's data store | +239ms |
137
+ | **Registry walk and full completion spec build** | **0.067ms** |
138
+ | Generated bash script for 27 commands | 257 lines, 9KB |
139
+
140
+ Half a second of dead air per keystroke is unusable, and no amount of lazy loading gets under the host's own require cost. So there is no `__complete` command. It was considered, costed at roughly 90 lines, and rejected on that table.
141
+
142
+ The same table explains two other decisions. The generator will not be optimised, because at 0.067ms it is 0.01% of the cheapest possible invocation and all the time goes to interpreter startup. Native extensions were rejected for the same reason, plus they would put a compiled artifact in every consumer's dependency chain.
143
+
144
+ ## What it will not do
145
+
146
+ **Values your host has to compute.** The walk touches only objects dry-cli already holds. The moment an option's `values:` calls into your data layer, that cost lands at class-definition time on *every* invocation, not just completion. In the profiled host that meant 239ms of YAML parsing added to shell startup. Declare the values on the option, where dry-cli validates against them anyway and the generator sees them free.
147
+
148
+ **fish, PowerShell, nushell.** Worth adding later. The emitter interface is built so a fourth shell is a new class rather than a new branch in an existing one.
149
+
150
+ **Watch your registry.** Regenerating is your call, in your release process.
151
+
152
+ ## Development
153
+
154
+ Ruby 3.2 or newer. This repository uses rbenv, so activate it first:
155
+
156
+ ```bash
157
+ eval "$(rbenv init -)"
158
+ bundle install
159
+ bundle exec rspec # the suite
160
+ bundle exec rubocop # the linter
161
+ bundle exec rake # both
162
+ bin/console # IRB with the gem loaded
163
+ ```
164
+
165
+ Two conventions in the suite are worth knowing before you add to it. Fixtures include registries this project did not write, because a generator tested against one CLI quietly encodes that CLI's shape. And generated scripts are validated by the shells themselves, with `bash -n` and `zsh -n` parsing without executing, since a regex over the output proves nothing about whether it runs.
166
+
167
+ ## Contributing
168
+
169
+ Bug reports and pull requests are welcome at <https://github.com/kigster/dry-cli-autocomplete>.
170
+
171
+ A note on the name. The `dry-` prefix and the `Dry::CLI::Autocomplete` namespace do not imply endorsement by dry-rb. This is an independent gem that extends theirs.
172
+
173
+ ## License
174
+
175
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "timeout"
6
+ require "yard"
7
+
8
+ def shell(*args)
9
+ puts "running: #{args.join(" ")}"
10
+ system(args.join(" "))
11
+ end
12
+
13
+ task :clean do
14
+ shell("rm -rf pkg/ tmp/ coverage/ doc/ ")
15
+ end
16
+
17
+ task gem: [:build] do
18
+ shell("gem install pkg/*")
19
+ end
20
+
21
+ task permissions: [:clean] do
22
+ # One traversal replaces a six-level glob chain that printed "No such file
23
+ # or directory" for every level this project does not have, skipped dotfiles
24
+ # entirely, and silently stopped at depth six. .git is pruned — its objects
25
+ # have no business being group-readable.
26
+ shell("find . -path ./.git -prune -o -type d -exec chmod o+rx,g+rx {} + -o -type f -exec chmod o+r,g+r {} +")
27
+ end
28
+
29
+ task build: :permissions
30
+
31
+ YARD::Rake::YardocTask.new(:doc) do |t|
32
+ t.files = %w[lib/**/*.rb exe/*.rb - README.md LICENSE.txt CHANGELOG.md]
33
+ t.options.unshift("--title", '"FlowEngine — DSL + AST for buildiong complex flows in Ruby."')
34
+ t.after = -> { exec("open doc/index.html") } if RUBY_PLATFORM =~ /darwin/
35
+ end
36
+
37
+ RSpec::Core::RakeTask.new(:spec)
38
+
39
+ task default: :spec
data/SPECIFICATION.md ADDED
@@ -0,0 +1,246 @@
1
+ # `dry-cli-autocomplete`: Specification
2
+
3
+ Generate static shell completion scripts for any `Dry::CLI` application, from the command registry alone.
4
+
5
+ The intended use is one line in a shell profile:
6
+
7
+ ```bash
8
+ eval "$(mycli completion bash)" # ~/.bashrc
9
+ eval "$(mycli completion zsh)" # ~/.zshrc
10
+ ```
11
+
12
+ The script is regenerated when the shell starts, so a new command in the host application completes as soon as it ships. Pressing TAB runs nothing: the shell matches against a word list the script already carries.
13
+
14
+ ## Research
15
+
16
+ Research discovered a completion gems [https://github.com/rngtng/dry-cli-completion](https://github.com/rngtng/dry-cli-completion) howerver it lacks in a several areas (see below).
17
+
18
+ ## Motivation
19
+
20
+ ## 1. Why this exists when `dry-cli-completion` already does
21
+
22
+ `rngtng/dry-cli-completion` (MIT, v2.0.0) works and is the obvious starting point. Read it before writing anything. It falls short in four specific ways, each of which is an acceptance criterion below.
23
+
24
+ **1.1 A node carrying both a command and children loses its children.** `Input#extract_commands` branches `if sub_node.command ... elsif sub_node.children`. An application that registers an overview command at a group's bare name, so that `mycli db --help` can explain what the group is for, gets:
25
+
26
+ ```ruby
27
+ register "db", DbOverview # node now has a command
28
+ register "db migrate", Migrate # ...and children, which are never walked
29
+ ```
30
+
31
+ Completion for `mycli db <TAB>` offers `--help` and nothing else. The subcommands are invisible. This is not exotic: it is what any application does when it wants group-level help.
32
+
33
+ **1.2 File arguments are dropped silently.** `Input#input_line` opens with `return if name.include?("<file>")`. A command whose argument name matches `/path/` produces no entry at all, and the generated script contains no `compgen -f`, no `-o default`, no `_filedir`. So `mycli deploy <TAB>` on a path argument completes nothing, which is the single most common thing a user wants.
34
+
35
+ **1.3 There is no light entry point.** `command.rb` opens with `require "dry/cli/completion"`, which loads the generator, which loads `completely`. A host that only wants to register the command pays for the whole tree at boot. Measured: `require "dry/cli"` is 160ms, adding the completion gem makes it 190ms. Thirty milliseconds on every invocation of a command run once per shell.
36
+
37
+ **1.4 zsh is a bashcompinit shim.** It emits `autoload -Uz +X bashcompinit && bashcompinit` and then bash. It works, but zsh users get no per-option descriptions and none of the native behaviour they expect.
38
+
39
+ There is also a dependency argument. `completely` pulls `colsole`, `docopt_ng` and `mister_bin`, and `mister_bin` is itself a CLI framework. Four gems, one of them a second CLI framework, to emit a shell script. This gem generates the script itself and depends on `dry-cli` and `dry-inflector` only.
40
+
41
+ ## 2. Design decisions, and the measurements behind them
42
+
43
+ These were settled by profiling a real dry-cli application (`tax_engine`, 27 commands, 33 options, 7 arguments). Reproduce them before overturning any of this.
44
+
45
+ | Measurement | Time |
46
+ | --------------------------------------------------------- | -------------: |
47
+ | Bare `ruby -e ''` | 100ms |
48
+ | `require "dry/cli"` | 160ms |
49
+ | `require "dry/cli"` + `dry-cli-completion` + `completely` | 190ms |
50
+ | `require "tax_engine"` (a gem with 2.5M lines of data) | 520ms |
51
+ | First touch of that host's data store | +239ms |
52
+ | **Registry walk and full completion spec build** | **0.067ms** |
53
+ | Generated bash script for 27 commands | 257 lines, 9KB |
54
+
55
+ ### 2.1 Static generation, never a runtime callback
56
+
57
+ Cobra and clap route every TAB press to a hidden `__complete` subcommand. That is correct for a Go or Rust binary that starts in 10ms. It is wrong here: a Ruby host costs 100ms at absolute best and 520ms in the case measured above. Half a second of dead air per keystroke is unusable, and no amount of lazy loading gets under the host's own require cost.
58
+
59
+ So the generated script carries every completion it will ever offer, and TAB spawns no process. **Do not add a `__complete` command.** It was considered, costed at about 90 lines, and rejected on this measurement.
60
+
61
+ ### 2.2 The generator reads the registry and never forces anything beyond it
62
+
63
+ The full walk plus spec build takes 0.067ms because it only touches objects dry-cli already holds. The moment an option's `values:` calls into a host's data, that cost lands at class-definition time on *every* invocation of the host, not just completion. In the profiled host that would have added 239ms of YAML parsing to shell startup.
64
+
65
+ Enum values *declared on an option* are free and must be included:
66
+
67
+ ```ruby
68
+ option :format, values: %w[json yaml table] # completes json yaml table
69
+ argument :component, values: %w[major minor] # completes major minor
70
+ ```
71
+
72
+ Values a host would have to compute are out of scope. There is no API for them. A host that wants them declares them as a constant on the option, where dry-cli validates the input and the generator sees it for free.
73
+
74
+ ### 2.3 Optimising the generator is pointless
75
+
76
+ At 0.067ms, the work this gem does is 0.01% of the cheapest possible invocation. Native extensions were considered and rejected: they cannot reduce interpreter startup, which is where all the time goes, and they would put a compiled artifact in the dependency chain of every consumer. Keep it plain Ruby.
77
+
78
+ ### 2.4 Nothing loads until the command runs
79
+
80
+ The host registers a command whose file pulls in no emitters:
81
+
82
+ ```ruby
83
+ require "dry/cli/autocomplete/command"
84
+ register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
85
+ ```
86
+
87
+ `command.rb` must define the command class and nothing else, and `require` the generator inside `#call`. This is the mistake in §1.3 and it cannot be retrofitted politely, so build it this way from the first commit. Verified working: after registering the shim, `defined?(Dry::CLI::Autocomplete::Generator)` is nil and `$LOADED_FEATURES` shows nothing, until the command is invoked.
88
+
89
+ ## 3. Reading a registry
90
+
91
+ Everything needed is public API. **Do not use `instance_variable_get(:@node)`**, which is what the existing gem does. `Registry#get` returns a lookup result exposing `command`, `children` and `names`.
92
+
93
+ This walk is proven against a foreign registry:
94
+
95
+ ```ruby
96
+ def walk(registry, path = [], acc = [])
97
+ result = registry.get(path)
98
+ acc << [path, result.command, (result.children || {}).keys]
99
+ (result.children || {}).each_key { |name| walk(registry, path + [name], acc) }
100
+ acc
101
+ end
102
+ ```
103
+
104
+ Available per command: `.options` and `.arguments`. Per option: `name`, `type`, `values`, `aliases`, `default`, `desc`, `required?`, `boolean?`, `array?`. Per argument: `name`, `values`, `desc`, `required?`. Per node: `children`, `command`, `aliases`, `hidden`.
105
+
106
+ Registering with `hidden: true` keeps a command out of `--help`; **the generator must skip hidden commands too**.
107
+
108
+ Run against a registry with three commands, one of them nested, this produces:
109
+
110
+ ```
111
+ (root) -> version deploy db
112
+ version -> --format json plain
113
+ deploy -> --force -f staging production
114
+ db -> migrate
115
+ db migrate -> --step <file>
116
+ ```
117
+
118
+ Note what that output demonstrates: the `-f` alias, enum values on both an option and an argument, a nested group with no command of its own, and a file argument detected.
119
+
120
+ ## 4. What the generated scripts must do
121
+
122
+ ### 4.1 Both shells, natively
123
+
124
+ **bash** emits a `complete -F _mycli_completions mycli` function using `compgen -W` over the word list for the current command path, plus `compgen -f` where an argument takes a file.
125
+
126
+ **zsh** emits a real `#compdef` script using `_arguments` and `_describe`, carrying each option's `desc` as help text. It is not a bashcompinit shim. This is the largest single piece of work in the gem, roughly 120 lines, and it is the reason the gem exists rather than a patch to the existing one.
127
+
128
+ ### 4.2 Program names that are not identifiers
129
+
130
+ A host may be installed as `my-tool`. Shell function names cannot contain a dash, so derive the identifier with `Dry::Inflector#underscore` rather than a hand-rolled `gsub`. `Dry::CLI::Inflector` ships with dry-cli but only has `dasherize` and is marked `@api private`; do not use it.
131
+
132
+ ### 4.3 File arguments
133
+
134
+ An argument whose name suggests a path should complete filenames. Matching on the name (`/file|path/`) is a heuristic and a poor one. Prefer letting the host be explicit, and fall back to the heuristic only when nothing is declared. Whatever the mechanism, the generated script must contain real file completion, which is the gap in §1.2.
135
+
136
+ ## 5. Testing
137
+
138
+ **Never test only against one CLI.** A generator tested against a single registry bakes in that registry's shape. The suite must carry at least three fixture registries, and at least one must come from outside this project. Candidates: the examples in dry-cli's own repository, and Hanami's CLI.
139
+
140
+ Each fixture must exercise: a nested group with a command at its bare name (§1.1), a file argument (§1.2), an option with `values`, a boolean flag, an option with an alias, and a hidden command.
141
+
142
+ Validate generated output by running the shells, not by matching strings: `bash -n script` and `zsh -n script` both parse without executing. Golden-file the scripts so a change in output is visible in review.
143
+
144
+ Pin the laziness contract with a spec, because it erodes silently:
145
+
146
+ ```ruby
147
+ it "loads no emitter until the command runs" do
148
+ expect(defined?(Dry::CLI::Autocomplete::Generator)).to be_nil
149
+ end
150
+ ```
151
+
152
+ ## 6. Acceptance criteria
153
+
154
+ 1. A node with both a command and children completes its children *and* its own options.
155
+ 1. Commands with file arguments produce real file completion in the generated script.
156
+ 1. `require "dry/cli/autocomplete/command"` loads no generator and no emitter.
157
+ 1. zsh output is a native `#compdef` script with per-option descriptions, not a bashcompinit shim.
158
+ 1. `bash -n` and `zsh -n` accept the generated scripts.
159
+ 1. Hidden commands do not appear.
160
+ 1. Program names containing dashes produce valid shell identifiers.
161
+ 1. Generating completions touches nothing outside the registry.
162
+ 1. Runtime dependencies are `dry-cli` and `dry-inflector`, and nothing else.
163
+ 1. The suite passes against at least one registry not written for this project.
164
+
165
+ ## 7. Out of scope
166
+
167
+ - A `__complete` hidden command or any per-TAB process. See §2.1.
168
+ - Values that require the host to load data. See §2.2.
169
+ - Native extensions. See §2.3.
170
+ - fish, PowerShell, nushell. Worth adding later; the emitter interface should make a fourth shell a new class rather than a new branch, but do not build them now.
171
+
172
+ ## 8. Scope estimate
173
+
174
+ About 470 lines, most of it the zsh emitter and the specs.
175
+
176
+ | Part | Lines |
177
+ | ---------------------------------- | ----: |
178
+ | Registry walk and spec builder | 60 |
179
+ | bash emitter | 60 |
180
+ | zsh emitter | 120 |
181
+ | Command shim and installation help | 30 |
182
+ | Specs, including foreign fixtures | 200 |
183
+
184
+ Suggested order: walk, then bash, then zsh. The bash emitter proves the spec builder against a real shell quickly, and the zsh emitter is where the estimate is most likely to be wrong.
185
+
186
+ ## 9. Work units
187
+
188
+ ### 1. Registry walk and spec builder
189
+
190
+ This section did not exist when the folder entered Building; an implementer found nothing here to build against and split it before writing any code, per the instruction that governs exactly this case. Four units, non-overlapping in the files they own, matching §8's table. #1 has no dependency on the others; #2 and #3 depend only on #1's *interface* below, not its code, so they can be built concurrently with each other and with WU1. WU4 integrates all three and is the last to land.
191
+
192
+ Owns:
193
+
194
+ - `lib/dry/cli/autocomplete/spec_builder.rb`,
195
+ - `spec/dry/cli/autocomplete/spec_builder_spec.rb`,
196
+ - `spec/support/fixtures/**`.
197
+
198
+ Builds the fixture registries the whole suite depends on (§5: at least three, at least one from outside this project) and the walker (§3) that turns a registry into the `CompletionSpec` shape defined below. Owns file-argument *detection* (§4.3): the heuristic and any explicit declaration are resolved here, so emitters only ever read a plain `file?` flag and never re-derive it.
199
+
200
+ Done when: builds a correct spec for every fixture; hidden commands are absent from it; a node with both a command and children reports both (§1.1 acceptance criterion); nothing outside the registry is touched (no file reads, no host constants beyond what `values:` already declared).
201
+
202
+ ### 2. `BASH` emitter
203
+
204
+ Owns:
205
+
206
+ * `lib/dry/cli/autocomplete/emitters/bash.rb`,
207
+ * `spec/dry/cli/autocomplete/emitters/bash_spec.rb`.
208
+
209
+ Consumes a `CompletionSpec` (build one by hand in specs against the documented shape; do not import WU1's fixtures until WU1 has landed) and emits the `complete -F` script per §4.1: `compgen -W` over each node's word list, `compgen -f` where `file?` is set.
210
+
211
+ Done when: golden-file tests cover a fixture with a nested group, a file argument, an aliased option, and a hidden command absent from output; every golden file passes `bash -n`.
212
+
213
+ ### 3. `ZSH` emitter
214
+
215
+ Owns:
216
+
217
+ * `lib/dry/cli/autocomplete/emitters/zsh.rb`
218
+ * spec/dry/cli/autocomplete/emitters/zsh_spec.rb`.
219
+
220
+ Same `CompletionSpec` input as WU2. Emits a native `#compdef` script using `_arguments`/`_describe` (§4.1), carrying each option's `desc`. Not a bashcompinit shim.
221
+
222
+ Done when: golden-file tests as WU2, output validated with `zsh -n`, and per-option descriptions are visible in the generated `_describe` calls.
223
+
224
+ ### 4. Generator and command shim
225
+
226
+ Owns:
227
+
228
+ * `lib/dry/cli/autocomplete/generator.rb`,
229
+ * `lib/dry/cli/autocomplete/command.rb`,
230
+ * `spec/dry/cli/autocomplete/generator_spec.rb`,
231
+ * `spec/dry/cli/autocomplete/command_spec.rb`.
232
+
233
+ The generator is the small piece that ties a registry to an emitter: given a registry and a shell name, run WU1's spec builder, hand the result to WU2 or WU3's emitter, return the script. `command.rb` is the shim (§2.4): defines the command class, derives the program's shell-identifier with `Dry::Inflector#underscore` (§4.2, never a hand-rolled `gsub`), and `require`s `generator` only inside `#call`.
234
+
235
+ Done when: the laziness spec from §5 passes (`defined?(Dry::CLI::Autocomplete::Generator)` is `nil` after requiring only `dry/cli/autocomplete/command`); dashed program names produce valid identifiers; the command actually produces working output end-to-end through a real emitter (a stub is fine mid-flight, but the unit is not done while one remains in the diff).
236
+
237
+ ### Interface contract between the units
238
+
239
+ `SpecBuilder.call(registry, program_name:)` returns a `CompletionSpec`:
240
+
241
+ - `program_name` — String, the shell-identifier-safe name (already run through `Dry::Inflector#underscore` by whichever unit constructs it — WU4's command shim owns this call, so WU1's builder just accepts the string it's given).
242
+ - `nodes` — Array of `{path: Array<String>, options: [...], arguments: [...], children: Array<String>}`, one entry per node in the registry, hidden nodes excluded.
243
+ - Each option: `{name:, type:, values:, aliases:, default:, desc:, required:, boolean:, array:}`.
244
+ - Each argument: `{name:, values:, desc:, required:, file:}` — `file:` is the resolved boolean described under WU1 above.
245
+
246
+ `Emitter.call(spec)` (both `Emitters::Bash` and `Emitters::Zsh`) takes one `CompletionSpec` and returns one String: the complete generated script. Neither emitter takes a registry, and neither knows what dry-cli's own API looks like.
@@ -0,0 +1,21 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" width="99" height="20">
3
+ <linearGradient id="b" x2="0" y2="100%">
4
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
5
+ <stop offset="1" stop-opacity=".1"/>
6
+ </linearGradient>
7
+ <mask id="a">
8
+ <rect width="99" height="20" rx="3" fill="#fff"/>
9
+ </mask>
10
+ <g mask="url(#a)">
11
+ <path fill="#555" d="M0 0h63v20H0z"/>
12
+ <path fill="#4c1" d="M63 0h36v20H63z"/>
13
+ <path fill="url(#b)" d="M0 0h99v20H0z"/>
14
+ </g>
15
+ <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
16
+ <text x="31.5" y="15" fill="#010101" fill-opacity=".3">coverage</text>
17
+ <text x="31.5" y="14">coverage</text>
18
+ <text x="80" y="15" fill="#010101" fill-opacity=".3">98%</text>
19
+ <text x="80" y="14">98%</text>
20
+ </g>
21
+ </svg>
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "../lib/dry/cli/autocomplete"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ module Dry
6
+ class CLI
7
+ module Autocomplete
8
+ # The command a host registers to expose `mycli completion <shell>`.
9
+ #
10
+ # This file defines the command class and nothing else. It loads no
11
+ # spec builder and no emitter, and it must stay that way: a host pays
12
+ # for whatever this require pulls on *every* invocation, while the
13
+ # command itself runs about once per shell. The generator is required
14
+ # inside #call, where the cost is actually incurred. See
15
+ # SPECIFICATION.md §1.3 and §2.4, and the spec that pins it.
16
+ class Command < Dry::CLI::Command
17
+ SHELLS = %w[bash zsh].freeze
18
+
19
+ # Binds the command to a registry, and optionally to the name the
20
+ # host is installed as. Without one, the program name is taken from
21
+ # $PROGRAM_NAME at call time rather than at registration, since a
22
+ # gem may be required long before anyone knows how it was invoked.
23
+ #
24
+ # register "completion", Dry::CLI::Autocomplete::Command[MyCLI]
25
+ def self.[](registry, program_name: nil)
26
+ Class.new(self) do
27
+ @registry = registry
28
+ @program_name = program_name
29
+ end
30
+ end
31
+
32
+ class << self
33
+ attr_reader :registry, :program_name
34
+ end
35
+
36
+ desc "Print a shell completion script"
37
+
38
+ argument :shell, required: true, values: SHELLS,
39
+ desc: "Shell to generate completions for"
40
+
41
+ example [
42
+ "bash > /usr/local/etc/bash_completion.d/#{File.basename($PROGRAM_NAME)}",
43
+ "zsh > \"${fpath[1]}/_#{File.basename($PROGRAM_NAME)}\""
44
+ ]
45
+
46
+ def call(shell:, **)
47
+ require_relative "spec_builder"
48
+ require_relative "emitters/#{shell}"
49
+
50
+ spec = SpecBuilder.call(registry, program_name: program_name)
51
+ out.puts emitter_for(shell).call(spec)
52
+ end
53
+
54
+ private
55
+
56
+ def registry
57
+ self.class.registry or
58
+ raise ArgumentError, "no registry bound: register Dry::CLI::Autocomplete::Command[MyCLI]"
59
+ end
60
+
61
+ def program_name
62
+ self.class.program_name || File.basename($PROGRAM_NAME)
63
+ end
64
+
65
+ def emitter_for(shell)
66
+ Emitters.const_get(shell.capitalize)
67
+ end
68
+
69
+ # Overridable so specs can capture output without reaching for $stdout.
70
+ def out = $stdout
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/inflector"
4
+
5
+ module Dry
6
+ class CLI
7
+ module Autocomplete
8
+ module Emitters
9
+ # Turns a CompletionSpec into a bash `complete -F` script.
10
+ # See SPECIFICATION.md §4.1: a static, case-statement walk over
11
+ # COMP_WORDS resolves which node the cursor is under, then
12
+ # `compgen -W` fills COMPREPLY from that node's children and
13
+ # option flags, with `compgen -f` added where an argument is a
14
+ # file (§4.3).
15
+ #
16
+ # Deliberately avoids bash associative arrays (bash 4+ only):
17
+ # macOS still ships bash 3.2 as /bin/bash, and this script is
18
+ # meant to be eval'd from exactly that.
19
+ class Bash
20
+ def self.call(spec)
21
+ new(spec).call
22
+ end
23
+
24
+ def initialize(spec)
25
+ @spec = spec
26
+ end
27
+
28
+ def call
29
+ "#{body.join("\n")}\n"
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :spec
35
+
36
+ def body
37
+ header_lines + path_walk_lines + word_lookup_lines + footer_lines
38
+ end
39
+
40
+ def header_lines
41
+ [
42
+ "#{function_name}() {",
43
+ " local cur path word next_path words i",
44
+ " COMPREPLY=()",
45
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
46
+ ' path=""',
47
+ " i=1"
48
+ ]
49
+ end
50
+
51
+ def path_walk_lines
52
+ path_walk_open_lines + path_walk_close_lines
53
+ end
54
+
55
+ def path_walk_open_lines
56
+ lines = [
57
+ ' while [ "$i" -lt "$COMP_CWORD" ]; do',
58
+ ' word="${COMP_WORDS[$i]}"',
59
+ ' next_path=""',
60
+ ' case "$path:$word" in'
61
+ ]
62
+ edge_arms.each { |arm| lines << " #{arm}" }
63
+ lines << " esac"
64
+ end
65
+
66
+ def path_walk_close_lines
67
+ [
68
+ ' if [ -z "$next_path" ]; then',
69
+ " break",
70
+ " fi",
71
+ ' path="$next_path"',
72
+ " i=$((i + 1))",
73
+ " done",
74
+ ""
75
+ ]
76
+ end
77
+
78
+ def word_lookup_lines
79
+ lines = [' words=""', ' case "$path" in']
80
+ word_arms.each { |arm| lines << " #{arm}" }
81
+ lines + [" esac", ""]
82
+ end
83
+
84
+ def footer_lines
85
+ [
86
+ ' COMPREPLY=($(compgen -W "$words" -- "$cur"))',
87
+ *file_completion_lines,
88
+ "}",
89
+ "complete -F #{function_name} #{spec.program_name}"
90
+ ]
91
+ end
92
+
93
+ def function_name = "_#{shell_identifier}_completions"
94
+
95
+ # A program installed as `my-tool` cannot name a shell function
96
+ # directly. See SPECIFICATION.md §4.2.
97
+ def shell_identifier
98
+ Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
99
+ end
100
+
101
+ def path_key(path) = path.join(" ")
102
+
103
+ def edge_arms
104
+ spec.nodes.flat_map do |node|
105
+ parent_key = path_key(node.path)
106
+ node.children.map do |child|
107
+ child_key = path_key(node.path + [child])
108
+ "\"#{quote(parent_key)}:#{quote(child)}\") next_path=\"#{quote(child_key)}\" ;;"
109
+ end
110
+ end
111
+ end
112
+
113
+ def word_arms
114
+ spec.nodes.filter_map do |node|
115
+ words = node_words(node)
116
+ next if words.empty?
117
+
118
+ "\"#{quote(path_key(node.path))}\") words=\"#{quote(words.join(" "))}\" ;;"
119
+ end
120
+ end
121
+
122
+ def node_words(node)
123
+ node.children + node.options.flat_map { |option| option_words(option) }
124
+ end
125
+
126
+ def option_words(option) = ["--#{option.name}"] + Array(option.aliases)
127
+
128
+ def file_completion_lines
129
+ paths = spec.nodes.select { |node| node.arguments.any?(&:file) }.map { |node| path_key(node.path) }
130
+ return [] if paths.empty?
131
+
132
+ lines = [' case "$path" in']
133
+ paths.each do |key|
134
+ lines << " \"#{quote(key)}\") COMPREPLY+=($(compgen -f -- \"$cur\")) ;;"
135
+ end
136
+ lines << " esac"
137
+ lines
138
+ end
139
+
140
+ def quote(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/inflector"
4
+
5
+ module Dry
6
+ class CLI
7
+ module Autocomplete
8
+ module Emitters
9
+ # Turns a CompletionSpec into a native zsh `#compdef` script.
10
+ #
11
+ # This is not a bashcompinit shim. Options go through `_arguments`
12
+ # carrying their `desc` as help text, subcommands go through
13
+ # `_describe`, declared enum values become a `(a b c)` action, and a
14
+ # file argument becomes `_files`. That per-option help is the thing
15
+ # zsh users lose with a shim, and the reason this emitter exists.
16
+ # See SPECIFICATION.md §4.1.
17
+ #
18
+ # Shares no code with the bash emitter by design: a fourth shell
19
+ # should be a new class here, never another branch inside one of
20
+ # these two.
21
+ class Zsh
22
+ def self.call(spec)
23
+ new(spec).call
24
+ end
25
+
26
+ def initialize(spec)
27
+ @spec = spec
28
+ end
29
+
30
+ def call
31
+ "#{body.join("\n")}\n"
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :spec
37
+
38
+ def body
39
+ header_lines + path_walk_lines + dispatch_lines + footer_lines
40
+ end
41
+
42
+ def header_lines
43
+ [
44
+ "#compdef #{spec.program_name}",
45
+ "",
46
+ "# Generated by dry-cli-autocomplete. Regenerate after changing commands.",
47
+ "",
48
+ "#{function_name}() {",
49
+ ' local curcontext="$curcontext" state line ret=1',
50
+ " local -a commands",
51
+ " typeset -A opt_args",
52
+ ""
53
+ ]
54
+ end
55
+
56
+ # Resolves which node the cursor sits under by consuming words the
57
+ # registry knows, exactly as the bash emitter does. words[1] is the
58
+ # program itself under zsh, so the scan starts at 2.
59
+ def path_walk_lines
60
+ lines = [
61
+ ' local path_key="" i=2',
62
+ " while (( i < CURRENT )); do",
63
+ ' case "$path_key:${words[i]}" in'
64
+ ]
65
+ edge_arms.each { |arm| lines << " #{arm}" }
66
+ lines + [
67
+ " (*) break ;;",
68
+ " esac",
69
+ " (( i += 1 ))",
70
+ " done",
71
+ ""
72
+ ]
73
+ end
74
+
75
+ def dispatch_lines
76
+ lines = [' case "$path_key" in']
77
+ spec.nodes.each { |node| lines.concat(node_arm_lines(node)) }
78
+ lines + [" esac", ""]
79
+ end
80
+
81
+ def footer_lines
82
+ [
83
+ " return ret",
84
+ "}",
85
+ "",
86
+ "#{function_name} \"$@\""
87
+ ]
88
+ end
89
+
90
+ def node_arm_lines(node)
91
+ inner = arguments_call_lines(node) + describe_call_lines(node)
92
+ return [] if inner.empty?
93
+
94
+ [" (#{single_quote(path_key(node.path))})", *inner.map { |line| " #{line}" }, " ;;"]
95
+ end
96
+
97
+ # One `_arguments` call carrying every option and positional this
98
+ # node declares. `-s` allows clustered single-letter flags.
99
+ def arguments_call_lines(node)
100
+ specs = node.options.flat_map { |option| option_specs(option) } +
101
+ node.arguments.map { |argument| argument_spec(argument) }
102
+ return [] if specs.empty?
103
+
104
+ ["_arguments -s \\", *specs[0..-2].map { |s| " #{s} \\" }, " #{specs.last} && ret=0"]
105
+ end
106
+
107
+ def describe_call_lines(node)
108
+ return [] if node.children.empty?
109
+
110
+ entries = node.children.map do |child|
111
+ single_quote("#{escape_describe(child)}:#{escape_describe(child_desc(node, child))}")
112
+ end
113
+
114
+ ["commands=(", *entries.map { |e| " #{e}" }, ")",
115
+ "_describe -t commands #{single_quote(describe_tag(node))} commands && ret=0"]
116
+ end
117
+
118
+ # Each alias gets its own spec rather than a {-f,--force} group:
119
+ # the grouped form needs its description outside the quotes, and
120
+ # one entry per flag is easier to read in the generated file.
121
+ def option_specs(option)
122
+ names = ["--#{option.name}"] + Array(option.aliases)
123
+ names.map { |name| single_quote("#{name}#{bracketed(option.desc)}#{option_action(option)}") }
124
+ end
125
+
126
+ # A boolean flag takes no value. Anything else gets one field
127
+ # naming what it wants and one supplying the completions for it.
128
+ def option_action(option)
129
+ return "" if option.boolean
130
+
131
+ ":#{escape_spec(option.name)}:#{value_action(option.values)}"
132
+ end
133
+
134
+ # A positional takes `:message:action`, with no bracketed
135
+ # description: zsh shows the message itself while completing, so
136
+ # the command's own wording goes there when it has any.
137
+ def argument_spec(argument)
138
+ action = argument.file ? "_files" : value_action(argument.values)
139
+ message = argument.desc.to_s.empty? ? argument.name : argument.desc
140
+ single_quote("*:#{escape_describe(message)}:#{action}")
141
+ end
142
+
143
+ def value_action(values)
144
+ return "" if values.nil? || values.empty?
145
+
146
+ "(#{values.map { |value| escape_spec(value) }.join(" ")})"
147
+ end
148
+
149
+ def bracketed(desc)
150
+ return "" if desc.nil? || desc.empty?
151
+
152
+ "[#{escape_bracket(desc)}]"
153
+ end
154
+
155
+ def child_desc(node, child)
156
+ found = spec.nodes.find { |candidate| candidate.path == node.path + [child] }
157
+ found&.desc.to_s
158
+ end
159
+
160
+ def describe_tag(node)
161
+ node.path.empty? ? "command" : "#{node.path.join(" ")} command"
162
+ end
163
+
164
+ def path_key(path) = path.join(" ")
165
+
166
+ # zsh reads `]` as the end of a description and `:` as the start of
167
+ # the next spec field, so both have to be escaped inside one.
168
+ def escape_bracket(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub("]", "\\\\]").gsub(":", "\\\\:")
169
+
170
+ def escape_spec(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub(":", "\\\\:").gsub(/([()\[\] ])/, '\\\\\1')
171
+
172
+ def escape_describe(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub(":", "\\\\:")
173
+
174
+ def single_quote(str) = "'#{str.to_s.gsub("'", "'\\\\''")}'"
175
+
176
+ def function_name = "_#{shell_identifier}_completions"
177
+
178
+ # A program installed as `my-tool` cannot name a shell function
179
+ # directly. See SPECIFICATION.md §4.2.
180
+ def shell_identifier
181
+ Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
182
+ end
183
+
184
+ def edge_arms
185
+ spec.nodes.flat_map do |node|
186
+ node.children.map do |child|
187
+ "(#{single_quote("#{path_key(node.path)}:#{child}")}) " \
188
+ "path_key=#{single_quote(path_key(node.path + [child]))} ;;"
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module Autocomplete
6
+ # Walks a Dry::CLI registry through its public API and returns a
7
+ # shell-agnostic description of every completion: one CompletionSpec
8
+ # carrying one Node per reachable, non-hidden command or group.
9
+ #
10
+ # Touches nothing beyond the registry and the command classes it
11
+ # already holds, so it stays cheap enough to run on every shell start.
12
+ # See SPECIFICATION.md §2.2 and §2.3.
13
+ class SpecBuilder
14
+ CompletionSpec = Struct.new(:program_name, :nodes, keyword_init: true)
15
+ Node = Struct.new(:path, :desc, :options, :arguments, :children, keyword_init: true)
16
+
17
+ # :values shadows Struct#values by design: it is the field name the
18
+ # interface contract in .plans/001.00-*/plan.md fixes for emitters.
19
+ OptionSpec = Struct.new(
20
+ :name, :type, :values, :aliases, :default, :desc, :required, :boolean, :array,
21
+ keyword_init: true
22
+ )
23
+ ArgumentSpec = Struct.new(:name, :values, :desc, :required, :file, keyword_init: true)
24
+ # rubocop:enable Lint/StructNewOverride
25
+
26
+ # A bare heuristic, used only when a host does not declare `file:`
27
+ # explicitly on the argument. See SPECIFICATION.md §4.3.
28
+ FILE_ARGUMENT_HEURISTIC = /file|path/i
29
+
30
+ def self.call(registry, program_name:)
31
+ new(registry, program_name).call
32
+ end
33
+
34
+ def initialize(registry, program_name)
35
+ @registry = registry
36
+ @program_name = program_name
37
+ end
38
+
39
+ def call
40
+ CompletionSpec.new(program_name: program_name, nodes: walk([]))
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :registry, :program_name
46
+
47
+ def walk(path, nodes = [])
48
+ result = registry.get(path)
49
+ visible = visible_children(result)
50
+
51
+ nodes << build_node(path, result, visible)
52
+ visible.each_key { |name| walk(path + [name], nodes) }
53
+ nodes
54
+ end
55
+
56
+ def visible_children(result)
57
+ result.children.reject { |_name, node| node.hidden }
58
+ end
59
+
60
+ def build_node(path, result, visible)
61
+ command = result.command
62
+
63
+ Node.new(
64
+ path: path,
65
+ desc: command&.description,
66
+ options: command ? command.options.map { |option| build_option(option) } : [],
67
+ arguments: command ? command.arguments.map { |argument| build_argument(argument) } : [],
68
+ children: visible.keys
69
+ )
70
+ end
71
+
72
+ def build_option(option)
73
+ OptionSpec.new(
74
+ name: option.name.to_s, type: option.type, values: option.values,
75
+ aliases: option.aliases, default: option.default, desc: option.options[:desc],
76
+ required: option.required? || false, boolean: option.boolean?, array: option.array?
77
+ )
78
+ end
79
+
80
+ def build_argument(argument)
81
+ ArgumentSpec.new(
82
+ name: argument.name.to_s, values: argument.values, desc: argument.options[:desc],
83
+ required: argument.required? || false, file: file_argument?(argument)
84
+ )
85
+ end
86
+
87
+ def file_argument?(argument)
88
+ explicit = argument.options[:file]
89
+ return !!explicit unless explicit.nil?
90
+
91
+ argument.name.to_s.match?(FILE_ARGUMENT_HEURISTIC)
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ # Reopened rather than defined: dry-cli declares `class CLI`, not a module,
5
+ # and getting that wrong raises TypeError the moment both are loaded.
6
+ #
7
+ # Declared here without requiring dry-cli, because the gemspec loads this
8
+ # file at build time when the dependency may not be installed. dry-cli's CLI
9
+ # inherits from Object, so an empty reopening is compatible either way.
10
+ class CLI
11
+ module Autocomplete
12
+ VERSION = "0.1.0"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+ require_relative "autocomplete/version"
5
+
6
+ module Dry
7
+ class CLI
8
+ # Generates static shell completion scripts from a Dry::CLI registry.
9
+ #
10
+ # The generator reads the registry and never forces anything beyond it:
11
+ # commands, subcommands, options, their declared enum values, and which
12
+ # arguments look like file paths. Nothing it produces requires loading a
13
+ # host's data, because the script is regenerated on every shell start via
14
+ # `eval "$(mycli completion bash)"` and a slow generator is a slow shell.
15
+ module Autocomplete
16
+ class Error < StandardError; end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "./dry/cli/autocomplete"
@@ -0,0 +1,8 @@
1
+ module Dry
2
+ class CLI
3
+ module Autocomplete
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: dry-cli-autocomplete
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Konstantin Gredeskoul
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-cli
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: dry-inflector
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ description: Supports auto-completion for dry-cli powered Ruby CLI tools, including
41
+ sub-commands, in BASH and ZSH.
42
+ email:
43
+ - kigster@gmail.com
44
+ executables:
45
+ - dry-cli-autocomplete
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - ".rubocop.local.yml"
50
+ - CHANGELOG.md
51
+ - CLAUDE.md
52
+ - LICENSE.txt
53
+ - README.md
54
+ - Rakefile
55
+ - SPECIFICATION.md
56
+ - docs/img/badge.svg
57
+ - exe/dry-cli-autocomplete
58
+ - lib/dry-cli-autocomplete.rb
59
+ - lib/dry/cli/autocomplete.rb
60
+ - lib/dry/cli/autocomplete/command.rb
61
+ - lib/dry/cli/autocomplete/emitters/bash.rb
62
+ - lib/dry/cli/autocomplete/emitters/zsh.rb
63
+ - lib/dry/cli/autocomplete/spec_builder.rb
64
+ - lib/dry/cli/autocomplete/version.rb
65
+ - sig/dry/cli/autocomplete.rbs
66
+ homepage: https://github.com/kigster/dry-cli-autocomplete
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ allowed_push_host: https://rubygems.org
71
+ homepage_uri: https://github.com/kigster/dry-cli-autocomplete
72
+ source_code_uri: https://github.com/kigster/dry-cli-autocomplete
73
+ changelog_uri: https://github.com/kigster/dry-cli-autocomplete/blob/main/CHANGELOG.md
74
+ rubygems_mfa_required: 'true'
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.18
90
+ specification_version: 4
91
+ summary: A missing auto-complete addition for dry-cli powered Ruby CLI tools for BASH
92
+ & ZSH
93
+ test_files: []