thor-completion 0.0.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 +7 -0
- data/.github/CODEOWNERS +4 -0
- data/.github/FUNDING.yml +4 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +41 -0
- data/.github/ISSUE_TEMPLATE/config.yml +5 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +23 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +38 -0
- data/.github/dependabot.yml +15 -0
- data/.github/workflows/ruby-tests.yml +52 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +20 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +79 -0
- data/Gemfile +5 -0
- data/LICENSE.md +20 -0
- data/README.md +85 -0
- data/Rakefile +15 -0
- data/bin/console +16 -0
- data/bin/setup +10 -0
- data/lib/thor/completion/bash.rb +254 -0
- data/lib/thor/completion/builder.rb +170 -0
- data/lib/thor/completion/fish.rb +186 -0
- data/lib/thor/completion/powershell.rb +164 -0
- data/lib/thor/completion/schema.json +208 -0
- data/lib/thor/completion/schema_validator.rb +16 -0
- data/lib/thor/completion/version.rb +7 -0
- data/lib/thor/completion/zsh.rb +343 -0
- data/lib/thor/completion.rb +33 -0
- data/lib/thor-completion.rb +3 -0
- data/thor-completion.gemspec +46 -0
- metadata +189 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Completion
|
|
5
|
+
class Bash
|
|
6
|
+
include SchemaValidator
|
|
7
|
+
|
|
8
|
+
attr_reader :output, :schema, :name, :commands, :global_options
|
|
9
|
+
|
|
10
|
+
def initialize(schema)
|
|
11
|
+
validate_schema!(schema)
|
|
12
|
+
|
|
13
|
+
@schema = schema
|
|
14
|
+
@name = schema[:name]
|
|
15
|
+
@commands = schema[:commands] || []
|
|
16
|
+
@global_options = schema[:globalOptions] || []
|
|
17
|
+
@output = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
generate_main_function
|
|
22
|
+
generate_subcommand_functions
|
|
23
|
+
|
|
24
|
+
output << ""
|
|
25
|
+
output << "complete -F _#{name} #{name}"
|
|
26
|
+
|
|
27
|
+
output.join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private def generate_main_function
|
|
31
|
+
output << "_#{name}() {"
|
|
32
|
+
output << " local cur prev words cword"
|
|
33
|
+
output << " if type _init_completion &>/dev/null; then"
|
|
34
|
+
output << " _init_completion || return"
|
|
35
|
+
output << " else"
|
|
36
|
+
output << " # Fallback initialization if bash-completion is not available"
|
|
37
|
+
output << " COMPREPLY=()"
|
|
38
|
+
output << " _get_comp_words_by_ref -n : cur prev words cword 2>/dev/null || {"
|
|
39
|
+
output << " cur=\"${COMP_WORDS[COMP_CWORD]}\""
|
|
40
|
+
output << " prev=\"${COMP_WORDS[COMP_CWORD-1]}\""
|
|
41
|
+
output << " words=(\"${COMP_WORDS[@]}\")"
|
|
42
|
+
output << " cword=$COMP_CWORD"
|
|
43
|
+
output << " }"
|
|
44
|
+
output << " fi"
|
|
45
|
+
output << ""
|
|
46
|
+
output << " local commands=\"#{commands.reject {|c| c[:hidden] }.map {|c| c[:name] }.join(' ')}\""
|
|
47
|
+
output << " local options=\"#{format_options(global_options)}\""
|
|
48
|
+
output << ""
|
|
49
|
+
output << " if [[ $cword -eq 1 ]]; then"
|
|
50
|
+
output << " COMPREPLY=($(compgen -W \"$commands $options\" -- \"$cur\"))"
|
|
51
|
+
output << " return"
|
|
52
|
+
output << " fi"
|
|
53
|
+
output << ""
|
|
54
|
+
output << " local command=\"${words[1]}\""
|
|
55
|
+
output << " case \"$command\" in"
|
|
56
|
+
|
|
57
|
+
commands.each do |cmd|
|
|
58
|
+
next if cmd[:hidden]
|
|
59
|
+
|
|
60
|
+
cmd_name = cmd[:name]
|
|
61
|
+
|
|
62
|
+
next unless cmd[:subcommands]&.any? || cmd[:options]&.any? || cmd[:arguments]&.any?
|
|
63
|
+
|
|
64
|
+
output << " #{cmd_name})"
|
|
65
|
+
output << " _#{name}_#{sanitize_name(cmd_name)}"
|
|
66
|
+
output << " ;;"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
output << " *)"
|
|
70
|
+
output << " COMPREPLY=()"
|
|
71
|
+
output << " ;;"
|
|
72
|
+
output << " esac"
|
|
73
|
+
output << "}"
|
|
74
|
+
output << ""
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private def generate_subcommand_functions
|
|
78
|
+
commands.each do |cmd|
|
|
79
|
+
next if cmd[:hidden]
|
|
80
|
+
next unless cmd[:subcommands]&.any? || cmd[:options]&.any? || cmd[:arguments]&.any?
|
|
81
|
+
|
|
82
|
+
generate_command_function(cmd, [])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private def generate_command_function(cmd, parent_names)
|
|
87
|
+
cmd_name = cmd[:name]
|
|
88
|
+
func_name = "_#{name}_#{(parent_names + [sanitize_name(cmd_name)]).join('_')}"
|
|
89
|
+
|
|
90
|
+
output << "#{func_name}() {"
|
|
91
|
+
|
|
92
|
+
if cmd[:subcommands]&.any?
|
|
93
|
+
subcommands = cmd[:subcommands].reject {|c| c[:hidden] }.map {|c| c[:name] }.join(" ")
|
|
94
|
+
output << " local subcommands=\"#{subcommands}\""
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
output << " local options=\"#{format_options(cmd[:options])}\"" if cmd[:options]&.any?
|
|
98
|
+
|
|
99
|
+
# Calculate the position where we are in the command
|
|
100
|
+
depth = parent_names.length + 2 # +2 for program name and command name
|
|
101
|
+
|
|
102
|
+
if cmd[:subcommands]&.any?
|
|
103
|
+
output << ""
|
|
104
|
+
output << " if [[ $cword -eq #{depth} ]]; then"
|
|
105
|
+
output << if cmd[:options]&.any?
|
|
106
|
+
" COMPREPLY=($(compgen -W \"$subcommands $options\" -- \"$cur\"))"
|
|
107
|
+
else
|
|
108
|
+
" COMPREPLY=($(compgen -W \"$subcommands\" -- \"$cur\"))"
|
|
109
|
+
end
|
|
110
|
+
output << " return"
|
|
111
|
+
output << " fi"
|
|
112
|
+
output << ""
|
|
113
|
+
output << " local subcommand=\"${words[#{depth}]}\""
|
|
114
|
+
output << " case \"$subcommand\" in"
|
|
115
|
+
|
|
116
|
+
cmd[:subcommands].each do |subcmd|
|
|
117
|
+
next if subcmd[:hidden]
|
|
118
|
+
|
|
119
|
+
subcmd_name = subcmd[:name]
|
|
120
|
+
|
|
121
|
+
next unless subcmd[:subcommands]&.any? || subcmd[:options]&.any? || subcmd[:arguments]&.any?
|
|
122
|
+
|
|
123
|
+
subcmd_func_name = "_#{name}_#{(parent_names + [sanitize_name(cmd_name), sanitize_name(subcmd_name)]).join('_')}"
|
|
124
|
+
output << " #{subcmd_name})"
|
|
125
|
+
output << " #{subcmd_func_name}"
|
|
126
|
+
output << " ;;"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
output << " *)"
|
|
130
|
+
output << if cmd[:options]&.any?
|
|
131
|
+
" COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))"
|
|
132
|
+
else
|
|
133
|
+
" COMPREPLY=()"
|
|
134
|
+
end
|
|
135
|
+
output << " ;;"
|
|
136
|
+
output << " esac"
|
|
137
|
+
elsif cmd[:arguments]&.any?
|
|
138
|
+
# Handle positional arguments
|
|
139
|
+
arg = cmd[:arguments].first
|
|
140
|
+
output << ""
|
|
141
|
+
output << " case \"$prev\" in"
|
|
142
|
+
|
|
143
|
+
# Handle options that take values
|
|
144
|
+
if cmd[:options]&.any?
|
|
145
|
+
cmd[:options].reject {|opt| opt[:type] == "boolean" }.each do |opt|
|
|
146
|
+
opt_names = ["--#{opt[:name]}"]
|
|
147
|
+
opt_names << "-#{opt[:short]}" if opt[:short]
|
|
148
|
+
|
|
149
|
+
output << " #{opt_names.join('|')})"
|
|
150
|
+
output << if opt[:enum]&.any?
|
|
151
|
+
" COMPREPLY=($(compgen -W \"#{opt[:enum].join(' ')}\" -- \"$cur\"))"
|
|
152
|
+
elsif opt[:completion]
|
|
153
|
+
" #{format_completion_bash(opt[:completion])}"
|
|
154
|
+
else
|
|
155
|
+
" COMPREPLY=($(compgen -f -- \"$cur\"))"
|
|
156
|
+
end
|
|
157
|
+
output << " return"
|
|
158
|
+
output << " ;;"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
output << " esac"
|
|
163
|
+
output << ""
|
|
164
|
+
|
|
165
|
+
if cmd[:options]&.any?
|
|
166
|
+
output << " if [[ $cur == -* ]]; then"
|
|
167
|
+
output << " COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))"
|
|
168
|
+
output << " return"
|
|
169
|
+
output << " fi"
|
|
170
|
+
output << ""
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Default to file completion for arguments
|
|
174
|
+
output << if arg[:completion]
|
|
175
|
+
" #{format_completion_bash(arg[:completion])}"
|
|
176
|
+
else
|
|
177
|
+
" COMPREPLY=($(compgen -f -- \"$cur\"))"
|
|
178
|
+
end
|
|
179
|
+
elsif cmd[:options]&.any?
|
|
180
|
+
# Only options, no arguments or subcommands
|
|
181
|
+
output << ""
|
|
182
|
+
output << " case \"$prev\" in"
|
|
183
|
+
|
|
184
|
+
cmd[:options].reject {|opt| opt[:type] == "boolean" }.each do |opt|
|
|
185
|
+
opt_names = ["--#{opt[:name]}"]
|
|
186
|
+
opt_names << "-#{opt[:short]}" if opt[:short]
|
|
187
|
+
|
|
188
|
+
output << " #{opt_names.join('|')})"
|
|
189
|
+
output << if opt[:enum]&.any?
|
|
190
|
+
" COMPREPLY=($(compgen -W \"#{opt[:enum].join(' ')}\" -- \"$cur\"))"
|
|
191
|
+
elsif opt[:completion]
|
|
192
|
+
" #{format_completion_bash(opt[:completion])}"
|
|
193
|
+
else
|
|
194
|
+
" COMPREPLY=($(compgen -f -- \"$cur\"))"
|
|
195
|
+
end
|
|
196
|
+
output << " return"
|
|
197
|
+
output << " ;;"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
output << " esac"
|
|
201
|
+
output << ""
|
|
202
|
+
output << " COMPREPLY=($(compgen -W \"$options\" -- \"$cur\"))"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
output << "}"
|
|
206
|
+
output << ""
|
|
207
|
+
|
|
208
|
+
# Recursively generate functions for subcommands
|
|
209
|
+
return unless cmd[:subcommands]&.any?
|
|
210
|
+
|
|
211
|
+
cmd[:subcommands].each do |subcmd|
|
|
212
|
+
next if subcmd[:hidden]
|
|
213
|
+
next unless subcmd[:subcommands]&.any? || subcmd[:options]&.any? || subcmd[:arguments]&.any?
|
|
214
|
+
|
|
215
|
+
generate_command_function(subcmd, parent_names + [sanitize_name(cmd_name)])
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private def format_options(options)
|
|
220
|
+
options.reject {|opt| opt[:hidden] }
|
|
221
|
+
.map {|opt| format_option_name(opt) }
|
|
222
|
+
.flatten
|
|
223
|
+
.join(" ")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private def format_option_name(opt)
|
|
227
|
+
names = []
|
|
228
|
+
Array(opt[:short]).each {|s| names << "-#{s}" } if opt[:short]
|
|
229
|
+
names << "--#{opt[:name]}" if opt[:name]
|
|
230
|
+
names
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private def format_completion_bash(completion)
|
|
234
|
+
case completion[:type]
|
|
235
|
+
when "directory"
|
|
236
|
+
"COMPREPLY=($(compgen -d -- \"$cur\"))"
|
|
237
|
+
when "static"
|
|
238
|
+
values = completion[:values].join(" ")
|
|
239
|
+
"COMPREPLY=($(compgen -W \"#{values}\" -- \"$cur\"))"
|
|
240
|
+
when "command", "dynamic"
|
|
241
|
+
cmd = completion[:command]
|
|
242
|
+
"COMPREPLY=($(compgen -W \"$(#{cmd})\" -- \"$cur\"))"
|
|
243
|
+
else
|
|
244
|
+
# Default to file completion for "file" type and unknown types
|
|
245
|
+
"COMPREPLY=($(compgen -f -- \"$cur\"))"
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private def sanitize_name(name)
|
|
250
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Completion
|
|
5
|
+
class Builder
|
|
6
|
+
extend SchemaValidator
|
|
7
|
+
|
|
8
|
+
def self.call(name:, description:, version:, cli:)
|
|
9
|
+
schema = {
|
|
10
|
+
name:,
|
|
11
|
+
description:,
|
|
12
|
+
version:,
|
|
13
|
+
commands: [],
|
|
14
|
+
subcommands: [],
|
|
15
|
+
globalOptions: []
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
build_command(cli, schema)
|
|
19
|
+
|
|
20
|
+
cli.class_options.each_value do |option|
|
|
21
|
+
schema[:globalOptions] += build_option(option)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
schema
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.build_command(cli, parent)
|
|
28
|
+
cli.options.each_value do |option|
|
|
29
|
+
parent[:options] += build_option(option)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
cli.all_commands.each_value do |command|
|
|
33
|
+
cmd_schema = {
|
|
34
|
+
name: command.name,
|
|
35
|
+
description: command.description || "",
|
|
36
|
+
options: command.options.each_value.flat_map { build_option(it) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Extract positional arguments from method parameters
|
|
40
|
+
if cli.instance_methods(false).include?(command.name.to_sym)
|
|
41
|
+
method = cli.instance_method(command.name.to_sym)
|
|
42
|
+
arguments = extract_arguments(method)
|
|
43
|
+
cmd_schema[:arguments] = arguments if arguments.any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if this command has subcommands
|
|
47
|
+
subcommand_class = cli.subcommand_classes[command.name]
|
|
48
|
+
if subcommand_class
|
|
49
|
+
cmd_schema[:subcommands] = []
|
|
50
|
+
|
|
51
|
+
# Recursively build subcommands
|
|
52
|
+
build_subcommands(subcommand_class, cmd_schema)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
parent[:commands] << cmd_schema
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.resolve_completion(value)
|
|
60
|
+
value = value.to_s
|
|
61
|
+
|
|
62
|
+
return "file" if value == "file" || value.end_with?("_file")
|
|
63
|
+
return "directory" if value == "dir" || value.match?(/_dir(ectory)?$/)
|
|
64
|
+
return "directory" if value == "folder" || value.end_with?("_folder")
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.build_subcommands(cli, parent)
|
|
70
|
+
cli.all_commands.each_value do |command|
|
|
71
|
+
subcmd_schema = {
|
|
72
|
+
name: command.name,
|
|
73
|
+
description: command.description || "",
|
|
74
|
+
options: command.options.each_value.flat_map { build_option(it) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Extract positional arguments from method parameters
|
|
78
|
+
if cli.instance_methods(false).include?(command.name.to_sym)
|
|
79
|
+
method = cli.instance_method(command.name.to_sym)
|
|
80
|
+
arguments = extract_arguments(method)
|
|
81
|
+
subcmd_schema[:arguments] = arguments if arguments.any?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if this subcommand has its own subcommands
|
|
85
|
+
subcommand_class = cli.subcommand_classes[command.name]
|
|
86
|
+
if subcommand_class
|
|
87
|
+
subcmd_schema[:subcommands] = []
|
|
88
|
+
|
|
89
|
+
# Recursively build nested subcommands
|
|
90
|
+
build_subcommands(subcommand_class, subcmd_schema)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
parent[:subcommands] << subcmd_schema
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.extract_arguments(method)
|
|
98
|
+
values = method
|
|
99
|
+
.parameters
|
|
100
|
+
.select {|type, _| %i[req opt rest].include?(type) } # rubocop:disable Style/HashSlice
|
|
101
|
+
|
|
102
|
+
values.map do |type, name|
|
|
103
|
+
arg_hash = {
|
|
104
|
+
name: name.to_s,
|
|
105
|
+
description: name.to_s.tr("_", " ").capitalize,
|
|
106
|
+
required: type == :req,
|
|
107
|
+
variadic: type == :rest
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Add completion hint if available
|
|
111
|
+
completion = resolve_completion(name)
|
|
112
|
+
arg_hash[:completion] = {type: completion} if completion
|
|
113
|
+
|
|
114
|
+
arg_hash
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.build_option(option)
|
|
119
|
+
dasherize = proc { it.to_s.tr("_", "-") }
|
|
120
|
+
name = dasherize.call(option.name)
|
|
121
|
+
|
|
122
|
+
[].tap do |list|
|
|
123
|
+
short_opts = option.aliases.map { dasherize.call(it.gsub(/^-+/, "")) }
|
|
124
|
+
opt_hash = {
|
|
125
|
+
name:,
|
|
126
|
+
type: option.type.to_s,
|
|
127
|
+
description: option.description || "",
|
|
128
|
+
required: option.required,
|
|
129
|
+
repeatable: option.repeatable || false,
|
|
130
|
+
default: option.default,
|
|
131
|
+
enum: Array(option.enum),
|
|
132
|
+
hidden: option.hide || false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (completion = resolve_completion(option.name))
|
|
136
|
+
opt_hash[:completion] = completion
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Only add short if there are aliases
|
|
140
|
+
if short_opts.any?
|
|
141
|
+
opt_hash[:short] =
|
|
142
|
+
short_opts.length == 1 ? short_opts.first : short_opts
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
list << opt_hash
|
|
146
|
+
|
|
147
|
+
if option.type == :boolean
|
|
148
|
+
list << {
|
|
149
|
+
name: "no-#{name}",
|
|
150
|
+
type: option.type.to_s,
|
|
151
|
+
description: "",
|
|
152
|
+
required: false,
|
|
153
|
+
repeatable: option.repeatable || false,
|
|
154
|
+
hidden: option.hide || false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
list << {
|
|
158
|
+
name: "skip-#{name}",
|
|
159
|
+
type: option.type.to_s,
|
|
160
|
+
description: "",
|
|
161
|
+
required: false,
|
|
162
|
+
repeatable: option.repeatable || false,
|
|
163
|
+
hidden: option.hide || false
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Thor
|
|
4
|
+
module Completion
|
|
5
|
+
class Fish
|
|
6
|
+
include SchemaValidator
|
|
7
|
+
|
|
8
|
+
attr_reader :output, :schema, :name, :commands, :global_options
|
|
9
|
+
|
|
10
|
+
def initialize(schema)
|
|
11
|
+
validate_schema!(schema)
|
|
12
|
+
|
|
13
|
+
@schema = schema
|
|
14
|
+
@name = schema[:name]
|
|
15
|
+
@commands = schema[:commands] || []
|
|
16
|
+
@global_options = schema[:globalOptions] || []
|
|
17
|
+
@output = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
# Add a description comment for the command itself
|
|
22
|
+
output << "# #{name} - #{schema[:description]}" if schema[:description]
|
|
23
|
+
output << ""
|
|
24
|
+
|
|
25
|
+
generate_completions
|
|
26
|
+
|
|
27
|
+
output.join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private def generate_completions
|
|
31
|
+
# Generate completions for main commands
|
|
32
|
+
commands.reject {|c| c[:hidden] }.each do |cmd|
|
|
33
|
+
cmd_name = cmd[:name]
|
|
34
|
+
cmd_desc = escape_fish(cmd[:description] || "")
|
|
35
|
+
|
|
36
|
+
output << "complete -c #{name} -n \"__fish_use_subcommand\" -a #{cmd_name} -d #{quote_fish(cmd_desc)}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Generate global options
|
|
40
|
+
global_options.reject {|opt| opt[:hidden] }.each do |opt|
|
|
41
|
+
generate_option_completion(opt, "__fish_use_subcommand")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Generate completions for subcommands
|
|
45
|
+
commands.each do |cmd|
|
|
46
|
+
next if cmd[:hidden]
|
|
47
|
+
|
|
48
|
+
generate_command_completions(cmd, [cmd[:name]])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private def generate_command_completions(cmd, path)
|
|
53
|
+
condition = build_condition(path)
|
|
54
|
+
|
|
55
|
+
# Generate subcommands
|
|
56
|
+
if cmd[:subcommands]&.any?
|
|
57
|
+
cmd[:subcommands].reject {|c| c[:hidden] }.each do |subcmd|
|
|
58
|
+
subcmd_name = subcmd[:name]
|
|
59
|
+
subcmd_desc = escape_fish(subcmd[:description] || "")
|
|
60
|
+
|
|
61
|
+
output << "complete -c #{name} -n #{quote_fish(condition)} -a #{subcmd_name} -d #{quote_fish(subcmd_desc)}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Generate options
|
|
66
|
+
if cmd[:options]&.any?
|
|
67
|
+
cmd[:options].reject {|opt| opt[:hidden] }.each do |opt|
|
|
68
|
+
generate_option_completion(opt, condition)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Generate argument completions
|
|
73
|
+
if cmd[:arguments]&.any?
|
|
74
|
+
arg = cmd[:arguments].first
|
|
75
|
+
if arg[:completion]
|
|
76
|
+
case arg[:completion][:type]
|
|
77
|
+
when "directory"
|
|
78
|
+
output << "complete -c #{name} -n #{quote_fish(condition)} -x -a '(__fish_complete_directories)'"
|
|
79
|
+
# For "file" type or others, Fish will provide file completion by default
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
# Fish provides file completion by default, so we don't need to add -F
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Recursively generate completions for subcommands
|
|
86
|
+
return unless cmd[:subcommands]&.any?
|
|
87
|
+
|
|
88
|
+
cmd[:subcommands].each do |subcmd|
|
|
89
|
+
next if subcmd[:hidden]
|
|
90
|
+
|
|
91
|
+
generate_command_completions(subcmd, path + [subcmd[:name]])
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private def generate_option_completion(opt, condition)
|
|
96
|
+
opt_desc = escape_fish(opt[:description] || "")
|
|
97
|
+
|
|
98
|
+
# Build the base completion command
|
|
99
|
+
base_cmd = "complete -c #{name} -n #{quote_fish(condition)}"
|
|
100
|
+
|
|
101
|
+
# Add short and long options
|
|
102
|
+
if opt[:short]
|
|
103
|
+
shorts = Array(opt[:short])
|
|
104
|
+
|
|
105
|
+
shorts.each do |short|
|
|
106
|
+
parts = [base_cmd, "-s #{short}"]
|
|
107
|
+
parts << "-l #{opt[:name]}" if opt[:name]
|
|
108
|
+
parts << "-d #{quote_fish(opt_desc)}" if opt_desc && !opt_desc.empty?
|
|
109
|
+
|
|
110
|
+
# Add value requirements for non-boolean options
|
|
111
|
+
if opt[:type] != "boolean"
|
|
112
|
+
parts << "-r" # require argument
|
|
113
|
+
|
|
114
|
+
if opt[:enum]&.any?
|
|
115
|
+
# Add enum values as completions
|
|
116
|
+
values = opt[:enum].map {|v| escape_fish(v) }.join(" ")
|
|
117
|
+
parts << "-a #{quote_fish(values)}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
output << parts.join(" ")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# If there's also a long option and multiple shorts, add long-only completion
|
|
125
|
+
if opt[:name] && shorts.any?
|
|
126
|
+
parts = [base_cmd, "-l #{opt[:name]}"]
|
|
127
|
+
parts << "-d #{quote_fish(opt_desc)}" if opt_desc && !opt_desc.empty?
|
|
128
|
+
|
|
129
|
+
if opt[:type] != "boolean"
|
|
130
|
+
parts << "-r"
|
|
131
|
+
if opt[:enum]&.any?
|
|
132
|
+
values = opt[:enum].map {|v| escape_fish(v) }.join(" ")
|
|
133
|
+
parts << "-a #{quote_fish(values)}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
output << parts.join(" ")
|
|
138
|
+
end
|
|
139
|
+
elsif opt[:name]
|
|
140
|
+
# Long option only
|
|
141
|
+
parts = [base_cmd, "-l #{opt[:name]}"]
|
|
142
|
+
parts << "-d #{quote_fish(opt_desc)}" if opt_desc && !opt_desc.empty?
|
|
143
|
+
|
|
144
|
+
if opt[:type] != "boolean"
|
|
145
|
+
parts << "-r"
|
|
146
|
+
if opt[:enum]&.any?
|
|
147
|
+
values = opt[:enum].map {|v| escape_fish(v) }.join(" ")
|
|
148
|
+
parts << "-a #{quote_fish(values)}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
output << parts.join(" ")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private def build_condition(path)
|
|
157
|
+
# Build a Fish condition that checks if we're in the right subcommand context
|
|
158
|
+
# For example: "__fish_seen_subcommand_from utils; and __fish_seen_subcommand_from nested"
|
|
159
|
+
conditions = path.map do |cmd|
|
|
160
|
+
"__fish_seen_subcommand_from #{cmd}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if conditions.length > 1
|
|
164
|
+
# For nested commands, we need to ensure we're in the right context
|
|
165
|
+
conditions.join("; and ")
|
|
166
|
+
else
|
|
167
|
+
conditions.first
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private def quote_fish(str)
|
|
172
|
+
# Fish uses single quotes, escape single quotes by ending the string,
|
|
173
|
+
# adding an escaped quote, and starting a new string
|
|
174
|
+
"'#{str.to_s.gsub("'", "'\\''")}'"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private def escape_fish(str)
|
|
178
|
+
str.to_s.gsub("'", "\\'")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private def sanitize_name(name)
|
|
182
|
+
name.to_s.gsub(/[^a-zA-Z0-9_]/, "_")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|