rubycli 0.1.7 → 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 +77 -2
- data/LICENSE +1 -1
- data/README.ja.md +357 -252
- data/README.md +386 -250
- 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 +295 -96
- data/lib/rubycli/cli.rb +26 -30
- data/lib/rubycli/command_line.rb +151 -86
- data/lib/rubycli/constant_capture.rb +563 -6
- data/lib/rubycli/documentation/metadata_parser.rb +108 -74
- 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 -456
- 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,18 +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
|
|
|
62
|
-
pos_args = convert_positional_arguments(pos_args, method, metadata)
|
|
76
|
+
pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
|
|
63
77
|
debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
64
78
|
[pos_args, kw_args]
|
|
65
79
|
end
|
|
@@ -88,12 +102,27 @@ module Rubycli
|
|
|
88
102
|
.map { |_, name| name.to_s }
|
|
89
103
|
end
|
|
90
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.
|
|
91
115
|
def option_token?(token)
|
|
92
|
-
token
|
|
116
|
+
token.match?(OPTION_TOKEN_PATTERN) && looks_like_option?(token)
|
|
93
117
|
end
|
|
94
118
|
|
|
95
|
-
def
|
|
96
|
-
|
|
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 }
|
|
97
126
|
end
|
|
98
127
|
|
|
99
128
|
def process_option_token(
|
|
@@ -103,9 +132,11 @@ module Rubycli
|
|
|
103
132
|
kw_args,
|
|
104
133
|
cli_aliases,
|
|
105
134
|
option_lookup,
|
|
106
|
-
type_converters
|
|
135
|
+
type_converters,
|
|
136
|
+
required_kw_param_names,
|
|
137
|
+
known_option_names
|
|
107
138
|
)
|
|
108
|
-
token =~
|
|
139
|
+
token =~ OPTION_TOKEN_PATTERN
|
|
109
140
|
cli_key = Regexp.last_match(1).tr('-', '_')
|
|
110
141
|
embedded_value = Regexp.last_match(2)
|
|
111
142
|
|
|
@@ -117,7 +148,11 @@ module Rubycli
|
|
|
117
148
|
final_key_sym = final_key.to_sym
|
|
118
149
|
|
|
119
150
|
option_meta = option_lookup[final_key_sym]
|
|
120
|
-
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
|
|
121
156
|
option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
|
|
122
157
|
|
|
123
158
|
value_capture = if embedded_value
|
|
@@ -126,17 +161,18 @@ module Rubycli
|
|
|
126
161
|
capture_option_value(
|
|
127
162
|
option_meta,
|
|
128
163
|
stream,
|
|
129
|
-
requires_value
|
|
164
|
+
requires_value,
|
|
165
|
+
known_option_names
|
|
130
166
|
)
|
|
167
|
+
elsif requires_value
|
|
168
|
+
capture_required_option_value(option_label, stream, known_option_names)
|
|
131
169
|
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
132
170
|
stream.consume
|
|
133
171
|
else
|
|
134
172
|
'true'
|
|
135
173
|
end
|
|
136
174
|
|
|
137
|
-
if requires_value &&
|
|
138
|
-
raise ArgumentError, "Option '#{option_label}' requires a value"
|
|
139
|
-
end
|
|
175
|
+
raise ArgumentError, "Option '#{option_label}' requires a value" if requires_value && value_capture.nil?
|
|
140
176
|
|
|
141
177
|
converted_value = convert_option_value(
|
|
142
178
|
final_key_sym,
|
|
@@ -147,34 +183,62 @@ module Rubycli
|
|
|
147
183
|
|
|
148
184
|
kw_args[final_key_sym] = converted_value
|
|
149
185
|
end
|
|
150
|
-
|
|
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)
|
|
151
197
|
if option_meta[:boolean_flag]
|
|
152
198
|
if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
|
|
153
199
|
return stream.consume
|
|
154
200
|
end
|
|
155
|
-
|
|
201
|
+
|
|
202
|
+
'true'
|
|
156
203
|
elsif option_meta[:optional_value]
|
|
157
204
|
if (next_token = stream.current) && !looks_like_option?(next_token)
|
|
158
205
|
return stream.consume
|
|
159
206
|
end
|
|
160
|
-
|
|
207
|
+
|
|
208
|
+
true
|
|
161
209
|
elsif requires_value == false
|
|
162
|
-
|
|
210
|
+
'true'
|
|
163
211
|
elsif requires_value
|
|
164
|
-
|
|
165
|
-
raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
|
|
166
|
-
|
|
167
|
-
return stream.consume
|
|
212
|
+
capture_required_option_value(option_meta.long, stream, known_option_names)
|
|
168
213
|
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
169
|
-
|
|
214
|
+
stream.consume
|
|
170
215
|
else
|
|
171
|
-
|
|
216
|
+
'true'
|
|
172
217
|
end
|
|
173
218
|
end
|
|
174
219
|
|
|
175
|
-
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)
|
|
176
238
|
key, value = split_assignment_token(token)
|
|
177
|
-
|
|
239
|
+
keyword = key.to_sym
|
|
240
|
+
option_meta = option_lookup[keyword]
|
|
241
|
+
kw_args[keyword] = convert_option_value(keyword, value, option_meta, type_converters)
|
|
178
242
|
end
|
|
179
243
|
|
|
180
244
|
def split_assignment_token(token)
|
|
@@ -187,33 +251,103 @@ module Rubycli
|
|
|
187
251
|
[key, value]
|
|
188
252
|
end
|
|
189
253
|
|
|
190
|
-
def convert_positional_arguments(pos_args, method, metadata)
|
|
254
|
+
def convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
|
|
191
255
|
return pos_args unless method
|
|
256
|
+
return pos_args if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
192
257
|
|
|
193
258
|
positional_map = metadata[:positionals_map] || {}
|
|
194
259
|
return pos_args if positional_map.empty?
|
|
195
260
|
|
|
196
261
|
converted = pos_args.dup
|
|
197
|
-
method.
|
|
198
|
-
next unless %i[req opt].include?(type)
|
|
262
|
+
positional_argument_bindings(method, converted.size).each do |type, name, indexes|
|
|
199
263
|
definition = positional_map[name]
|
|
200
264
|
next unless definition
|
|
201
|
-
next if index >= converted.size
|
|
202
265
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
205
285
|
|
|
206
|
-
begin
|
|
207
|
-
converted[index] = converter.call(converted[index])
|
|
208
|
-
rescue StandardError => e
|
|
209
286
|
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
210
|
-
|
|
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
|
|
211
293
|
end
|
|
212
294
|
end
|
|
213
295
|
|
|
214
296
|
converted
|
|
215
297
|
end
|
|
216
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
|
+
|
|
217
351
|
def converter_for_definition(definition)
|
|
218
352
|
types = Array(definition.types).compact
|
|
219
353
|
return nil if types.empty?
|
|
@@ -225,13 +359,29 @@ module Rubycli
|
|
|
225
359
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
226
360
|
inner = normalized[6..-2].strip
|
|
227
361
|
element_converter = converter_for_single_type(inner)
|
|
228
|
-
return
|
|
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
|
+
}
|
|
229
371
|
elsif normalized.end_with?('[]')
|
|
230
372
|
inner = normalized[0..-3]
|
|
231
373
|
element_converter = converter_for_single_type(inner)
|
|
232
|
-
return
|
|
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
|
+
}
|
|
233
383
|
elsif normalized.casecmp('Array').zero?
|
|
234
|
-
return ->(value) {
|
|
384
|
+
return ->(value) { list_items(value) }
|
|
235
385
|
else
|
|
236
386
|
single = converter_for_single_type(normalized)
|
|
237
387
|
return single if single
|
|
@@ -265,15 +415,24 @@ module Rubycli
|
|
|
265
415
|
return if positional_args.nil? || positional_args.empty?
|
|
266
416
|
|
|
267
417
|
positional_map = metadata[:positionals_map] || {}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
ordered_params.each_with_index do |(_, name), index|
|
|
418
|
+
positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes|
|
|
271
419
|
definition = positional_map[name]
|
|
272
420
|
next unless definition
|
|
273
|
-
next if index >= positional_args.size
|
|
274
421
|
|
|
275
422
|
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
276
|
-
|
|
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
|
|
277
436
|
end
|
|
278
437
|
end
|
|
279
438
|
|
|
@@ -309,10 +468,10 @@ module Rubycli
|
|
|
309
468
|
formatted_value = format_literal_value(entry)
|
|
310
469
|
|
|
311
470
|
message = if description
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
471
|
+
"#{label} must be #{description} (received #{formatted_value})"
|
|
472
|
+
else
|
|
473
|
+
"Value #{formatted_value} for #{label} is not allowed"
|
|
474
|
+
end
|
|
316
475
|
|
|
317
476
|
suggestions = literal_suggestions(definition, entry)
|
|
318
477
|
return message if suggestions.empty?
|
|
@@ -355,7 +514,9 @@ module Rubycli
|
|
|
355
514
|
end
|
|
356
515
|
|
|
357
516
|
def allowed_value_description(definition)
|
|
358
|
-
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?)
|
|
359
520
|
type_descriptions = Array(definition.types)
|
|
360
521
|
.map { |token| token.to_s.strip }
|
|
361
522
|
.reject { |token| token.empty? || literal_hint_token?(token) }
|
|
@@ -397,6 +558,7 @@ module Rubycli
|
|
|
397
558
|
|
|
398
559
|
if (inner = array_inner_type(normalized))
|
|
399
560
|
return false unless value.is_a?(Array)
|
|
561
|
+
|
|
400
562
|
return value.all? { |element| matches_type_token?(inner, element) }
|
|
401
563
|
end
|
|
402
564
|
|
|
@@ -420,8 +582,6 @@ module Rubycli
|
|
|
420
582
|
token[0..-3]
|
|
421
583
|
elsif token.start_with?('Array<') && token.end_with?('>')
|
|
422
584
|
token[6..-2].strip
|
|
423
|
-
else
|
|
424
|
-
nil
|
|
425
585
|
end
|
|
426
586
|
end
|
|
427
587
|
|
|
@@ -466,6 +626,17 @@ module Rubycli
|
|
|
466
626
|
safe_constant_lookup(normalized)
|
|
467
627
|
end
|
|
468
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
|
+
|
|
469
640
|
def format_literal_value(value)
|
|
470
641
|
case value
|
|
471
642
|
when Symbol
|
|
@@ -490,7 +661,6 @@ module Rubycli
|
|
|
490
661
|
token.start_with?('%i[', '%I[', '%w[', '%W[')
|
|
491
662
|
end
|
|
492
663
|
|
|
493
|
-
|
|
494
664
|
def build_cli_alias_map(option_defs)
|
|
495
665
|
option_defs.each_with_object({}) do |opt, memo|
|
|
496
666
|
next unless opt.short
|
|
@@ -526,21 +696,17 @@ module Rubycli
|
|
|
526
696
|
lambda do |value|
|
|
527
697
|
return nil if value.nil? && allow_nil
|
|
528
698
|
|
|
529
|
-
if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
|
|
530
|
-
next nil
|
|
531
|
-
end
|
|
699
|
+
next nil if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
|
|
532
700
|
|
|
533
701
|
if converters.empty?
|
|
534
702
|
value
|
|
535
703
|
else
|
|
536
704
|
last_error = nil
|
|
537
705
|
converters.each do |converter|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
last_error = e
|
|
543
|
-
end
|
|
706
|
+
result = converter.call(value)
|
|
707
|
+
return result
|
|
708
|
+
rescue StandardError => e
|
|
709
|
+
last_error = e
|
|
544
710
|
end
|
|
545
711
|
raise last_error || ArgumentError.new("Could not convert value '#{value}'")
|
|
546
712
|
end
|
|
@@ -564,10 +730,13 @@ module Rubycli
|
|
|
564
730
|
when 'Boolean', 'TrueClass', 'FalseClass'
|
|
565
731
|
->(value) { TypeUtils.convert_boolean(value) }
|
|
566
732
|
when 'Symbol'
|
|
567
|
-
|
|
733
|
+
lambda { |value|
|
|
734
|
+
converted_value = convert_arg(value)
|
|
735
|
+
converted_value.is_a?(Symbol) ? converted_value : value.to_sym
|
|
736
|
+
}
|
|
568
737
|
when 'BigDecimal', 'Decimal'
|
|
569
|
-
|
|
570
|
-
|
|
738
|
+
require_optional_library('bigdecimal', normalized)
|
|
739
|
+
lambda { |value|
|
|
571
740
|
return value if value.is_a?(BigDecimal)
|
|
572
741
|
|
|
573
742
|
if value.is_a?(String)
|
|
@@ -577,20 +746,35 @@ module Rubycli
|
|
|
577
746
|
end
|
|
578
747
|
}
|
|
579
748
|
when 'Date'
|
|
580
|
-
|
|
749
|
+
require_optional_library('date', normalized)
|
|
581
750
|
->(value) { Date.parse(value) }
|
|
582
|
-
when 'Time'
|
|
583
|
-
|
|
751
|
+
when 'Time'
|
|
752
|
+
require_optional_library('time', normalized)
|
|
584
753
|
->(value) { Time.parse(value) }
|
|
585
|
-
when '
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
588
764
|
|
|
589
|
-
|
|
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
|
|
590
774
|
}
|
|
591
775
|
when 'Pathname'
|
|
592
|
-
|
|
593
|
-
|
|
776
|
+
require_optional_library('pathname', normalized)
|
|
777
|
+
lambda { |value|
|
|
594
778
|
return value if value.is_a?(Pathname)
|
|
595
779
|
|
|
596
780
|
Pathname.new(value.to_s)
|
|
@@ -599,39 +783,47 @@ module Rubycli
|
|
|
599
783
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
600
784
|
inner = normalized[6..-2].strip
|
|
601
785
|
element_converter = converter_for_single_type(inner)
|
|
602
|
-
|
|
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
|
+
}
|
|
603
795
|
elsif normalized.end_with?('[]')
|
|
604
796
|
inner = normalized[0..-3]
|
|
605
797
|
element_converter = converter_for_single_type(inner)
|
|
606
|
-
|
|
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
|
+
}
|
|
607
807
|
elsif normalized == 'Array'
|
|
608
|
-
->(value) {
|
|
609
|
-
else
|
|
610
|
-
nil
|
|
808
|
+
->(value) { list_items(value) }
|
|
611
809
|
end
|
|
612
810
|
end
|
|
613
811
|
end
|
|
614
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
|
+
|
|
615
819
|
def convert_option_value(keyword, value, option_meta, type_converters)
|
|
616
|
-
if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
617
|
-
return convert_arg(value)
|
|
618
|
-
end
|
|
820
|
+
return convert_arg(value) if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
619
821
|
|
|
620
822
|
converter = type_converters[keyword]
|
|
621
823
|
converted_value = convert_arg(value)
|
|
622
824
|
return converted_value unless converter
|
|
623
825
|
|
|
624
|
-
|
|
625
|
-
expects_list = option_meta && option_meta.types.any? { |type|
|
|
626
|
-
type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
value_for_converter = converted_value
|
|
630
|
-
if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
|
|
631
|
-
value_for_converter = original_input
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
converter.call(value_for_converter)
|
|
826
|
+
converter.call(value)
|
|
635
827
|
rescue StandardError => e
|
|
636
828
|
option_label = option_meta&.long || option_meta&.short || keyword
|
|
637
829
|
raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
|
|
@@ -639,9 +831,16 @@ module Rubycli
|
|
|
639
831
|
|
|
640
832
|
def looks_like_option?(token)
|
|
641
833
|
return false unless token
|
|
642
|
-
return false if
|
|
834
|
+
return false if ['--', '-'].include?(token)
|
|
643
835
|
|
|
644
|
-
|
|
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
|
|
645
844
|
end
|
|
646
845
|
end
|
|
647
846
|
end
|