rubycli 0.1.4 → 0.1.6
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 +24 -0
- data/README.ja.md +67 -12
- data/README.md +67 -12
- data/lib/rubycli/argument_parser.rb +255 -1
- data/lib/rubycli/arguments/value_converter.rb +11 -0
- data/lib/rubycli/cli.rb +5 -1
- data/lib/rubycli/command_line.rb +42 -6
- data/lib/rubycli/documentation/metadata_parser.rb +377 -31
- data/lib/rubycli/documentation_registry.rb +1 -1
- data/lib/rubycli/environment.rb +42 -7
- data/lib/rubycli/eval_coercer.rb +1 -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 +103 -32
- metadata +2 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require 'did_you_mean'
|
|
2
|
+
|
|
1
3
|
require_relative 'type_utils'
|
|
2
4
|
require_relative 'arguments/token_stream'
|
|
3
5
|
require_relative 'arguments/value_converter'
|
|
@@ -61,6 +63,14 @@ module Rubycli
|
|
|
61
63
|
[pos_args, kw_args]
|
|
62
64
|
end
|
|
63
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
|
+
|
|
64
74
|
private
|
|
65
75
|
|
|
66
76
|
def debug_log(message)
|
|
@@ -196,6 +206,235 @@ module Rubycli
|
|
|
196
206
|
@value_converter.convert(arg)
|
|
197
207
|
end
|
|
198
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
|
|
268
|
+
|
|
269
|
+
def literal_allowed?(allowed_entries, value)
|
|
270
|
+
entries = Array(allowed_entries).compact
|
|
271
|
+
return false if entries.empty?
|
|
272
|
+
|
|
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
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def type_allowed?(types, value)
|
|
296
|
+
tokens = Array(types).compact
|
|
297
|
+
return false if tokens.empty?
|
|
298
|
+
|
|
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?
|
|
309
|
+
|
|
310
|
+
return combined.first if combined.size == 1
|
|
311
|
+
|
|
312
|
+
"one of #{combined.join(', ')}"
|
|
313
|
+
end
|
|
314
|
+
|
|
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
|
+
[]
|
|
337
|
+
end
|
|
338
|
+
|
|
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
|
|
361
|
+
end
|
|
362
|
+
|
|
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
|
|
372
|
+
|
|
373
|
+
def nil_type_token?(token)
|
|
374
|
+
token.to_s.strip.casecmp('nil').zero? || token.to_s.strip.casecmp('NilClass').zero?
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def safe_constant_lookup(name)
|
|
378
|
+
parts = name.to_s.split('::').reject(&:empty?)
|
|
379
|
+
return nil if parts.empty?
|
|
380
|
+
|
|
381
|
+
context = Object
|
|
382
|
+
parts.each do |const_name|
|
|
383
|
+
return nil unless context.const_defined?(const_name, false)
|
|
384
|
+
|
|
385
|
+
context = context.const_get(const_name)
|
|
386
|
+
end
|
|
387
|
+
context
|
|
388
|
+
rescue NameError
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
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
|
|
408
|
+
|
|
409
|
+
safe_constant_lookup(normalized)
|
|
410
|
+
rescue LoadError
|
|
411
|
+
safe_constant_lookup(normalized)
|
|
412
|
+
end
|
|
413
|
+
|
|
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
|
|
430
|
+
|
|
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[')
|
|
436
|
+
end
|
|
437
|
+
|
|
199
438
|
|
|
200
439
|
def build_cli_alias_map(option_defs)
|
|
201
440
|
option_defs.each_with_object({}) do |opt, memo|
|
|
@@ -273,7 +512,15 @@ module Rubycli
|
|
|
273
512
|
->(value) { value.to_sym }
|
|
274
513
|
when 'BigDecimal', 'Decimal'
|
|
275
514
|
require 'bigdecimal'
|
|
276
|
-
->(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
|
+
}
|
|
277
524
|
when 'Date'
|
|
278
525
|
require 'date'
|
|
279
526
|
->(value) { Date.parse(value) }
|
|
@@ -282,6 +529,13 @@ module Rubycli
|
|
|
282
529
|
->(value) { Time.parse(value) }
|
|
283
530
|
when 'JSON', 'Hash'
|
|
284
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
|
+
}
|
|
285
539
|
else
|
|
286
540
|
if normalized.start_with?('Array<') && normalized.end_with?('>')
|
|
287
541
|
inner = normalized[6..-2].strip
|
|
@@ -15,6 +15,11 @@ module Rubycli
|
|
|
15
15
|
trimmed = value.strip
|
|
16
16
|
return value if trimmed.empty?
|
|
17
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
|
+
|
|
18
23
|
if literal_like?(trimmed)
|
|
19
24
|
literal = try_literal_parse(value)
|
|
20
25
|
return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
|
|
@@ -33,6 +38,12 @@ module Rubycli
|
|
|
33
38
|
|
|
34
39
|
private
|
|
35
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
|
+
|
|
36
47
|
def integer_string?(str)
|
|
37
48
|
str =~ /\A-?\d+\z/
|
|
38
49
|
end
|
data/lib/rubycli/cli.rb
CHANGED
|
@@ -48,6 +48,9 @@ module Rubycli
|
|
|
48
48
|
else
|
|
49
49
|
execute_method(entry.method, command, args, cli_mode)
|
|
50
50
|
end
|
|
51
|
+
rescue Rubycli::ArgumentError => e
|
|
52
|
+
warn "[ERROR] #{e.message}"
|
|
53
|
+
1
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def available_commands(target)
|
|
@@ -113,6 +116,7 @@ module Rubycli
|
|
|
113
116
|
method = target.method(:call)
|
|
114
117
|
pos_args, kw_args = @argument_parser.parse(args, method)
|
|
115
118
|
Rubycli.apply_argument_coercions(pos_args, kw_args)
|
|
119
|
+
@argument_parser.validate_inputs(method, pos_args, kw_args)
|
|
116
120
|
begin
|
|
117
121
|
result = Rubycli.call_target(target, pos_args, kw_args)
|
|
118
122
|
@result_emitter.emit(result)
|
|
@@ -151,11 +155,11 @@ module Rubycli
|
|
|
151
155
|
def execute_method_with_params(method_obj, command, args, cli_mode)
|
|
152
156
|
pos_args, kw_args = @argument_parser.parse(args, method_obj)
|
|
153
157
|
Rubycli.apply_argument_coercions(pos_args, kw_args)
|
|
154
|
-
|
|
155
158
|
if should_show_method_help?(pos_args, kw_args)
|
|
156
159
|
puts usage_for_method(command, method_obj)
|
|
157
160
|
return 0
|
|
158
161
|
end
|
|
162
|
+
@argument_parser.validate_inputs(method_obj, pos_args, kw_args)
|
|
159
163
|
|
|
160
164
|
begin
|
|
161
165
|
result = Rubycli.call_target(method_obj, pos_args, kw_args)
|
data/lib/rubycli/command_line.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Rubycli
|
|
4
4
|
module CommandLine
|
|
5
5
|
USAGE = <<~USAGE
|
|
6
|
-
Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] <target-path> [<class-or-module>] [-- <cli-args>...]
|
|
6
|
+
Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] [--strict] [--check|-c] <target-path> [<class-or-module>] [-- <cli-args>...]
|
|
7
7
|
|
|
8
8
|
Examples:
|
|
9
9
|
rubycli scripts/sample_runner.rb echo --message hello
|
|
@@ -17,6 +17,8 @@ module Rubycli
|
|
|
17
17
|
--eval-args, -e Evaluate following arguments as Ruby code
|
|
18
18
|
--eval-lax, -E Evaluate as Ruby but fall back to raw strings when parsing fails
|
|
19
19
|
--auto-target, -a Auto-select the only callable constant when names don't match
|
|
20
|
+
--strict Enforce documented input types/choices (invalid values abort)
|
|
21
|
+
--check, -c Validate documentation/comments without executing commands
|
|
20
22
|
(Note: --json-args cannot be combined with --eval-args or --eval-lax)
|
|
21
23
|
(Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
|
|
22
24
|
|
|
@@ -42,6 +44,7 @@ module Rubycli
|
|
|
42
44
|
eval_mode = false
|
|
43
45
|
eval_lax_mode = false
|
|
44
46
|
constant_mode = nil
|
|
47
|
+
check_mode = false
|
|
45
48
|
pre_script_sources = []
|
|
46
49
|
|
|
47
50
|
loop do
|
|
@@ -64,7 +67,7 @@ module Rubycli
|
|
|
64
67
|
flag = args.shift
|
|
65
68
|
src = args.shift
|
|
66
69
|
unless src
|
|
67
|
-
warn "#{flag} requires a file path or inline Ruby code"
|
|
70
|
+
warn "[ERROR] #{flag} requires a file path or inline Ruby code"
|
|
68
71
|
return 1
|
|
69
72
|
end
|
|
70
73
|
context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
|
|
@@ -79,8 +82,19 @@ module Rubycli
|
|
|
79
82
|
eval_mode = true
|
|
80
83
|
eval_lax_mode = true
|
|
81
84
|
args.shift
|
|
85
|
+
when '--strict'
|
|
86
|
+
Rubycli.environment.enable_strict_input!
|
|
87
|
+
args.shift
|
|
88
|
+
when '--check', '-c'
|
|
89
|
+
check_mode = true
|
|
90
|
+
Rubycli.environment.enable_doc_check!
|
|
91
|
+
args.shift
|
|
82
92
|
when '--print-result'
|
|
83
|
-
|
|
93
|
+
args.shift
|
|
94
|
+
when '--debug'
|
|
95
|
+
args.shift
|
|
96
|
+
warn "[ERROR] --debug flag has been removed; set RUBYCLI_DEBUG=true instead."
|
|
97
|
+
return 1
|
|
84
98
|
when '--auto-target', '-a'
|
|
85
99
|
constant_mode = :auto
|
|
86
100
|
args.shift
|
|
@@ -106,10 +120,32 @@ module Rubycli
|
|
|
106
120
|
args.shift if args.first == '--'
|
|
107
121
|
|
|
108
122
|
if json_mode && eval_mode
|
|
109
|
-
warn '--json-args cannot be combined with --eval-args or --eval-lax'
|
|
123
|
+
warn '[ERROR] --json-args cannot be combined with --eval-args or --eval-lax'
|
|
124
|
+
return 1
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
if check_mode && (json_mode || eval_mode)
|
|
128
|
+
warn '[ERROR] --check cannot be combined with --json-args or --eval-args'
|
|
110
129
|
return 1
|
|
111
130
|
end
|
|
112
131
|
|
|
132
|
+
if check_mode && !args.empty?
|
|
133
|
+
warn '[ERROR] --check does not accept command arguments'
|
|
134
|
+
return 1
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Rubycli.environment.clear_documentation_issues!
|
|
138
|
+
|
|
139
|
+
if check_mode
|
|
140
|
+
return Rubycli::Runner.check(
|
|
141
|
+
target_path,
|
|
142
|
+
class_or_module,
|
|
143
|
+
new: new_flag,
|
|
144
|
+
pre_scripts: pre_script_sources,
|
|
145
|
+
constant_mode: constant_mode
|
|
146
|
+
)
|
|
147
|
+
end
|
|
148
|
+
|
|
113
149
|
Rubycli::Runner.execute(
|
|
114
150
|
target_path,
|
|
115
151
|
class_or_module,
|
|
@@ -124,10 +160,10 @@ module Rubycli
|
|
|
124
160
|
|
|
125
161
|
0
|
|
126
162
|
rescue Rubycli::Runner::PreScriptError => e
|
|
127
|
-
warn e.message
|
|
163
|
+
warn "[ERROR] #{e.message}"
|
|
128
164
|
1
|
|
129
165
|
rescue Rubycli::Runner::Error => e
|
|
130
|
-
warn e.message
|
|
166
|
+
warn "[ERROR] #{e.message}"
|
|
131
167
|
1
|
|
132
168
|
end
|
|
133
169
|
end
|