rubycli 0.1.6 → 0.2.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 +4 -4
- data/CHANGELOG.md +88 -0
- data/LICENSE +1 -1
- data/README.ja.md +357 -235
- data/README.md +386 -233
- data/examples/documentation_style_showcase.rb +110 -0
- data/examples/fallback_example.rb +14 -0
- data/examples/fallback_example_with_extra_docs.rb +13 -0
- data/examples/hello_app.rb +10 -0
- data/examples/hello_app_with_docs.rb +17 -0
- data/examples/hello_app_with_require.rb +19 -0
- data/examples/mismatched_constant_runner.rb +16 -0
- data/examples/multi_constant_runner.rb +20 -0
- data/examples/new_mode_runner.rb +46 -0
- data/examples/strict_choices_demo.rb +22 -0
- data/examples/typed_arguments_demo.rb +42 -0
- data/lib/rubycli/argument_mode_controller.rb +7 -11
- data/lib/rubycli/argument_parser.rb +338 -80
- data/lib/rubycli/cli.rb +26 -30
- data/lib/rubycli/command_line.rb +159 -72
- data/lib/rubycli/constant_capture.rb +563 -6
- data/lib/rubycli/documentation/metadata_parser.rb +114 -74
- data/lib/rubycli/documentation_registry.rb +1 -0
- data/lib/rubycli/environment.rb +17 -9
- data/lib/rubycli/eval_coercer.rb +27 -10
- data/lib/rubycli/help_renderer.rb +67 -62
- data/lib/rubycli/json_coercer.rb +2 -0
- data/lib/rubycli/result_emitter.rb +18 -8
- data/lib/rubycli/runner.rb +513 -0
- data/lib/rubycli/type_utils.rb +8 -6
- data/lib/rubycli/types.rb +2 -0
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +7 -400
- metadata +59 -4
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'did_you_mean'
|
|
4
|
+
require 'ripper'
|
|
2
5
|
|
|
3
6
|
require_relative 'type_utils'
|
|
4
7
|
require_relative 'arguments/token_stream'
|
|
@@ -8,6 +11,8 @@ module Rubycli
|
|
|
8
11
|
class ArgumentParser
|
|
9
12
|
include TypeUtils
|
|
10
13
|
|
|
14
|
+
OPTION_TOKEN_PATTERN = /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
|
|
15
|
+
|
|
11
16
|
def initialize(environment:, documentation_registry:, json_coercer:, debug_logger:)
|
|
12
17
|
@environment = environment
|
|
13
18
|
@documentation_registry = documentation_registry
|
|
@@ -18,9 +23,11 @@ module Rubycli
|
|
|
18
23
|
|
|
19
24
|
def parse(args, method = nil)
|
|
20
25
|
pos_args = []
|
|
26
|
+
raw_pos_args = []
|
|
21
27
|
kw_args = {}
|
|
22
28
|
|
|
23
29
|
kw_param_names = extract_keyword_parameter_names(method)
|
|
30
|
+
required_kw_param_names = extract_required_keyword_parameter_names(method)
|
|
24
31
|
debug_log "Available keyword parameters: #{kw_param_names.inspect}"
|
|
25
32
|
|
|
26
33
|
metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil }
|
|
@@ -28,6 +35,9 @@ module Rubycli
|
|
|
28
35
|
cli_aliases = build_cli_alias_map(option_defs)
|
|
29
36
|
option_lookup = build_option_lookup(option_defs)
|
|
30
37
|
type_converters = build_type_converter_map(option_defs)
|
|
38
|
+
known_option_names = (
|
|
39
|
+
kw_param_names + cli_aliases.keys + option_lookup.keys.map(&:to_s)
|
|
40
|
+
).uniq
|
|
31
41
|
|
|
32
42
|
stream = Arguments::TokenStream.new(args)
|
|
33
43
|
|
|
@@ -36,8 +46,9 @@ module Rubycli
|
|
|
36
46
|
|
|
37
47
|
if token == '--'
|
|
38
48
|
stream.advance
|
|
39
|
-
rest_tokens = stream.consume_remaining
|
|
40
|
-
|
|
49
|
+
rest_tokens = stream.consume_remaining
|
|
50
|
+
raw_pos_args.concat(rest_tokens)
|
|
51
|
+
pos_args.concat(rest_tokens.map { |value| convert_arg(value) })
|
|
41
52
|
break
|
|
42
53
|
elsif option_token?(token)
|
|
43
54
|
stream.advance
|
|
@@ -48,17 +59,21 @@ module Rubycli
|
|
|
48
59
|
kw_args,
|
|
49
60
|
cli_aliases,
|
|
50
61
|
option_lookup,
|
|
51
|
-
type_converters
|
|
62
|
+
type_converters,
|
|
63
|
+
required_kw_param_names,
|
|
64
|
+
known_option_names
|
|
52
65
|
)
|
|
53
|
-
elsif
|
|
66
|
+
elsif assignment_token_for_method?(token, method, kw_param_names)
|
|
54
67
|
stream.advance
|
|
55
|
-
process_assignment_token(token, kw_args)
|
|
68
|
+
process_assignment_token(token, kw_args, option_lookup, type_converters)
|
|
56
69
|
else
|
|
70
|
+
raw_pos_args << token
|
|
57
71
|
pos_args << convert_arg(token)
|
|
58
72
|
stream.advance
|
|
59
73
|
end
|
|
60
74
|
end
|
|
61
75
|
|
|
76
|
+
pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
|
|
62
77
|
debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
63
78
|
[pos_args, kw_args]
|
|
64
79
|
end
|
|
@@ -87,12 +102,27 @@ module Rubycli
|
|
|
87
102
|
.map { |_, name| name.to_s }
|
|
88
103
|
end
|
|
89
104
|
|
|
105
|
+
def extract_required_keyword_parameter_names(method)
|
|
106
|
+
return [] unless method
|
|
107
|
+
|
|
108
|
+
method.parameters
|
|
109
|
+
.select { |type, _| type == :keyreq }
|
|
110
|
+
.map { |_, name| name.to_s }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Tokens such as -5, -1_000 and -0x10 match the option pattern, but no Ruby
|
|
114
|
+
# keyword can be named after a number, so they are values, not options.
|
|
90
115
|
def option_token?(token)
|
|
91
|
-
token
|
|
116
|
+
token.match?(OPTION_TOKEN_PATTERN) && looks_like_option?(token)
|
|
92
117
|
end
|
|
93
118
|
|
|
94
|
-
def
|
|
95
|
-
|
|
119
|
+
def assignment_token_for_method?(token, method, kw_param_names)
|
|
120
|
+
key, = split_assignment_token(token)
|
|
121
|
+
return false unless key
|
|
122
|
+
return true unless method
|
|
123
|
+
return true if kw_param_names.include?(key)
|
|
124
|
+
|
|
125
|
+
method.parameters.any? { |type, _| type == :keyrest }
|
|
96
126
|
end
|
|
97
127
|
|
|
98
128
|
def process_option_token(
|
|
@@ -102,9 +132,11 @@ module Rubycli
|
|
|
102
132
|
kw_args,
|
|
103
133
|
cli_aliases,
|
|
104
134
|
option_lookup,
|
|
105
|
-
type_converters
|
|
135
|
+
type_converters,
|
|
136
|
+
required_kw_param_names,
|
|
137
|
+
known_option_names
|
|
106
138
|
)
|
|
107
|
-
token =~
|
|
139
|
+
token =~ OPTION_TOKEN_PATTERN
|
|
108
140
|
cli_key = Regexp.last_match(1).tr('-', '_')
|
|
109
141
|
embedded_value = Regexp.last_match(2)
|
|
110
142
|
|
|
@@ -116,7 +148,11 @@ module Rubycli
|
|
|
116
148
|
final_key_sym = final_key.to_sym
|
|
117
149
|
|
|
118
150
|
option_meta = option_lookup[final_key_sym]
|
|
119
|
-
requires_value =
|
|
151
|
+
requires_value = if option_meta
|
|
152
|
+
option_meta[:requires_value]
|
|
153
|
+
else
|
|
154
|
+
required_kw_param_names.include?(final_key)
|
|
155
|
+
end
|
|
120
156
|
option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
|
|
121
157
|
|
|
122
158
|
value_capture = if embedded_value
|
|
@@ -125,17 +161,18 @@ module Rubycli
|
|
|
125
161
|
capture_option_value(
|
|
126
162
|
option_meta,
|
|
127
163
|
stream,
|
|
128
|
-
requires_value
|
|
164
|
+
requires_value,
|
|
165
|
+
known_option_names
|
|
129
166
|
)
|
|
167
|
+
elsif requires_value
|
|
168
|
+
capture_required_option_value(option_label, stream, known_option_names)
|
|
130
169
|
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
131
170
|
stream.consume
|
|
132
171
|
else
|
|
133
172
|
'true'
|
|
134
173
|
end
|
|
135
174
|
|
|
136
|
-
if requires_value &&
|
|
137
|
-
raise ArgumentError, "Option '#{option_label}' requires a value"
|
|
138
|
-
end
|
|
175
|
+
raise ArgumentError, "Option '#{option_label}' requires a value" if requires_value && value_capture.nil?
|
|
139
176
|
|
|
140
177
|
converted_value = convert_option_value(
|
|
141
178
|
final_key_sym,
|
|
@@ -146,34 +183,62 @@ module Rubycli
|
|
|
146
183
|
|
|
147
184
|
kw_args[final_key_sym] = converted_value
|
|
148
185
|
end
|
|
149
|
-
|
|
186
|
+
|
|
187
|
+
def capture_required_option_value(option_label, stream, known_option_names)
|
|
188
|
+
next_token = stream.current
|
|
189
|
+
option_like_value = looks_like_option?(next_token) &&
|
|
190
|
+
!valid_eval_option_value?(next_token, known_option_names)
|
|
191
|
+
raise ArgumentError, "Option '#{option_label}' requires a value" if next_token.nil? || option_like_value
|
|
192
|
+
|
|
193
|
+
stream.consume
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def capture_option_value(option_meta, stream, requires_value, known_option_names)
|
|
150
197
|
if option_meta[:boolean_flag]
|
|
151
198
|
if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
|
|
152
199
|
return stream.consume
|
|
153
200
|
end
|
|
154
|
-
|
|
201
|
+
|
|
202
|
+
'true'
|
|
155
203
|
elsif option_meta[:optional_value]
|
|
156
204
|
if (next_token = stream.current) && !looks_like_option?(next_token)
|
|
157
205
|
return stream.consume
|
|
158
206
|
end
|
|
159
|
-
|
|
207
|
+
|
|
208
|
+
true
|
|
160
209
|
elsif requires_value == false
|
|
161
|
-
|
|
210
|
+
'true'
|
|
162
211
|
elsif requires_value
|
|
163
|
-
|
|
164
|
-
raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
|
|
165
|
-
|
|
166
|
-
return stream.consume
|
|
212
|
+
capture_required_option_value(option_meta.long, stream, known_option_names)
|
|
167
213
|
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
168
|
-
|
|
214
|
+
stream.consume
|
|
169
215
|
else
|
|
170
|
-
|
|
216
|
+
'true'
|
|
171
217
|
end
|
|
172
218
|
end
|
|
173
219
|
|
|
174
|
-
def
|
|
220
|
+
def valid_eval_option_value?(token, known_option_names)
|
|
221
|
+
return false unless Rubycli.eval_mode?
|
|
222
|
+
return false if known_option_token?(token, known_option_names)
|
|
223
|
+
|
|
224
|
+
!Ripper.sexp(token).nil?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def known_option_token?(token, known_option_names)
|
|
228
|
+
match = token&.match(OPTION_TOKEN_PATTERN)
|
|
229
|
+
return false unless match
|
|
230
|
+
|
|
231
|
+
key = match[1].tr('-', '_')
|
|
232
|
+
return true if known_option_names.include?(key)
|
|
233
|
+
|
|
234
|
+
known_option_names.one? { |name| name.start_with?(key) }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def process_assignment_token(token, kw_args, option_lookup, type_converters)
|
|
175
238
|
key, value = split_assignment_token(token)
|
|
176
|
-
|
|
239
|
+
keyword = key.to_sym
|
|
240
|
+
option_meta = option_lookup[keyword]
|
|
241
|
+
kw_args[keyword] = convert_option_value(keyword, value, option_meta, type_converters)
|
|
177
242
|
end
|
|
178
243
|
|
|
179
244
|
def split_assignment_token(token)
|
|
@@ -186,6 +251,146 @@ module Rubycli
|
|
|
186
251
|
[key, value]
|
|
187
252
|
end
|
|
188
253
|
|
|
254
|
+
def convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
|
|
255
|
+
return pos_args unless method
|
|
256
|
+
return pos_args if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
257
|
+
|
|
258
|
+
positional_map = metadata[:positionals_map] || {}
|
|
259
|
+
return pos_args if positional_map.empty?
|
|
260
|
+
|
|
261
|
+
converted = pos_args.dup
|
|
262
|
+
positional_argument_bindings(method, converted.size).each do |type, name, indexes|
|
|
263
|
+
definition = positional_map[name]
|
|
264
|
+
next unless definition
|
|
265
|
+
|
|
266
|
+
case type
|
|
267
|
+
when :req, :opt
|
|
268
|
+
index = indexes.first
|
|
269
|
+
next unless index
|
|
270
|
+
|
|
271
|
+
converter = converter_for_definition(definition)
|
|
272
|
+
next unless converter
|
|
273
|
+
|
|
274
|
+
begin
|
|
275
|
+
converted[index] = converter.call(raw_pos_args[index])
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
278
|
+
raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}"
|
|
279
|
+
end
|
|
280
|
+
when :rest
|
|
281
|
+
next if indexes.empty?
|
|
282
|
+
|
|
283
|
+
converter = converter_for_rest_definition(definition)
|
|
284
|
+
next unless converter
|
|
285
|
+
|
|
286
|
+
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
287
|
+
indexes.each do |index|
|
|
288
|
+
value = raw_pos_args[index]
|
|
289
|
+
converted[index] = converter.call(value)
|
|
290
|
+
rescue StandardError => e
|
|
291
|
+
raise ArgumentError, "Value '#{value}' for #{label} is invalid: #{e.message}"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
converted
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def positional_argument_bindings(method_obj, argument_count)
|
|
300
|
+
parameters = method_obj.parameters.select { |type, _| %i[req opt rest].include?(type) }
|
|
301
|
+
first_flexible = parameters.index { |type, _| %i[opt rest].include?(type) }
|
|
302
|
+
|
|
303
|
+
unless first_flexible
|
|
304
|
+
return parameters.each_with_index.map do |(type, name), index|
|
|
305
|
+
[type, name, index < argument_count ? [index] : []]
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
last_flexible = parameters.rindex { |type, _| %i[opt rest].include?(type) }
|
|
310
|
+
bindings = []
|
|
311
|
+
cursor = 0
|
|
312
|
+
|
|
313
|
+
parameters[0...first_flexible].each do |type, name|
|
|
314
|
+
indexes = cursor < argument_count ? [cursor] : []
|
|
315
|
+
bindings << [type, name, indexes]
|
|
316
|
+
cursor += 1 unless indexes.empty?
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
trailing = parameters[(last_flexible + 1)..] || []
|
|
320
|
+
trailing_start = [argument_count - trailing.size, cursor].max
|
|
321
|
+
middle_end = [trailing_start, argument_count].min
|
|
322
|
+
|
|
323
|
+
parameters[first_flexible..last_flexible].each do |type, name|
|
|
324
|
+
indexes = if type == :rest
|
|
325
|
+
(cursor...middle_end).to_a
|
|
326
|
+
elsif cursor < middle_end
|
|
327
|
+
[cursor]
|
|
328
|
+
else
|
|
329
|
+
[]
|
|
330
|
+
end
|
|
331
|
+
bindings << [type, name, indexes]
|
|
332
|
+
cursor += indexes.size
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
trailing.each_with_index do |(type, name), offset|
|
|
336
|
+
index = trailing_start + offset
|
|
337
|
+
bindings << [type, name, index < argument_count ? [index] : []]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
bindings
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def converter_for_rest_definition(definition)
|
|
344
|
+
scalar_types = Array(definition.types).compact.map do |type|
|
|
345
|
+
normalized = type.to_s.strip
|
|
346
|
+
array_inner_type(normalized) || normalized
|
|
347
|
+
end
|
|
348
|
+
build_converter_for_types(scalar_types)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def converter_for_definition(definition)
|
|
352
|
+
types = Array(definition.types).compact
|
|
353
|
+
return nil if types.empty?
|
|
354
|
+
|
|
355
|
+
types.each do |type|
|
|
356
|
+
normalized = type.to_s.strip
|
|
357
|
+
next if normalized.empty?
|
|
358
|
+
|
|
359
|
+
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
360
|
+
inner = normalized[6..-2].strip
|
|
361
|
+
element_converter = converter_for_single_type(inner)
|
|
362
|
+
return lambda { |value|
|
|
363
|
+
list_items(value).map do |item|
|
|
364
|
+
if inner == 'String' && item.is_a?(String)
|
|
365
|
+
item
|
|
366
|
+
else
|
|
367
|
+
(element_converter ? element_converter.call(item) : item)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
}
|
|
371
|
+
elsif normalized.end_with?('[]')
|
|
372
|
+
inner = normalized[0..-3]
|
|
373
|
+
element_converter = converter_for_single_type(inner)
|
|
374
|
+
return lambda { |value|
|
|
375
|
+
list_items(value).map do |item|
|
|
376
|
+
if inner == 'String' && item.is_a?(String)
|
|
377
|
+
item
|
|
378
|
+
else
|
|
379
|
+
(element_converter ? element_converter.call(item) : item)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
}
|
|
383
|
+
elsif normalized.casecmp('Array').zero?
|
|
384
|
+
return ->(value) { list_items(value) }
|
|
385
|
+
else
|
|
386
|
+
single = converter_for_single_type(normalized)
|
|
387
|
+
return single if single
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
189
394
|
def resolve_keyword_parameter(cli_key, kw_param_names)
|
|
190
395
|
exact_match = kw_param_names.find { |name| name == cli_key }
|
|
191
396
|
return exact_match if exact_match
|
|
@@ -210,15 +415,24 @@ module Rubycli
|
|
|
210
415
|
return if positional_args.nil? || positional_args.empty?
|
|
211
416
|
|
|
212
417
|
positional_map = metadata[:positionals_map] || {}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
ordered_params.each_with_index do |(_, name), index|
|
|
418
|
+
positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes|
|
|
216
419
|
definition = positional_map[name]
|
|
217
420
|
next unless definition
|
|
218
|
-
next if index >= positional_args.size
|
|
219
421
|
|
|
220
422
|
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
221
|
-
|
|
423
|
+
|
|
424
|
+
case type
|
|
425
|
+
when :req, :opt
|
|
426
|
+
index = indexes.first
|
|
427
|
+
next unless index
|
|
428
|
+
|
|
429
|
+
enforce_value_against_definition(definition, positional_args[index], label)
|
|
430
|
+
when :rest
|
|
431
|
+
next if indexes.empty?
|
|
432
|
+
|
|
433
|
+
values = indexes.map { |index| positional_args[index] }
|
|
434
|
+
enforce_value_against_definition(definition, values, label)
|
|
435
|
+
end
|
|
222
436
|
end
|
|
223
437
|
end
|
|
224
438
|
|
|
@@ -254,10 +468,10 @@ module Rubycli
|
|
|
254
468
|
formatted_value = format_literal_value(entry)
|
|
255
469
|
|
|
256
470
|
message = if description
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
471
|
+
"#{label} must be #{description} (received #{formatted_value})"
|
|
472
|
+
else
|
|
473
|
+
"Value #{formatted_value} for #{label} is not allowed"
|
|
474
|
+
end
|
|
261
475
|
|
|
262
476
|
suggestions = literal_suggestions(definition, entry)
|
|
263
477
|
return message if suggestions.empty?
|
|
@@ -300,7 +514,9 @@ module Rubycli
|
|
|
300
514
|
end
|
|
301
515
|
|
|
302
516
|
def allowed_value_description(definition)
|
|
303
|
-
literal_descriptions = Array(definition.allowed_values).map
|
|
517
|
+
literal_descriptions = Array(definition.allowed_values).map do |entry|
|
|
518
|
+
format_literal_value(entry[:value])
|
|
519
|
+
end.reject(&:empty?)
|
|
304
520
|
type_descriptions = Array(definition.types)
|
|
305
521
|
.map { |token| token.to_s.strip }
|
|
306
522
|
.reject { |token| token.empty? || literal_hint_token?(token) }
|
|
@@ -342,6 +558,7 @@ module Rubycli
|
|
|
342
558
|
|
|
343
559
|
if (inner = array_inner_type(normalized))
|
|
344
560
|
return false unless value.is_a?(Array)
|
|
561
|
+
|
|
345
562
|
return value.all? { |element| matches_type_token?(inner, element) }
|
|
346
563
|
end
|
|
347
564
|
|
|
@@ -365,8 +582,6 @@ module Rubycli
|
|
|
365
582
|
token[0..-3]
|
|
366
583
|
elsif token.start_with?('Array<') && token.end_with?('>')
|
|
367
584
|
token[6..-2].strip
|
|
368
|
-
else
|
|
369
|
-
nil
|
|
370
585
|
end
|
|
371
586
|
end
|
|
372
587
|
|
|
@@ -411,6 +626,17 @@ module Rubycli
|
|
|
411
626
|
safe_constant_lookup(normalized)
|
|
412
627
|
end
|
|
413
628
|
|
|
629
|
+
# Type annotations may name a class from a library that is not installed
|
|
630
|
+
# (bigdecimal, for instance, is no longer a default gem). Report it as a
|
|
631
|
+
# user-facing error instead of letting the LoadError escape with a backtrace.
|
|
632
|
+
def require_optional_library(library, type_token)
|
|
633
|
+
require library
|
|
634
|
+
rescue LoadError
|
|
635
|
+
raise ArgumentError,
|
|
636
|
+
"Type '#{type_token}' needs the #{library} library, which is not installed. " \
|
|
637
|
+
"Install it with `gem install #{library}` (or add it to your Gemfile)."
|
|
638
|
+
end
|
|
639
|
+
|
|
414
640
|
def format_literal_value(value)
|
|
415
641
|
case value
|
|
416
642
|
when Symbol
|
|
@@ -435,7 +661,6 @@ module Rubycli
|
|
|
435
661
|
token.start_with?('%i[', '%I[', '%w[', '%W[')
|
|
436
662
|
end
|
|
437
663
|
|
|
438
|
-
|
|
439
664
|
def build_cli_alias_map(option_defs)
|
|
440
665
|
option_defs.each_with_object({}) do |opt, memo|
|
|
441
666
|
next unless opt.short
|
|
@@ -471,21 +696,17 @@ module Rubycli
|
|
|
471
696
|
lambda do |value|
|
|
472
697
|
return nil if value.nil? && allow_nil
|
|
473
698
|
|
|
474
|
-
if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
|
|
475
|
-
next nil
|
|
476
|
-
end
|
|
699
|
+
next nil if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
|
|
477
700
|
|
|
478
701
|
if converters.empty?
|
|
479
702
|
value
|
|
480
703
|
else
|
|
481
704
|
last_error = nil
|
|
482
705
|
converters.each do |converter|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
last_error = e
|
|
488
|
-
end
|
|
706
|
+
result = converter.call(value)
|
|
707
|
+
return result
|
|
708
|
+
rescue StandardError => e
|
|
709
|
+
last_error = e
|
|
489
710
|
end
|
|
490
711
|
raise last_error || ArgumentError.new("Could not convert value '#{value}'")
|
|
491
712
|
end
|
|
@@ -509,10 +730,13 @@ module Rubycli
|
|
|
509
730
|
when 'Boolean', 'TrueClass', 'FalseClass'
|
|
510
731
|
->(value) { TypeUtils.convert_boolean(value) }
|
|
511
732
|
when 'Symbol'
|
|
512
|
-
|
|
733
|
+
lambda { |value|
|
|
734
|
+
converted_value = convert_arg(value)
|
|
735
|
+
converted_value.is_a?(Symbol) ? converted_value : value.to_sym
|
|
736
|
+
}
|
|
513
737
|
when 'BigDecimal', 'Decimal'
|
|
514
|
-
|
|
515
|
-
|
|
738
|
+
require_optional_library('bigdecimal', normalized)
|
|
739
|
+
lambda { |value|
|
|
516
740
|
return value if value.is_a?(BigDecimal)
|
|
517
741
|
|
|
518
742
|
if value.is_a?(String)
|
|
@@ -522,16 +746,35 @@ module Rubycli
|
|
|
522
746
|
end
|
|
523
747
|
}
|
|
524
748
|
when 'Date'
|
|
525
|
-
|
|
749
|
+
require_optional_library('date', normalized)
|
|
526
750
|
->(value) { Date.parse(value) }
|
|
527
|
-
when 'Time'
|
|
528
|
-
|
|
751
|
+
when 'Time'
|
|
752
|
+
require_optional_library('time', normalized)
|
|
529
753
|
->(value) { Time.parse(value) }
|
|
530
|
-
when '
|
|
531
|
-
|
|
754
|
+
when 'DateTime'
|
|
755
|
+
require_optional_library('date', normalized)
|
|
756
|
+
->(value) { DateTime.parse(value) }
|
|
757
|
+
when 'JSON'
|
|
758
|
+
lambda { |value|
|
|
759
|
+
parsed_value = convert_arg(value)
|
|
760
|
+
parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array)
|
|
761
|
+
unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array)
|
|
762
|
+
raise ::ArgumentError, 'JSON value must be an object or array'
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
parsed_value
|
|
766
|
+
}
|
|
767
|
+
when 'Hash'
|
|
768
|
+
lambda { |value|
|
|
769
|
+
parsed_value = convert_arg(value)
|
|
770
|
+
parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash)
|
|
771
|
+
raise ::ArgumentError, 'Hash value must be an object' unless parsed_value.is_a?(Hash)
|
|
772
|
+
|
|
773
|
+
parsed_value
|
|
774
|
+
}
|
|
532
775
|
when 'Pathname'
|
|
533
|
-
|
|
534
|
-
|
|
776
|
+
require_optional_library('pathname', normalized)
|
|
777
|
+
lambda { |value|
|
|
535
778
|
return value if value.is_a?(Pathname)
|
|
536
779
|
|
|
537
780
|
Pathname.new(value.to_s)
|
|
@@ -540,39 +783,47 @@ module Rubycli
|
|
|
540
783
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
541
784
|
inner = normalized[6..-2].strip
|
|
542
785
|
element_converter = converter_for_single_type(inner)
|
|
543
|
-
|
|
786
|
+
lambda { |value|
|
|
787
|
+
list_items(value).map do |item|
|
|
788
|
+
if inner == 'String' && item.is_a?(String)
|
|
789
|
+
item
|
|
790
|
+
else
|
|
791
|
+
(element_converter ? element_converter.call(item) : item)
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
}
|
|
544
795
|
elsif normalized.end_with?('[]')
|
|
545
796
|
inner = normalized[0..-3]
|
|
546
797
|
element_converter = converter_for_single_type(inner)
|
|
547
|
-
|
|
798
|
+
lambda { |value|
|
|
799
|
+
list_items(value).map do |item|
|
|
800
|
+
if inner == 'String' && item.is_a?(String)
|
|
801
|
+
item
|
|
802
|
+
else
|
|
803
|
+
(element_converter ? element_converter.call(item) : item)
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
}
|
|
548
807
|
elsif normalized == 'Array'
|
|
549
|
-
->(value) {
|
|
550
|
-
else
|
|
551
|
-
nil
|
|
808
|
+
->(value) { list_items(value) }
|
|
552
809
|
end
|
|
553
810
|
end
|
|
554
811
|
end
|
|
555
812
|
|
|
813
|
+
def list_items(value)
|
|
814
|
+
converted_value = convert_arg(value)
|
|
815
|
+
source = converted_value.is_a?(Array) ? converted_value : value
|
|
816
|
+
TypeUtils.parse_list(source)
|
|
817
|
+
end
|
|
818
|
+
|
|
556
819
|
def convert_option_value(keyword, value, option_meta, type_converters)
|
|
557
|
-
if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
558
|
-
return convert_arg(value)
|
|
559
|
-
end
|
|
820
|
+
return convert_arg(value) if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
560
821
|
|
|
561
822
|
converter = type_converters[keyword]
|
|
562
823
|
converted_value = convert_arg(value)
|
|
563
824
|
return converted_value unless converter
|
|
564
825
|
|
|
565
|
-
|
|
566
|
-
expects_list = option_meta && option_meta.types.any? { |type|
|
|
567
|
-
type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
value_for_converter = converted_value
|
|
571
|
-
if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
|
|
572
|
-
value_for_converter = original_input
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
converter.call(value_for_converter)
|
|
826
|
+
converter.call(value)
|
|
576
827
|
rescue StandardError => e
|
|
577
828
|
option_label = option_meta&.long || option_meta&.short || keyword
|
|
578
829
|
raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
|
|
@@ -580,9 +831,16 @@ module Rubycli
|
|
|
580
831
|
|
|
581
832
|
def looks_like_option?(token)
|
|
582
833
|
return false unless token
|
|
583
|
-
return false if
|
|
834
|
+
return false if ['--', '-'].include?(token)
|
|
584
835
|
|
|
585
|
-
|
|
836
|
+
decimal_digits = '\d(?:_?\d)*'
|
|
837
|
+
negative_decimal = token.match?(
|
|
838
|
+
/\A-(?:#{decimal_digits}(?:\.(?:#{decimal_digits})?)?|\.#{decimal_digits})(?:[eE][+-]?#{decimal_digits})?\z/
|
|
839
|
+
)
|
|
840
|
+
negative_radix = token.match?(
|
|
841
|
+
/\A-0(?:[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+|[dD][0-9_]+)\z/
|
|
842
|
+
)
|
|
843
|
+
token.start_with?('-') && !negative_decimal && !negative_radix
|
|
586
844
|
end
|
|
587
845
|
end
|
|
588
846
|
end
|