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.
@@ -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