dry-cli-autocomplete 0.1.3

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.
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) however 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>
data/justfile ADDED
@@ -0,0 +1,110 @@
1
+ # Tell 'just' to run bash, source our setup script, then execute the recipe
2
+ set shell := ["bash", "-c"]
3
+
4
+ version := `grep VERSION lib/dry/cli/autocomplete/version.rb | awk '{print $3}' | tr -d '"' | tr -d '\n'`
5
+ rbenv := 'eval "$(rbenv init - bash 2>/dev/null || true)"; bundle exec '
6
+ repo := 'git@github.com:kigster/dry-cli-autocomplete.git'
7
+
8
+ gem_name := 'dry-cli-autocomplete'
9
+ gem_file := 'pkg/' + gem_name + '-' + version + '.gem'
10
+ gem_url := 'https://rubygems.org/gems/' + gem_name
11
+
12
+ [no-exit-message]
13
+ recipes:
14
+ just --choose
15
+
16
+ # Sync all dependencies
17
+ install:
18
+ bin/setup
19
+
20
+ build: install
21
+
22
+ # Lint and reformat files
23
+ lint:
24
+ {{ rbenv }} rubocop
25
+
26
+ # Lint and reformat files (-a) — pass -A as an argument
27
+ format *args:
28
+ {{ rbenv }} rubocop -a {{ args }}
29
+ /usr/bin/find . -name '*.md' -exec mdformat --wrap no {} \; -print
30
+
31
+ # Run all the tests
32
+ test *args:
33
+ export ENVIRONMENT=test; {{ rbenv }} rspec {{args}}
34
+
35
+ # Run tests with coverage
36
+ test-coverage *args:
37
+ export ENVIRONMENT=test; export COVERAGE=true; {{ rbenv }} rspec {{ args }}
38
+
39
+ ci: lint test-coverage
40
+
41
+ alias check-all := ci
42
+
43
+ clean:
44
+ #!/usr/bin/env bash
45
+ @find . -name .DS_Store -delete -print || true
46
+ @rm -rf tmp/*
47
+
48
+ # Run all lefthook pre-commit hooks
49
+ lefthook:
50
+ {{ rbenv }} lefthook run pre-commit --all-files
51
+
52
+ # Print current gem version
53
+ version:
54
+ @echo "{{ version }}"
55
+
56
+ # Clobber
57
+ clobber:
58
+ {{ rbenv }} rake clobber
59
+
60
+ # Generate documentation
61
+ doc:
62
+ #!/usr/bin/env bash
63
+ {{ rbenv }} rake doc
64
+
65
+ # `gem push` rather than `rake release`: release also guards the tree, tags and
66
+ # pushes git — which `just release` does deliberately and separately — and it
67
+ # gives no way to pass a 2FA code, so it always stopped to prompt.
68
+ #
69
+ # The code comes from 1Password unless one is passed in:
70
+ #
71
+ # just publish # read the code from 1Password
72
+ # just publish 123456 # use this code
73
+ #
74
+ # `just publish-all` in inquirex-tools passes one, because a TOTP is single-use:
75
+ # four gems reading the same 30-second window would have the second push
76
+ # rejected as a replay.
77
+ #
78
+ # Build the .gem and push it to RubyGems, non-interactively
79
+ publish otp="": build
80
+ #!/usr/bin/env bash
81
+ set -euo pipefail
82
+ eval "$(rbenv init - bash 2>/dev/null || true)"
83
+
84
+ mkdir -p pkg
85
+ gem build {{ gem_name }}.gemspec --output "{{ gem_file }}"
86
+
87
+ # `|| true` is load-bearing: under `set -e` a failed `op read` — not signed
88
+ # in to 1Password, item renamed, op not installed — would abort the recipe
89
+ # before the prompting fallback below could run.
90
+ otp="{{ otp }}"
91
+
92
+ if [[ -n "${otp}" ]]; then
93
+ gem push "{{ gem_file }}" --otp "${otp}"
94
+ else
95
+ echo "rubygems: no OTP available — gem push will prompt if 2FA is required."
96
+ gem push "{{ gem_file }}"
97
+ fi
98
+
99
+ # Only reachable when the push succeeded: `set -e` aborts the recipe on a
100
+ # non-zero `gem push`, so the page never opens for a release that failed.
101
+ echo "published {{ gem_name }} {{ version }} → {{ gem_url }}"
102
+ open "{{ gem_url }}" 2>/dev/null || xdg-open "{{ gem_url }}" 2>/dev/null || true
103
+
104
+ # Tag v{{ version }}, publish the GH release, & refresh the Homebrew tap.
105
+ release:
106
+ git fetch --tags
107
+ git tag -f "v{{ version }}"
108
+ git push -f --tags
109
+ gh release delete -y "v{{ version }}" --repo {{ repo }} 2>/dev/null || true
110
+ gh release create "v{{ version }}" --generate-notes --repo {{ repo }}
data/lefthook.yml ADDED
@@ -0,0 +1,34 @@
1
+ output:
2
+ - summary
3
+ - failure
4
+
5
+ pre-commit:
6
+ parallel: true
7
+ jobs:
8
+ - name: lint
9
+ run: bundle exec rubocop --force-exclusion --parallel {staged_files}
10
+ stage_fixed: true
11
+
12
+ - name: check for conflict markers and whitespace issues
13
+ run: git --no-pager diff --check
14
+
15
+ # If tests take >1 second, move this (or just the long-running tests) to pre-push.
16
+ - name: run tests
17
+ run: just test
18
+
19
+ - name: fix rubocop formatting issues
20
+ run: bundle exec rubocop -a {staged_files}
21
+ glob: "*.{rb,Gemfile,gemspec}"
22
+ stage_fixed: true
23
+
24
+ - name: spell check
25
+ run: codespell {staged_files}
26
+ glob: "*.{rb,md,gemspec}"
27
+
28
+ - name: format markdown
29
+ run: mdformat {staged_files}
30
+ glob: "*.md"
31
+ stage_fixed: true
32
+
33
+ - name: scan for secrets
34
+ run: detect-secrets-hook --baseline .secrets.baseline
@@ -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,177 @@
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 + option_value_lines + word_lookup_lines + footer_lines
38
+ end
39
+
40
+ def header_lines
41
+ [
42
+ "#{function_name}() {",
43
+ " local cur prev path word next_path words i",
44
+ " COMPREPLY=()",
45
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
46
+ ' prev=""',
47
+ ' if [ "$COMP_CWORD" -gt 0 ]; then',
48
+ ' prev="${COMP_WORDS[$((COMP_CWORD - 1))]}"',
49
+ " fi",
50
+ ' path=""',
51
+ " i=1"
52
+ ]
53
+ end
54
+
55
+ def path_walk_lines
56
+ path_walk_open_lines + path_walk_close_lines
57
+ end
58
+
59
+ def path_walk_open_lines
60
+ lines = [
61
+ ' while [ "$i" -lt "$COMP_CWORD" ]; do',
62
+ ' word="${COMP_WORDS[$i]}"',
63
+ ' next_path=""',
64
+ ' case "$path:$word" in'
65
+ ]
66
+ edge_arms.each { |arm| lines << " #{arm}" }
67
+ lines << " esac"
68
+ end
69
+
70
+ def path_walk_close_lines
71
+ [
72
+ ' if [ -z "$next_path" ]; then',
73
+ " break",
74
+ " fi",
75
+ ' path="$next_path"',
76
+ " i=$((i + 1))",
77
+ " done",
78
+ ""
79
+ ]
80
+ end
81
+
82
+ def word_lookup_lines
83
+ lines = [' words=""', ' case "$path" in']
84
+ word_arms.each { |arm| lines << " #{arm}" }
85
+ lines + [" esac", ""]
86
+ end
87
+
88
+ def footer_lines
89
+ [
90
+ ' COMPREPLY=($(compgen -W "$words" -- "$cur"))',
91
+ *file_completion_lines,
92
+ "}",
93
+ "complete -F #{function_name} #{spec.program_name}"
94
+ ]
95
+ end
96
+
97
+ def function_name = "_#{shell_identifier}_completions"
98
+
99
+ # A program installed as `my-tool` cannot name a shell function
100
+ # directly. See SPECIFICATION.md §4.2.
101
+ def shell_identifier
102
+ Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
103
+ end
104
+
105
+ def path_key(path) = path.join(" ")
106
+
107
+ def edge_arms
108
+ spec.nodes.flat_map do |node|
109
+ parent_key = path_key(node.path)
110
+ node.children.map do |child|
111
+ child_key = path_key(node.path + [child])
112
+ "\"#{quote(parent_key)}:#{quote(child)}\") next_path=\"#{quote(child_key)}\" ;;"
113
+ end
114
+ end
115
+ end
116
+
117
+ def word_arms
118
+ spec.nodes.filter_map do |node|
119
+ words = node_words(node)
120
+ next if words.empty?
121
+
122
+ "\"#{quote(path_key(node.path))}\") words=\"#{quote(words.join(' '))}\" ;;"
123
+ end
124
+ end
125
+
126
+ # Children, flags, and any values a positional declares: all three are
127
+ # legitimate next words at this point in the line.
128
+ def node_words(node)
129
+ node.children +
130
+ node.options.flat_map { |option| option_words(option) } +
131
+ node.arguments.flat_map { |argument| Array(argument.values) }
132
+ end
133
+
134
+ def option_words(option) = ["--#{option.name}"] + Array(option.aliases)
135
+
136
+ # An option that declares values gets its own arm, keyed on the word
137
+ # before the cursor. Typing `--format ` then TAB should offer what
138
+ # --format accepts, not the command list again, so this arm answers
139
+ # and returns rather than falling through. Long name and every alias
140
+ # are listed together, since `-f` accepts what `--format` accepts.
141
+ def option_value_lines
142
+ arms = spec.nodes.flat_map do |node|
143
+ node.options.filter_map do |option|
144
+ values = Array(option.values)
145
+ next if values.empty?
146
+
147
+ key = path_key(node.path)
148
+ option_words(option).map do |name|
149
+ " \"#{quote(key)}:#{quote(name)}\") " \
150
+ "COMPREPLY=($(compgen -W \"#{quote(values.join(' '))}\" -- \"$cur\")); return ;;"
151
+ end
152
+ end
153
+ end.flatten
154
+
155
+ return [] if arms.empty?
156
+
157
+ [' case "$path:$prev" in', *arms, " esac", ""]
158
+ end
159
+
160
+ def file_completion_lines
161
+ paths = spec.nodes.select { |node| node.arguments.any?(&:file) }.map { |node| path_key(node.path) }
162
+ return [] if paths.empty?
163
+
164
+ lines = [' case "$path" in']
165
+ paths.each do |key|
166
+ lines << " \"#{quote(key)}\") COMPREPLY+=($(compgen -f -- \"$cur\")) ;;"
167
+ end
168
+ lines << " esac"
169
+ lines
170
+ end
171
+
172
+ def quote(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end