rubycli 0.1.1
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/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.ja.md +404 -0
- data/README.md +399 -0
- data/exe/rubycli +6 -0
- data/lib/rubycli/argument_parser.rb +343 -0
- data/lib/rubycli/cli.rb +341 -0
- data/lib/rubycli/command_line.rb +116 -0
- data/lib/rubycli/documentation_registry.rb +836 -0
- data/lib/rubycli/environment.rb +77 -0
- data/lib/rubycli/eval_coercer.rb +42 -0
- data/lib/rubycli/help_renderer.rb +298 -0
- data/lib/rubycli/json_coercer.rb +32 -0
- data/lib/rubycli/result_emitter.rb +41 -0
- data/lib/rubycli/type_utils.rb +128 -0
- data/lib/rubycli/types.rb +16 -0
- data/lib/rubycli/version.rb +5 -0
- data/lib/rubycli.rb +406 -0
- metadata +65 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
require_relative 'type_utils'
|
|
2
|
+
|
|
3
|
+
module Rubycli
|
|
4
|
+
class ArgumentParser
|
|
5
|
+
include TypeUtils
|
|
6
|
+
|
|
7
|
+
def initialize(environment:, documentation_registry:, json_coercer:, debug_logger:)
|
|
8
|
+
@environment = environment
|
|
9
|
+
@documentation_registry = documentation_registry
|
|
10
|
+
@json_coercer = json_coercer
|
|
11
|
+
@debug_logger = debug_logger
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse(args, method = nil)
|
|
15
|
+
pos_args = []
|
|
16
|
+
kw_args = {}
|
|
17
|
+
|
|
18
|
+
kw_param_names = extract_keyword_parameter_names(method)
|
|
19
|
+
debug_log "Available keyword parameters: #{kw_param_names.inspect}"
|
|
20
|
+
|
|
21
|
+
metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil }
|
|
22
|
+
option_defs = metadata[:options] || []
|
|
23
|
+
cli_aliases = build_cli_alias_map(option_defs)
|
|
24
|
+
option_lookup = build_option_lookup(option_defs)
|
|
25
|
+
type_converters = build_type_converter_map(option_defs)
|
|
26
|
+
|
|
27
|
+
i = 0
|
|
28
|
+
while i < args.size
|
|
29
|
+
token = args[i]
|
|
30
|
+
|
|
31
|
+
if token == '--'
|
|
32
|
+
rest_tokens = (args[(i + 1)..-1] || []).map { |value| convert_arg(value) }
|
|
33
|
+
pos_args.concat(rest_tokens)
|
|
34
|
+
break
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if option_token?(token)
|
|
38
|
+
i = process_option_token(
|
|
39
|
+
token,
|
|
40
|
+
args,
|
|
41
|
+
i,
|
|
42
|
+
kw_param_names,
|
|
43
|
+
kw_args,
|
|
44
|
+
cli_aliases,
|
|
45
|
+
option_lookup,
|
|
46
|
+
type_converters
|
|
47
|
+
)
|
|
48
|
+
elsif assignment_token?(token)
|
|
49
|
+
process_assignment_token(token, kw_args)
|
|
50
|
+
else
|
|
51
|
+
pos_args << convert_arg(token)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
i += 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
58
|
+
[pos_args, kw_args]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def debug_log(message)
|
|
64
|
+
return unless @debug_logger
|
|
65
|
+
|
|
66
|
+
@debug_logger.call(message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def extract_keyword_parameter_names(method)
|
|
70
|
+
return [] unless method
|
|
71
|
+
|
|
72
|
+
method.parameters
|
|
73
|
+
.select { |type, _| %i[key keyreq].include?(type) }
|
|
74
|
+
.map { |_, name| name.to_s }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def option_token?(token)
|
|
78
|
+
token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def assignment_token?(token)
|
|
82
|
+
!split_assignment_token(token).nil?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def process_option_token(
|
|
86
|
+
token,
|
|
87
|
+
args,
|
|
88
|
+
current_index,
|
|
89
|
+
kw_param_names,
|
|
90
|
+
kw_args,
|
|
91
|
+
cli_aliases,
|
|
92
|
+
option_lookup,
|
|
93
|
+
type_converters
|
|
94
|
+
)
|
|
95
|
+
token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
|
|
96
|
+
cli_key = Regexp.last_match(1).tr('-', '_')
|
|
97
|
+
embedded_value = Regexp.last_match(2)
|
|
98
|
+
|
|
99
|
+
resolved_cli_key = cli_aliases.fetch(cli_key, cli_key)
|
|
100
|
+
debug_log "Processing option '#{Regexp.last_match(1)}' -> '#{resolved_cli_key}'"
|
|
101
|
+
|
|
102
|
+
resolved_key = resolve_keyword_parameter(resolved_cli_key, kw_param_names)
|
|
103
|
+
final_key = resolved_key || resolved_cli_key
|
|
104
|
+
final_key_sym = final_key.to_sym
|
|
105
|
+
|
|
106
|
+
option_meta = option_lookup[final_key_sym]
|
|
107
|
+
requires_value = option_meta ? option_meta[:requires_value] : nil
|
|
108
|
+
option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
|
|
109
|
+
|
|
110
|
+
value_capture, current_index = if embedded_value
|
|
111
|
+
[embedded_value, current_index]
|
|
112
|
+
elsif option_meta
|
|
113
|
+
capture_option_value(
|
|
114
|
+
option_meta,
|
|
115
|
+
args,
|
|
116
|
+
current_index,
|
|
117
|
+
requires_value
|
|
118
|
+
)
|
|
119
|
+
elsif current_index + 1 < args.size && !looks_like_option?(args[current_index + 1])
|
|
120
|
+
current_index += 1
|
|
121
|
+
[args[current_index], current_index]
|
|
122
|
+
else
|
|
123
|
+
['true', current_index]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if requires_value && (value_capture.nil? || value_capture == 'true')
|
|
127
|
+
raise ArgumentError, "Option '#{option_label}' requires a value"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
converted_value = convert_option_value(
|
|
131
|
+
final_key_sym,
|
|
132
|
+
value_capture,
|
|
133
|
+
option_meta,
|
|
134
|
+
type_converters
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
kw_args[final_key_sym] = converted_value
|
|
138
|
+
current_index
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def capture_option_value(option_meta, args, current_index, requires_value)
|
|
142
|
+
new_index = current_index
|
|
143
|
+
value = if option_meta[:boolean_flag]
|
|
144
|
+
if new_index + 1 < args.size && TypeUtils.boolean_string?(args[new_index + 1])
|
|
145
|
+
new_index += 1
|
|
146
|
+
args[new_index]
|
|
147
|
+
else
|
|
148
|
+
'true'
|
|
149
|
+
end
|
|
150
|
+
elsif option_meta[:optional_value]
|
|
151
|
+
if new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
|
|
152
|
+
new_index += 1
|
|
153
|
+
args[new_index]
|
|
154
|
+
else
|
|
155
|
+
true
|
|
156
|
+
end
|
|
157
|
+
elsif requires_value == false
|
|
158
|
+
'true'
|
|
159
|
+
elsif requires_value
|
|
160
|
+
if new_index + 1 >= args.size
|
|
161
|
+
raise ArgumentError, "Option '#{option_meta.long}' requires a value"
|
|
162
|
+
end
|
|
163
|
+
new_index += 1
|
|
164
|
+
args[new_index]
|
|
165
|
+
elsif new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
|
|
166
|
+
new_index += 1
|
|
167
|
+
args[new_index]
|
|
168
|
+
else
|
|
169
|
+
'true'
|
|
170
|
+
end
|
|
171
|
+
[value, new_index]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def process_assignment_token(token, kw_args)
|
|
175
|
+
key, value = split_assignment_token(token)
|
|
176
|
+
kw_args[key.to_sym] = convert_arg(value)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def split_assignment_token(token)
|
|
180
|
+
return nil unless token&.include?('=')
|
|
181
|
+
|
|
182
|
+
key, value = token.split('=', 2)
|
|
183
|
+
return nil if key.nil? || key.empty? || value.nil?
|
|
184
|
+
return nil unless key.match?(/\A[a-zA-Z_]\w*\z/)
|
|
185
|
+
|
|
186
|
+
[key, value]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def resolve_keyword_parameter(cli_key, kw_param_names)
|
|
190
|
+
exact_match = kw_param_names.find { |name| name == cli_key }
|
|
191
|
+
return exact_match if exact_match
|
|
192
|
+
|
|
193
|
+
matching_keys = kw_param_names.select { |name| name.start_with?(cli_key) }
|
|
194
|
+
debug_log "Prefix matching for '#{cli_key}': found #{matching_keys.inspect}"
|
|
195
|
+
|
|
196
|
+
if matching_keys.size == 1
|
|
197
|
+
debug_log "Unambiguous prefix match found: '#{matching_keys.first}'"
|
|
198
|
+
matching_keys.first
|
|
199
|
+
else
|
|
200
|
+
debug_log "No unique match found for '#{cli_key}'"
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def convert_arg(arg)
|
|
206
|
+
return nil if arg.nil? || arg.casecmp('nil').zero?
|
|
207
|
+
return true if arg.casecmp('true').zero?
|
|
208
|
+
return false if arg.casecmp('false').zero?
|
|
209
|
+
return arg.to_i if integer_string?(arg)
|
|
210
|
+
return arg.to_f if float_string?(arg)
|
|
211
|
+
|
|
212
|
+
arg
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def integer_string?(str)
|
|
216
|
+
str =~ /\A-?\d+\z/
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def float_string?(str)
|
|
220
|
+
str =~ /\A-?\d+\.\d+\z/
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def build_cli_alias_map(option_defs)
|
|
225
|
+
option_defs.each_with_object({}) do |opt, memo|
|
|
226
|
+
next unless opt.short
|
|
227
|
+
|
|
228
|
+
key = opt.short.delete_prefix('-')
|
|
229
|
+
memo[key] = opt.keyword.to_s
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def build_option_lookup(option_defs)
|
|
234
|
+
option_defs.each_with_object({}) do |opt, memo|
|
|
235
|
+
memo[opt.keyword] = opt
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def build_type_converter_map(option_defs)
|
|
240
|
+
option_defs.each_with_object({}) do |opt, memo|
|
|
241
|
+
next if opt.types.nil? || opt.types.empty?
|
|
242
|
+
|
|
243
|
+
converter = build_converter_for_types(opt.types)
|
|
244
|
+
memo[opt.keyword] = converter if converter
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def build_converter_for_types(types)
|
|
249
|
+
return nil if types.empty?
|
|
250
|
+
|
|
251
|
+
allow_nil = types.any? { |type| nil_type?(type) }
|
|
252
|
+
converters = types.map { |type| converter_for_single_type(type) }.compact
|
|
253
|
+
|
|
254
|
+
return nil if converters.empty? && !allow_nil
|
|
255
|
+
|
|
256
|
+
lambda do |value|
|
|
257
|
+
return nil if value.nil? && allow_nil
|
|
258
|
+
|
|
259
|
+
if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
|
|
260
|
+
next nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if converters.empty?
|
|
264
|
+
value
|
|
265
|
+
else
|
|
266
|
+
last_error = nil
|
|
267
|
+
converters.each do |converter|
|
|
268
|
+
begin
|
|
269
|
+
result = converter.call(value)
|
|
270
|
+
return result
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
last_error = e
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
raise last_error || ArgumentError.new("Could not convert value '#{value}'")
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def converter_for_single_type(type)
|
|
281
|
+
normalized = type.to_s.strip
|
|
282
|
+
|
|
283
|
+
return nil if nil_type?(normalized)
|
|
284
|
+
|
|
285
|
+
case normalized
|
|
286
|
+
when 'String'
|
|
287
|
+
->(value) { value }
|
|
288
|
+
when 'Integer', 'Fixnum'
|
|
289
|
+
->(value) { Integer(value, 10) }
|
|
290
|
+
when 'Float'
|
|
291
|
+
->(value) { Float(value) }
|
|
292
|
+
when 'Numeric'
|
|
293
|
+
->(value) { Float(value) }
|
|
294
|
+
when 'Boolean', 'TrueClass', 'FalseClass'
|
|
295
|
+
->(value) { TypeUtils.convert_boolean(value) }
|
|
296
|
+
when 'Symbol'
|
|
297
|
+
->(value) { value.to_sym }
|
|
298
|
+
when 'BigDecimal', 'Decimal'
|
|
299
|
+
require 'bigdecimal'
|
|
300
|
+
->(value) { BigDecimal(value) }
|
|
301
|
+
when 'Date'
|
|
302
|
+
require 'date'
|
|
303
|
+
->(value) { Date.parse(value) }
|
|
304
|
+
when 'Time', 'DateTime'
|
|
305
|
+
require 'time'
|
|
306
|
+
->(value) { Time.parse(value) }
|
|
307
|
+
when 'JSON', 'Hash'
|
|
308
|
+
->(value) { JSON.parse(value) }
|
|
309
|
+
else
|
|
310
|
+
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
311
|
+
inner = normalized[6..-2].strip
|
|
312
|
+
element_converter = converter_for_single_type(inner)
|
|
313
|
+
->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
|
|
314
|
+
elsif normalized.end_with?('[]')
|
|
315
|
+
inner = normalized[0..-3]
|
|
316
|
+
element_converter = converter_for_single_type(inner)
|
|
317
|
+
->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
|
|
318
|
+
elsif normalized == 'Array'
|
|
319
|
+
->(value) { TypeUtils.parse_list(value) }
|
|
320
|
+
else
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def convert_option_value(keyword, value, option_meta, type_converters)
|
|
327
|
+
converter = type_converters[keyword]
|
|
328
|
+
return convert_arg(value) unless converter
|
|
329
|
+
|
|
330
|
+
converter.call(value)
|
|
331
|
+
rescue StandardError => e
|
|
332
|
+
option_label = option_meta&.long || option_meta&.short || keyword
|
|
333
|
+
raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def looks_like_option?(token)
|
|
337
|
+
return false unless token
|
|
338
|
+
return false if token == '--'
|
|
339
|
+
|
|
340
|
+
token.start_with?('-') && !(token =~ /\A-?\d+(\.\d+)?\z/)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
data/lib/rubycli/cli.rb
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
require 'set'
|
|
2
|
+
|
|
3
|
+
module Rubycli
|
|
4
|
+
class CLI
|
|
5
|
+
CommandEntry = Struct.new(:command, :method, :category, :aliases, keyword_init: true) do
|
|
6
|
+
def all_commands
|
|
7
|
+
[command] + aliases
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
CommandCatalog = Struct.new(:entries, :alias_map, :duplicates, keyword_init: true) do
|
|
12
|
+
def lookup(command)
|
|
13
|
+
alias_map[command]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def entries_for(category)
|
|
17
|
+
entries.select { |entry| entry.category == category }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def commands
|
|
21
|
+
entries.map(&:command)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(environment:, argument_parser:, documentation_registry:, help_renderer:, result_emitter:)
|
|
26
|
+
@environment = environment
|
|
27
|
+
@argument_parser = argument_parser
|
|
28
|
+
@documentation_registry = documentation_registry
|
|
29
|
+
@help_renderer = help_renderer
|
|
30
|
+
@result_emitter = result_emitter
|
|
31
|
+
@file_cache = {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(target, args = ARGV, cli_mode = true)
|
|
35
|
+
debug_log "Starting rubycli with args: #{args.inspect}"
|
|
36
|
+
catalog = command_catalog(target)
|
|
37
|
+
|
|
38
|
+
if should_show_help?(args)
|
|
39
|
+
@help_renderer.print_help(target, catalog)
|
|
40
|
+
exit(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
command = args.shift
|
|
44
|
+
entry = resolve_command_entry(catalog, command)
|
|
45
|
+
|
|
46
|
+
if entry.nil?
|
|
47
|
+
handle_missing_method(target, catalog, command, args, cli_mode)
|
|
48
|
+
else
|
|
49
|
+
execute_method(entry.method, command, args, cli_mode)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def available_commands(target)
|
|
54
|
+
command_catalog(target).commands.sort
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def find_method(target, command)
|
|
58
|
+
catalog = command_catalog(target)
|
|
59
|
+
entry = catalog.lookup(command)
|
|
60
|
+
entry ||= catalog.lookup(normalize_command(command)) if command.include?('-')
|
|
61
|
+
entry&.method
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def usage_for_method(command, method)
|
|
65
|
+
@help_renderer.usage_for_method(command, method)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def method_description(method)
|
|
69
|
+
@help_renderer.method_description(method)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def command_catalog_for(target)
|
|
73
|
+
command_catalog(target)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def debug_log(message)
|
|
79
|
+
return unless @environment.debug?
|
|
80
|
+
|
|
81
|
+
puts "[DEBUG] #{message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def should_show_help?(args)
|
|
85
|
+
args.empty? || ['help', '--help', '-h'].include?(args[0])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_command_entry(catalog, command)
|
|
89
|
+
entry = catalog.lookup(command)
|
|
90
|
+
return entry if entry
|
|
91
|
+
|
|
92
|
+
return entry unless command.include?('-')
|
|
93
|
+
|
|
94
|
+
normalized = normalize_command(command)
|
|
95
|
+
debug_log "Tried snake_case conversion: #{command} -> #{normalized}" if normalized != command
|
|
96
|
+
catalog.lookup(normalized)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_missing_method(target, catalog, command, args, cli_mode)
|
|
100
|
+
if target.respond_to?(:call)
|
|
101
|
+
debug_log "Target is callable, treating as lambda/proc"
|
|
102
|
+
args.unshift(command)
|
|
103
|
+
execute_callable(target, args, command, cli_mode)
|
|
104
|
+
else
|
|
105
|
+
error_msg = "Command '#{command}' is not available."
|
|
106
|
+
puts error_msg
|
|
107
|
+
@help_renderer.print_help(target, catalog)
|
|
108
|
+
exit(1)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def execute_callable(target, args, command, cli_mode)
|
|
113
|
+
method = target.method(:call)
|
|
114
|
+
pos_args, kw_args = @argument_parser.parse(args, method)
|
|
115
|
+
Rubycli.apply_argument_coercions(pos_args, kw_args)
|
|
116
|
+
begin
|
|
117
|
+
result = Rubycli.call_target(target, pos_args, kw_args)
|
|
118
|
+
@result_emitter.emit(result)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
handle_execution_error(e, command, method, pos_args, kw_args, cli_mode)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def execute_method(method_obj, command, args, cli_mode)
|
|
125
|
+
if method_obj.parameters.empty? && !args.empty?
|
|
126
|
+
execute_parameterless_method(method_obj, command, args, cli_mode)
|
|
127
|
+
else
|
|
128
|
+
execute_method_with_params(method_obj, command, args, cli_mode)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def execute_parameterless_method(method_obj, command, args, cli_mode)
|
|
133
|
+
if help_requested_for_parameterless?(args)
|
|
134
|
+
puts usage_for_method(command, method_obj)
|
|
135
|
+
exit(0)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
result = method_obj.call
|
|
140
|
+
debug_log "Parameterless method returned: #{result.inspect}"
|
|
141
|
+
if result
|
|
142
|
+
run(result, args, false)
|
|
143
|
+
end
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
handle_execution_error(e, command, method_obj, [], {}, cli_mode)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def execute_method_with_params(method_obj, command, args, cli_mode)
|
|
150
|
+
pos_args, kw_args = @argument_parser.parse(args, method_obj)
|
|
151
|
+
Rubycli.apply_argument_coercions(pos_args, kw_args)
|
|
152
|
+
|
|
153
|
+
if should_show_method_help?(pos_args, kw_args)
|
|
154
|
+
puts usage_for_method(command, method_obj)
|
|
155
|
+
exit(0)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
result = Rubycli.call_target(method_obj, pos_args, kw_args)
|
|
160
|
+
@result_emitter.emit(result)
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
handle_execution_error(e, command, method_obj, pos_args, kw_args, cli_mode)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def should_show_method_help?(pos_args, kw_args)
|
|
167
|
+
(kw_args.key?(:help) && kw_args.delete(:help)) || pos_args.include?('help')
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def help_requested_for_parameterless?(args)
|
|
171
|
+
return false if args.nil? || args.empty?
|
|
172
|
+
|
|
173
|
+
args.all? { |arg| help_argument?(arg) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def help_argument?(arg)
|
|
177
|
+
%w[help --help -h].include?(arg)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def handle_execution_error(error, command, method_obj, pos_args, kw_args, cli_mode)
|
|
181
|
+
if cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
|
|
182
|
+
puts "Error: #{error.message}"
|
|
183
|
+
puts usage_for_method(command, method_obj)
|
|
184
|
+
exit(1)
|
|
185
|
+
else
|
|
186
|
+
raise error
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def usage_error?(error)
|
|
191
|
+
msg = error.message
|
|
192
|
+
msg.include?('wrong number of arguments') ||
|
|
193
|
+
msg.include?('missing keyword') ||
|
|
194
|
+
msg.include?('unknown keyword') ||
|
|
195
|
+
msg.include?('no implicit conversion')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def arguments_match?(method, pos_args, kw_args)
|
|
199
|
+
return false unless method
|
|
200
|
+
|
|
201
|
+
params = method.parameters
|
|
202
|
+
positional_match = check_positional_arguments(params, pos_args)
|
|
203
|
+
keyword_match = check_keyword_arguments(params, kw_args)
|
|
204
|
+
|
|
205
|
+
positional_match && keyword_match
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def check_positional_arguments(params, pos_args)
|
|
209
|
+
req_pos = params.count { |type, _| type == :req }
|
|
210
|
+
opt_pos = params.count { |type, _| type == :opt }
|
|
211
|
+
has_rest = params.any? { |type, _| type == :rest }
|
|
212
|
+
|
|
213
|
+
if has_rest
|
|
214
|
+
pos_args.size >= req_pos
|
|
215
|
+
else
|
|
216
|
+
(req_pos..(req_pos + opt_pos)).cover?(pos_args.size)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def check_keyword_arguments(params, kw_args)
|
|
221
|
+
known_keys = params.select { |t, _| %i[key keyreq keyrest].include?(t) }.map { |_, n| n }
|
|
222
|
+
unknown_keys = kw_args.keys - known_keys
|
|
223
|
+
req_kw = params.select { |type, _| type == :keyreq }.map { |_, name| name }
|
|
224
|
+
has_keyrest = params.any? { |type, _| type == :keyrest }
|
|
225
|
+
|
|
226
|
+
required_keywords_present = req_kw.all? { |k| kw_args.key?(k) }
|
|
227
|
+
no_unknown_keywords = has_keyrest || unknown_keys.empty?
|
|
228
|
+
|
|
229
|
+
required_keywords_present && no_unknown_keywords
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def command_catalog(target)
|
|
233
|
+
if target.is_a?(Module) || target.is_a?(Class)
|
|
234
|
+
entries = collect_singleton_entries(target)
|
|
235
|
+
alias_map = build_alias_map(entries)
|
|
236
|
+
CommandCatalog.new(entries: entries, alias_map: alias_map, duplicates: Set.new)
|
|
237
|
+
else
|
|
238
|
+
build_instance_catalog(target)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def collect_singleton_entries(target)
|
|
243
|
+
method_names = target.singleton_class.public_instance_methods(false)
|
|
244
|
+
method_names.each_with_object([]) do |name, memo|
|
|
245
|
+
method_obj = safe_method_lookup(target, name)
|
|
246
|
+
next unless exposable_method?(method_obj)
|
|
247
|
+
|
|
248
|
+
memo << CommandEntry.new(command: name.to_s, method: method_obj, category: :class, aliases: [])
|
|
249
|
+
end.sort_by { |entry| entry.command }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_instance_catalog(target)
|
|
253
|
+
instance_methods = collect_instance_methods(target)
|
|
254
|
+
class_methods = collect_class_methods(target)
|
|
255
|
+
|
|
256
|
+
duplicate_names = instance_methods.keys & class_methods.keys
|
|
257
|
+
entries = []
|
|
258
|
+
|
|
259
|
+
instance_methods.keys.sort.each do |name|
|
|
260
|
+
method_obj = instance_methods[name]
|
|
261
|
+
aliases = duplicate_names.include?(name) ? ["instance::#{name}"] : []
|
|
262
|
+
entries << CommandEntry.new(command: name, method: method_obj, category: :instance, aliases: aliases)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
class_methods.keys.sort.each do |name|
|
|
266
|
+
method_obj = class_methods[name]
|
|
267
|
+
entries << CommandEntry.new(command: "class::#{name}", method: method_obj, category: :class, aliases: [])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
alias_map = build_alias_map(entries)
|
|
271
|
+
CommandCatalog.new(entries: entries, alias_map: alias_map, duplicates: duplicate_names.sort)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def collect_instance_methods(target)
|
|
275
|
+
collect_method_map(target.public_methods(false)) { |name| safe_method_lookup(target, name) }
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def collect_class_methods(target)
|
|
279
|
+
klass = target.class
|
|
280
|
+
collect_method_map(klass.singleton_class.public_instance_methods(false)) { |name| safe_method_lookup(klass, name) }
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def collect_method_map(method_names)
|
|
284
|
+
method_names.each_with_object({}) do |name, memo|
|
|
285
|
+
method_obj = yield(name)
|
|
286
|
+
next unless exposable_method?(method_obj)
|
|
287
|
+
|
|
288
|
+
memo[name.to_s] = method_obj
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def safe_method_lookup(target, name)
|
|
293
|
+
target.method(name)
|
|
294
|
+
rescue NameError
|
|
295
|
+
nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def exposable_method?(method_obj)
|
|
299
|
+
return false unless method_obj&.source_location
|
|
300
|
+
return false if accessor_generated_method?(method_obj)
|
|
301
|
+
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def build_alias_map(entries)
|
|
306
|
+
entries.each_with_object({}) do |entry, memo|
|
|
307
|
+
entry.all_commands.each do |command|
|
|
308
|
+
memo[command] = entry
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def normalize_command(command)
|
|
314
|
+
return command unless command
|
|
315
|
+
|
|
316
|
+
if command.start_with?('class::')
|
|
317
|
+
base = command.split('class::', 2).last
|
|
318
|
+
"class::#{base.tr('-', '_')}"
|
|
319
|
+
elsif command.start_with?('instance::')
|
|
320
|
+
base = command.split('instance::', 2).last
|
|
321
|
+
"instance::#{base.tr('-', '_')}"
|
|
322
|
+
else
|
|
323
|
+
command.tr('-', '_')
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def accessor_generated_method?(method_obj)
|
|
328
|
+
location = method_obj.source_location
|
|
329
|
+
return false unless location
|
|
330
|
+
|
|
331
|
+
file, line = location
|
|
332
|
+
lines = (@file_cache[file] ||= File.readlines(file, chomp: true))
|
|
333
|
+
line_content = lines[line - 1]
|
|
334
|
+
return false unless line_content
|
|
335
|
+
|
|
336
|
+
line_content.match?(/\battr_(reader|writer|accessor)\b/)
|
|
337
|
+
rescue Errno::ENOENT
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|