rubycli 0.1.2 → 0.1.5
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 +44 -0
- data/README.ja.md +82 -14
- data/README.md +82 -14
- data/lib/rubycli/argument_mode_controller.rb +69 -0
- data/lib/rubycli/argument_parser.rb +287 -102
- data/lib/rubycli/arguments/token_stream.rb +41 -0
- data/lib/rubycli/arguments/value_converter.rb +85 -0
- data/lib/rubycli/cli.rb +14 -7
- data/lib/rubycli/command_line.rb +58 -6
- data/lib/rubycli/constant_capture.rb +50 -0
- data/lib/rubycli/documentation/comment_extractor.rb +52 -0
- data/lib/rubycli/documentation/metadata_parser.rb +973 -0
- data/lib/rubycli/documentation_registry.rb +11 -853
- data/lib/rubycli/environment.rb +50 -8
- data/lib/rubycli/eval_coercer.rb +16 -1
- data/lib/rubycli/help_renderer.rb +64 -9
- data/lib/rubycli/types.rb +2 -2
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +265 -121
- metadata +8 -2
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
require '
|
|
1
|
+
require 'did_you_mean'
|
|
2
|
+
|
|
2
3
|
require_relative 'type_utils'
|
|
4
|
+
require_relative 'arguments/token_stream'
|
|
5
|
+
require_relative 'arguments/value_converter'
|
|
3
6
|
|
|
4
7
|
module Rubycli
|
|
5
8
|
class ArgumentParser
|
|
@@ -10,6 +13,7 @@ module Rubycli
|
|
|
10
13
|
@documentation_registry = documentation_registry
|
|
11
14
|
@json_coercer = json_coercer
|
|
12
15
|
@debug_logger = debug_logger
|
|
16
|
+
@value_converter = Arguments::ValueConverter.new
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def parse(args, method = nil)
|
|
@@ -25,21 +29,21 @@ module Rubycli
|
|
|
25
29
|
option_lookup = build_option_lookup(option_defs)
|
|
26
30
|
type_converters = build_type_converter_map(option_defs)
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
stream = Arguments::TokenStream.new(args)
|
|
33
|
+
|
|
34
|
+
until stream.finished?
|
|
35
|
+
token = stream.current
|
|
31
36
|
|
|
32
37
|
if token == '--'
|
|
33
|
-
|
|
38
|
+
stream.advance
|
|
39
|
+
rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) }
|
|
34
40
|
pos_args.concat(rest_tokens)
|
|
35
41
|
break
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
i = process_option_token(
|
|
42
|
+
elsif option_token?(token)
|
|
43
|
+
stream.advance
|
|
44
|
+
process_option_token(
|
|
40
45
|
token,
|
|
41
|
-
|
|
42
|
-
i,
|
|
46
|
+
stream,
|
|
43
47
|
kw_param_names,
|
|
44
48
|
kw_args,
|
|
45
49
|
cli_aliases,
|
|
@@ -47,18 +51,26 @@ module Rubycli
|
|
|
47
51
|
type_converters
|
|
48
52
|
)
|
|
49
53
|
elsif assignment_token?(token)
|
|
54
|
+
stream.advance
|
|
50
55
|
process_assignment_token(token, kw_args)
|
|
51
56
|
else
|
|
52
57
|
pos_args << convert_arg(token)
|
|
58
|
+
stream.advance
|
|
53
59
|
end
|
|
54
|
-
|
|
55
|
-
i += 1
|
|
56
60
|
end
|
|
57
61
|
|
|
58
62
|
debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
|
|
59
63
|
[pos_args, kw_args]
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
def validate_inputs(method_obj, positional_args, keyword_args)
|
|
67
|
+
return unless method_obj
|
|
68
|
+
|
|
69
|
+
metadata = @documentation_registry.metadata_for(method_obj)
|
|
70
|
+
validate_positional_arguments(method_obj, metadata, positional_args)
|
|
71
|
+
validate_keyword_arguments(metadata, keyword_args)
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
private
|
|
63
75
|
|
|
64
76
|
def debug_log(message)
|
|
@@ -85,8 +97,7 @@ module Rubycli
|
|
|
85
97
|
|
|
86
98
|
def process_option_token(
|
|
87
99
|
token,
|
|
88
|
-
|
|
89
|
-
current_index,
|
|
100
|
+
stream,
|
|
90
101
|
kw_param_names,
|
|
91
102
|
kw_args,
|
|
92
103
|
cli_aliases,
|
|
@@ -108,21 +119,19 @@ module Rubycli
|
|
|
108
119
|
requires_value = option_meta ? option_meta[:requires_value] : nil
|
|
109
120
|
option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
|
|
110
121
|
|
|
111
|
-
value_capture
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
['true', current_index]
|
|
125
|
-
end
|
|
122
|
+
value_capture = if embedded_value
|
|
123
|
+
embedded_value
|
|
124
|
+
elsif option_meta
|
|
125
|
+
capture_option_value(
|
|
126
|
+
option_meta,
|
|
127
|
+
stream,
|
|
128
|
+
requires_value
|
|
129
|
+
)
|
|
130
|
+
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
131
|
+
stream.consume
|
|
132
|
+
else
|
|
133
|
+
'true'
|
|
134
|
+
end
|
|
126
135
|
|
|
127
136
|
if requires_value && (value_capture.nil? || value_capture == 'true')
|
|
128
137
|
raise ArgumentError, "Option '#{option_label}' requires a value"
|
|
@@ -136,40 +145,30 @@ module Rubycli
|
|
|
136
145
|
)
|
|
137
146
|
|
|
138
147
|
kw_args[final_key_sym] = converted_value
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
end
|
|
164
|
-
new_index += 1
|
|
165
|
-
args[new_index]
|
|
166
|
-
elsif new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
|
|
167
|
-
new_index += 1
|
|
168
|
-
args[new_index]
|
|
169
|
-
else
|
|
170
|
-
'true'
|
|
171
|
-
end
|
|
172
|
-
[value, new_index]
|
|
148
|
+
end
|
|
149
|
+
def capture_option_value(option_meta, stream, requires_value)
|
|
150
|
+
if option_meta[:boolean_flag]
|
|
151
|
+
if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
|
|
152
|
+
return stream.consume
|
|
153
|
+
end
|
|
154
|
+
return 'true'
|
|
155
|
+
elsif option_meta[:optional_value]
|
|
156
|
+
if (next_token = stream.current) && !looks_like_option?(next_token)
|
|
157
|
+
return stream.consume
|
|
158
|
+
end
|
|
159
|
+
return true
|
|
160
|
+
elsif requires_value == false
|
|
161
|
+
return 'true'
|
|
162
|
+
elsif requires_value
|
|
163
|
+
next_token = stream.current
|
|
164
|
+
raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
|
|
165
|
+
|
|
166
|
+
return stream.consume
|
|
167
|
+
elsif (next_token = stream.current) && !looks_like_option?(next_token)
|
|
168
|
+
return stream.consume
|
|
169
|
+
else
|
|
170
|
+
return 'true'
|
|
171
|
+
end
|
|
173
172
|
end
|
|
174
173
|
|
|
175
174
|
def process_assignment_token(token, kw_args)
|
|
@@ -204,65 +203,236 @@ module Rubycli
|
|
|
204
203
|
end
|
|
205
204
|
|
|
206
205
|
def convert_arg(arg)
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
@value_converter.convert(arg)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_positional_arguments(method_obj, metadata, positional_args)
|
|
210
|
+
return if positional_args.nil? || positional_args.empty?
|
|
211
|
+
|
|
212
|
+
positional_map = metadata[:positionals_map] || {}
|
|
213
|
+
ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) }
|
|
214
|
+
|
|
215
|
+
ordered_params.each_with_index do |(_, name), index|
|
|
216
|
+
definition = positional_map[name]
|
|
217
|
+
next unless definition
|
|
218
|
+
next if index >= positional_args.size
|
|
219
|
+
|
|
220
|
+
label = definition.label || definition.placeholder || name.to_s.upcase
|
|
221
|
+
enforce_value_against_definition(definition, positional_args[index], label)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def validate_keyword_arguments(metadata, keyword_args)
|
|
226
|
+
return if keyword_args.nil? || keyword_args.empty?
|
|
227
|
+
|
|
228
|
+
option_lookup = build_option_lookup(metadata[:options] || [])
|
|
229
|
+
keyword_args.each do |key, value|
|
|
230
|
+
definition = option_lookup[key.to_sym]
|
|
231
|
+
next unless definition
|
|
232
|
+
|
|
233
|
+
label = definition.long || "--#{key.to_s.tr('_', '-')}"
|
|
234
|
+
enforce_value_against_definition(definition, value, label)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def enforce_value_against_definition(definition, value, label)
|
|
239
|
+
return unless definition
|
|
240
|
+
|
|
241
|
+
return if type_allowed?(definition.types, value)
|
|
242
|
+
|
|
243
|
+
Array(value.is_a?(Array) ? value : [value]).each do |entry|
|
|
244
|
+
next if literal_allowed?(definition.allowed_values, entry)
|
|
245
|
+
next if type_allowed?(definition.types, entry)
|
|
246
|
+
|
|
247
|
+
message = build_invalid_value_message(definition, entry, label)
|
|
248
|
+
@environment.handle_input_violation(message)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def build_invalid_value_message(definition, entry, label)
|
|
253
|
+
description = allowed_value_description(definition)
|
|
254
|
+
formatted_value = format_literal_value(entry)
|
|
255
|
+
|
|
256
|
+
message = if description
|
|
257
|
+
"#{label} must be #{description} (received #{formatted_value})"
|
|
258
|
+
else
|
|
259
|
+
"Value #{formatted_value} for #{label} is not allowed"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
suggestions = literal_suggestions(definition, entry)
|
|
263
|
+
return message if suggestions.empty?
|
|
264
|
+
|
|
265
|
+
suggestion_text = suggestions.size == 1 ? suggestions.first : suggestions.join(', ')
|
|
266
|
+
"#{message}. Did you mean #{suggestion_text}?"
|
|
267
|
+
end
|
|
209
268
|
|
|
210
|
-
|
|
211
|
-
|
|
269
|
+
def literal_allowed?(allowed_entries, value)
|
|
270
|
+
entries = Array(allowed_entries).compact
|
|
271
|
+
return false if entries.empty?
|
|
212
272
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
273
|
+
entries.any? { |entry| literal_match?(entry[:value], value) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def literal_match?(candidate, value)
|
|
277
|
+
case candidate
|
|
278
|
+
when Symbol
|
|
279
|
+
value.is_a?(Symbol) && value == candidate
|
|
280
|
+
when String
|
|
281
|
+
value.is_a?(String) && value == candidate
|
|
282
|
+
when Integer
|
|
283
|
+
value.is_a?(Integer) && value == candidate
|
|
284
|
+
when Float
|
|
285
|
+
value.is_a?(Float) && value == candidate
|
|
286
|
+
when TrueClass, FalseClass
|
|
287
|
+
value == candidate
|
|
288
|
+
when NilClass
|
|
289
|
+
value.nil?
|
|
290
|
+
else
|
|
291
|
+
value == candidate
|
|
216
292
|
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def type_allowed?(types, value)
|
|
296
|
+
tokens = Array(types).compact
|
|
297
|
+
return false if tokens.empty?
|
|
217
298
|
|
|
218
|
-
|
|
299
|
+
tokens.any? { |token| matches_type_token?(token, value) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def allowed_value_description(definition)
|
|
303
|
+
literal_descriptions = Array(definition.allowed_values).map { |entry| format_literal_value(entry[:value]) }.reject(&:empty?)
|
|
304
|
+
type_descriptions = Array(definition.types)
|
|
305
|
+
.map { |token| token.to_s.strip }
|
|
306
|
+
.reject { |token| token.empty? || literal_hint_token?(token) }
|
|
307
|
+
combined = (literal_descriptions + type_descriptions).uniq.reject(&:empty?)
|
|
308
|
+
return nil if combined.empty?
|
|
219
309
|
|
|
220
|
-
|
|
221
|
-
return true if lower == 'true'
|
|
222
|
-
return false if lower == 'false'
|
|
223
|
-
return arg.to_i if integer_string?(trimmed)
|
|
224
|
-
return arg.to_f if float_string?(trimmed)
|
|
310
|
+
return combined.first if combined.size == 1
|
|
225
311
|
|
|
226
|
-
|
|
312
|
+
"one of #{combined.join(', ')}"
|
|
227
313
|
end
|
|
228
314
|
|
|
229
|
-
def
|
|
230
|
-
|
|
315
|
+
def literal_suggestions(definition, entry)
|
|
316
|
+
literals = Array(definition.allowed_values).map { |allowed| allowed[:value] }.compact
|
|
317
|
+
return [] if literals.empty?
|
|
318
|
+
return [] unless entry.is_a?(String) || entry.is_a?(Symbol)
|
|
319
|
+
|
|
320
|
+
candidates = literals.select { |value| value.is_a?(String) || value.is_a?(Symbol) }
|
|
321
|
+
return [] if candidates.empty?
|
|
322
|
+
|
|
323
|
+
lookup = {}
|
|
324
|
+
dictionary = candidates.each_with_object([]) do |candidate, memo|
|
|
325
|
+
key = candidate.to_s
|
|
326
|
+
lookup[key] ||= candidate
|
|
327
|
+
memo << key unless memo.include?(key)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
|
|
331
|
+
matches = spell_checker.correct(entry.to_s)
|
|
332
|
+
return [] if matches.empty?
|
|
333
|
+
|
|
334
|
+
matches.take(3).map { |match| format_literal_value(lookup[match]) }
|
|
335
|
+
rescue LoadError, NameError
|
|
336
|
+
[]
|
|
231
337
|
end
|
|
232
338
|
|
|
233
|
-
def
|
|
234
|
-
|
|
339
|
+
def matches_type_token?(token, value)
|
|
340
|
+
normalized = token.to_s.strip
|
|
341
|
+
return true if normalized.empty?
|
|
342
|
+
|
|
343
|
+
if (inner = array_inner_type(normalized))
|
|
344
|
+
return false unless value.is_a?(Array)
|
|
345
|
+
return value.all? { |element| matches_type_token?(inner, element) }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
case normalized
|
|
349
|
+
when 'Boolean'
|
|
350
|
+
value.is_a?(TrueClass) || value.is_a?(FalseClass)
|
|
351
|
+
when 'JSON'
|
|
352
|
+
value.is_a?(Hash) || value.is_a?(Array)
|
|
353
|
+
when 'nil', 'NilClass'
|
|
354
|
+
value.nil?
|
|
355
|
+
else
|
|
356
|
+
klass = constant_for_token(normalized)
|
|
357
|
+
return value.is_a?(klass) if klass
|
|
358
|
+
|
|
359
|
+
false
|
|
360
|
+
end
|
|
235
361
|
end
|
|
236
362
|
|
|
237
|
-
|
|
363
|
+
def array_inner_type(token)
|
|
364
|
+
if token.end_with?('[]')
|
|
365
|
+
token[0..-3]
|
|
366
|
+
elsif token.start_with?('Array<') && token.end_with?('>')
|
|
367
|
+
token[6..-2].strip
|
|
368
|
+
else
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
238
372
|
|
|
239
|
-
def
|
|
240
|
-
|
|
373
|
+
def nil_type_token?(token)
|
|
374
|
+
token.to_s.strip.casecmp('nil').zero? || token.to_s.strip.casecmp('NilClass').zero?
|
|
375
|
+
end
|
|
241
376
|
|
|
242
|
-
|
|
243
|
-
|
|
377
|
+
def safe_constant_lookup(name)
|
|
378
|
+
parts = name.to_s.split('::').reject(&:empty?)
|
|
379
|
+
return nil if parts.empty?
|
|
244
380
|
|
|
245
|
-
|
|
246
|
-
|
|
381
|
+
context = Object
|
|
382
|
+
parts.each do |const_name|
|
|
383
|
+
return nil unless context.const_defined?(const_name, false)
|
|
247
384
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
385
|
+
context = context.const_get(const_name)
|
|
386
|
+
end
|
|
387
|
+
context
|
|
388
|
+
rescue NameError
|
|
389
|
+
nil
|
|
251
390
|
end
|
|
252
391
|
|
|
253
|
-
def
|
|
254
|
-
|
|
392
|
+
def constant_for_token(token)
|
|
393
|
+
normalized = token.to_s
|
|
394
|
+
case normalized
|
|
395
|
+
when 'Fixnum'
|
|
396
|
+
return Integer
|
|
397
|
+
when 'Date', 'DateTime'
|
|
398
|
+
require 'date'
|
|
399
|
+
when 'Time'
|
|
400
|
+
require 'time'
|
|
401
|
+
when 'BigDecimal', 'Decimal'
|
|
402
|
+
require 'bigdecimal'
|
|
403
|
+
when 'Pathname'
|
|
404
|
+
require 'pathname'
|
|
405
|
+
when 'Struct'
|
|
406
|
+
return Struct
|
|
407
|
+
end
|
|
255
408
|
|
|
256
|
-
|
|
409
|
+
safe_constant_lookup(normalized)
|
|
410
|
+
rescue LoadError
|
|
411
|
+
safe_constant_lookup(normalized)
|
|
257
412
|
end
|
|
258
413
|
|
|
259
|
-
def
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
414
|
+
def format_literal_value(value)
|
|
415
|
+
case value
|
|
416
|
+
when Symbol
|
|
417
|
+
":#{value}"
|
|
418
|
+
when String
|
|
419
|
+
value.inspect
|
|
420
|
+
when Integer, Float
|
|
421
|
+
value.to_s
|
|
422
|
+
when TrueClass, FalseClass
|
|
423
|
+
value.to_s
|
|
424
|
+
when NilClass
|
|
425
|
+
'nil'
|
|
426
|
+
else
|
|
427
|
+
value.inspect
|
|
428
|
+
end
|
|
429
|
+
end
|
|
264
430
|
|
|
265
|
-
|
|
431
|
+
def literal_hint_token?(token)
|
|
432
|
+
token = token.to_s.strip
|
|
433
|
+
return false if token.empty?
|
|
434
|
+
|
|
435
|
+
token.start_with?('%i[', '%I[', '%w[', '%W[')
|
|
266
436
|
end
|
|
267
437
|
|
|
268
438
|
|
|
@@ -342,7 +512,15 @@ module Rubycli
|
|
|
342
512
|
->(value) { value.to_sym }
|
|
343
513
|
when 'BigDecimal', 'Decimal'
|
|
344
514
|
require 'bigdecimal'
|
|
345
|
-
->(value) {
|
|
515
|
+
->(value) {
|
|
516
|
+
return value if value.is_a?(BigDecimal)
|
|
517
|
+
|
|
518
|
+
if value.is_a?(String)
|
|
519
|
+
BigDecimal(value)
|
|
520
|
+
else
|
|
521
|
+
BigDecimal(value.to_s)
|
|
522
|
+
end
|
|
523
|
+
}
|
|
346
524
|
when 'Date'
|
|
347
525
|
require 'date'
|
|
348
526
|
->(value) { Date.parse(value) }
|
|
@@ -351,6 +529,13 @@ module Rubycli
|
|
|
351
529
|
->(value) { Time.parse(value) }
|
|
352
530
|
when 'JSON', 'Hash'
|
|
353
531
|
->(value) { JSON.parse(value) }
|
|
532
|
+
when 'Pathname'
|
|
533
|
+
require 'pathname'
|
|
534
|
+
->(value) {
|
|
535
|
+
return value if value.is_a?(Pathname)
|
|
536
|
+
|
|
537
|
+
Pathname.new(value.to_s)
|
|
538
|
+
}
|
|
354
539
|
else
|
|
355
540
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
356
541
|
inner = normalized[6..-2].strip
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycli
|
|
4
|
+
module Arguments
|
|
5
|
+
# Lightweight mutable cursor over CLI tokens.
|
|
6
|
+
class TokenStream
|
|
7
|
+
def initialize(tokens)
|
|
8
|
+
@tokens = Array(tokens).dup
|
|
9
|
+
@index = 0
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def current
|
|
13
|
+
@tokens[@index]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def peek(offset = 1)
|
|
17
|
+
@tokens[@index + offset]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def advance(count = 1)
|
|
21
|
+
@index += count
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def consume
|
|
25
|
+
value = current
|
|
26
|
+
advance
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def consume_remaining
|
|
31
|
+
remaining = @tokens[@index..] || []
|
|
32
|
+
@index = @tokens.length
|
|
33
|
+
remaining
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def finished?
|
|
37
|
+
@index >= @tokens.length
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'psych'
|
|
4
|
+
|
|
5
|
+
module Rubycli
|
|
6
|
+
module Arguments
|
|
7
|
+
# Converts raw CLI tokens into Ruby primitives when safe to do so.
|
|
8
|
+
class ValueConverter
|
|
9
|
+
LITERAL_PARSE_FAILURE = Object.new
|
|
10
|
+
|
|
11
|
+
def convert(value)
|
|
12
|
+
return value if Rubycli.eval_mode? || Rubycli.json_mode?
|
|
13
|
+
return value unless value.is_a?(String)
|
|
14
|
+
|
|
15
|
+
trimmed = value.strip
|
|
16
|
+
return value if trimmed.empty?
|
|
17
|
+
|
|
18
|
+
if symbol_literal?(trimmed)
|
|
19
|
+
symbol_value = trimmed.delete_prefix(':')
|
|
20
|
+
return symbol_value.to_sym unless symbol_value.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if literal_like?(trimmed)
|
|
24
|
+
literal = try_literal_parse(value)
|
|
25
|
+
return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return nil if null_literal?(trimmed)
|
|
29
|
+
|
|
30
|
+
lower = trimmed.downcase
|
|
31
|
+
return true if lower == 'true'
|
|
32
|
+
return false if lower == 'false'
|
|
33
|
+
return value.to_i if integer_string?(trimmed)
|
|
34
|
+
return value.to_f if float_string?(trimmed)
|
|
35
|
+
|
|
36
|
+
value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def symbol_literal?(value)
|
|
42
|
+
return false unless value
|
|
43
|
+
|
|
44
|
+
value.start_with?(':') && value.length > 1 && value[1..].match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def integer_string?(str)
|
|
48
|
+
str =~ /\A-?\d+\z/
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def float_string?(str)
|
|
52
|
+
str =~ /\A-?\d+\.\d+\z/
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def try_literal_parse(value)
|
|
56
|
+
return LITERAL_PARSE_FAILURE unless value.is_a?(String)
|
|
57
|
+
|
|
58
|
+
trimmed = value.strip
|
|
59
|
+
return value if trimmed.empty?
|
|
60
|
+
|
|
61
|
+
literal = Psych.safe_load(trimmed, aliases: false)
|
|
62
|
+
return literal unless literal.nil? && !null_literal?(trimmed)
|
|
63
|
+
|
|
64
|
+
LITERAL_PARSE_FAILURE
|
|
65
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
|
|
66
|
+
LITERAL_PARSE_FAILURE
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def null_literal?(value)
|
|
70
|
+
return false unless value
|
|
71
|
+
|
|
72
|
+
%w[null ~].include?(value.downcase)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def literal_like?(value)
|
|
76
|
+
return false unless value
|
|
77
|
+
return true if value.start_with?('[', '{', '"', "'")
|
|
78
|
+
return true if value.start_with?('---')
|
|
79
|
+
return true if value.match?(/\A(?:true|false|null|nil)\z/i)
|
|
80
|
+
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|