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.
@@ -0,0 +1,213 @@
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
+ # A #compdef file dropped on $fpath is autoloaded by the completion
82
+ # system with its own name on the function stack, and is expected to
83
+ # do the completion. The same text sourced from a profile, which is
84
+ # what `eval "$(mycli completion zsh)"` does, runs with no completion
85
+ # in progress: calling the function there reaches for _describe
86
+ # before the completion system has defined it, and zsh reports a
87
+ # command-not-found from inside a file the user never wrote.
88
+ #
89
+ # Naming the function after the program, which is the convention a
90
+ # compdef file follows anyway, lets one line tell the two apart.
91
+ def footer_lines
92
+ [
93
+ " return ret",
94
+ "}",
95
+ "",
96
+ "if [ \"$funcstack[1]\" = \"#{function_name}\" ]; then",
97
+ " #{function_name} \"$@\"",
98
+ "else",
99
+ " compdef #{function_name} #{spec.program_name}",
100
+ "fi"
101
+ ]
102
+ end
103
+
104
+ def node_arm_lines(node)
105
+ inner = arguments_call_lines(node) + describe_call_lines(node)
106
+ return [] if inner.empty?
107
+
108
+ [" (#{single_quote(path_key(node.path))})", *inner.map { |line| " #{line}" }, " ;;"]
109
+ end
110
+
111
+ # One `_arguments` call carrying every option and positional this
112
+ # node declares. `-s` allows clustered single-letter flags.
113
+ def arguments_call_lines(node)
114
+ specs = node.options.flat_map { |option| option_specs(option) } +
115
+ node.arguments.map { |argument| argument_spec(argument) }
116
+ return [] if specs.empty?
117
+
118
+ ["_arguments -s \\", *specs[0..-2].map { |s| " #{s} \\" }, " #{specs.last} && ret=0"]
119
+ end
120
+
121
+ def describe_call_lines(node)
122
+ return [] if node.children.empty?
123
+
124
+ entries = node.children.map do |child|
125
+ single_quote("#{escape_describe(child)}:#{escape_describe(child_desc(node, child))}")
126
+ end
127
+
128
+ ["commands=(", *entries.map { |e| " #{e}" }, ")",
129
+ "_describe -t commands #{single_quote(describe_tag(node))} commands && ret=0"]
130
+ end
131
+
132
+ # Each alias gets its own spec rather than a {-f,--force} group:
133
+ # the grouped form needs its description outside the quotes, and
134
+ # one entry per flag is easier to read in the generated file.
135
+ def option_specs(option)
136
+ names = ["--#{option.name}"] + Array(option.aliases)
137
+ names.map { |name| single_quote("#{name}#{bracketed(option.desc)}#{option_action(option)}") }
138
+ end
139
+
140
+ # A boolean flag takes no value. Anything else gets one field
141
+ # naming what it wants and one supplying the completions for it.
142
+ def option_action(option)
143
+ return "" if option.boolean
144
+
145
+ ":#{escape_spec(option.name)}:#{value_action(option.values)}"
146
+ end
147
+
148
+ # A positional takes `:message:action`, with no bracketed
149
+ # description: zsh shows the message itself while completing, so
150
+ # the command's own wording goes there when it has any.
151
+ def argument_spec(argument)
152
+ action = argument.file ? "_files" : value_action(argument.values)
153
+ message = argument.desc.to_s.empty? ? argument.name : argument.desc
154
+ single_quote("*:#{escape_describe(message)}:#{action}")
155
+ end
156
+
157
+ def value_action(values)
158
+ return "" if values.nil? || values.empty?
159
+
160
+ "(#{values.map { |value| escape_spec(value) }.join(' ')})"
161
+ end
162
+
163
+ def bracketed(desc)
164
+ return "" if desc.nil? || desc.empty?
165
+
166
+ "[#{escape_bracket(desc)}]"
167
+ end
168
+
169
+ def child_desc(node, child)
170
+ found = spec.nodes.find { |candidate| candidate.path == node.path + [child] }
171
+ found&.desc.to_s
172
+ end
173
+
174
+ def describe_tag(node)
175
+ node.path.empty? ? "command" : "#{node.path.join(' ')} command"
176
+ end
177
+
178
+ def path_key(path) = path.join(" ")
179
+
180
+ # zsh reads `]` as the end of a description and `:` as the start of
181
+ # the next spec field, so both have to be escaped inside one.
182
+ def escape_bracket(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub("]", "\\\\]").gsub(":", "\\\\:")
183
+
184
+ def escape_spec(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub(":", "\\\\:").gsub(/([()\[\] ])/, '\\\\\1')
185
+
186
+ def escape_describe(str) = str.to_s.gsub("\\", "\\\\\\\\").gsub(":", "\\\\:")
187
+
188
+ def single_quote(str) = "'#{str.to_s.gsub("'", "'\\\\''")}'"
189
+
190
+ # `_mycli`, not `_mycli_completions`: zsh autoloads a compdef file by
191
+ # the name of the function it defines, so matching that convention is
192
+ # what makes the footer's fpath check work.
193
+ def function_name = "_#{shell_identifier}"
194
+
195
+ # A program installed as `my-tool` cannot name a shell function
196
+ # directly. See SPECIFICATION.md §4.2.
197
+ def shell_identifier
198
+ Dry::Inflector.new.underscore(spec.program_name.to_s).gsub(/[^A-Za-z0-9_]/, "_")
199
+ end
200
+
201
+ def edge_arms
202
+ spec.nodes.flat_map do |node|
203
+ node.children.map do |child|
204
+ "(#{single_quote("#{path_key(node.path)}:#{child}")}) " \
205
+ "path_key=#{single_quote(path_key(node.path + [child]))} ;;"
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,103 @@
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
+ # `::Data`, with the leading colons, and never a bare `Data`. This file
15
+ # is lexically inside `module Dry`, so an unqualified constant is looked
16
+ # up there first: a bare `Struct` here meant `Dry::Struct` in any host
17
+ # that had dry-struct loaded, which took different arguments and broke
18
+ # on the host's machine while this gem's own suite stayed green. No
19
+ # `Dry::Data` exists today, but the same trap is one released gem away,
20
+ # and dry_struct_host_spec.rb is what keeps watch.
21
+ #
22
+ # Data rather than Struct because these are descriptions, built once and
23
+ # rendered: frozen is what they should be, a missing field raises rather
24
+ # than arriving as a silent nil, and `:values` stops colliding with a
25
+ # method Struct defines and Data does not.
26
+ CompletionSpec = ::Data.define(:program_name, :nodes)
27
+ Node = ::Data.define(:path, :desc, :options, :arguments, :children)
28
+ OptionSpec = ::Data.define(
29
+ :name, :type, :values, :aliases, :default, :desc, :required, :boolean, :array
30
+ )
31
+ ArgumentSpec = ::Data.define(:name, :values, :desc, :required, :file)
32
+
33
+ # A bare heuristic, used only when a host does not declare `file:`
34
+ # explicitly on the argument. See SPECIFICATION.md §4.3.
35
+ FILE_ARGUMENT_HEURISTIC = /file|path/i
36
+
37
+ def self.call(registry, program_name:)
38
+ new(registry, program_name).call
39
+ end
40
+
41
+ def initialize(registry, program_name)
42
+ @registry = registry
43
+ @program_name = program_name
44
+ end
45
+
46
+ def call
47
+ CompletionSpec.new(program_name: program_name, nodes: walk([]))
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :registry, :program_name
53
+
54
+ def walk(path, nodes = [])
55
+ result = registry.get(path)
56
+ visible = visible_children(result)
57
+
58
+ nodes << build_node(path, result, visible)
59
+ visible.each_key { |name| walk(path + [name], nodes) }
60
+ nodes
61
+ end
62
+
63
+ def visible_children(result)
64
+ result.children.reject { |_name, node| node.hidden }
65
+ end
66
+
67
+ def build_node(path, result, visible)
68
+ command = result.command
69
+
70
+ Node.new(
71
+ path: path,
72
+ desc: command&.description,
73
+ options: command ? command.options.map { |option| build_option(option) } : [],
74
+ arguments: command ? command.arguments.map { |argument| build_argument(argument) } : [],
75
+ children: visible.keys
76
+ )
77
+ end
78
+
79
+ def build_option(option)
80
+ OptionSpec.new(
81
+ name: option.name.to_s, type: option.type, values: option.values,
82
+ aliases: option.aliases, default: option.default, desc: option.options[:desc],
83
+ required: option.required? || false, boolean: option.boolean?, array: option.array?
84
+ )
85
+ end
86
+
87
+ def build_argument(argument)
88
+ ArgumentSpec.new(
89
+ name: argument.name.to_s, values: argument.values, desc: argument.options[:desc],
90
+ required: argument.required? || false, file: file_argument?(argument)
91
+ )
92
+ end
93
+
94
+ def file_argument?(argument)
95
+ explicit = argument.options[:file]
96
+ return !!explicit unless explicit.nil?
97
+
98
+ argument.name.to_s.match?(FILE_ARGUMENT_HEURISTIC)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ 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.3"
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.3
5
+ platform: ruby
6
+ authors:
7
+ - Konstantin Gredeskoul
8
+ bindir: bin
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
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".secrets.baseline"
49
+ - CHANGELOG.md
50
+ - CLAUDE.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - SPECIFICATION.md
55
+ - docs/img/badge.svg
56
+ - justfile
57
+ - lefthook.yml
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: '4.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: []