rubycli 0.1.1 → 0.1.4

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.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ # Coordinates json/eval argument modes and enforces mutual exclusion.
5
+ class ArgumentModeController
6
+ def initialize(json_coercer:, eval_coercer:)
7
+ @json_coercer = json_coercer
8
+ @eval_coercer = eval_coercer
9
+ end
10
+
11
+ def json_mode?
12
+ @json_coercer.json_mode?
13
+ end
14
+
15
+ def eval_mode?
16
+ @eval_coercer.eval_mode?
17
+ end
18
+
19
+ def with_json_mode(enabled = true, &block)
20
+ enforce_mutual_exclusion!(:json, enabled)
21
+ @json_coercer.with_json_mode(enabled, &block)
22
+ end
23
+
24
+ def with_eval_mode(enabled = true, **options, &block)
25
+ enforce_mutual_exclusion!(:eval, enabled)
26
+ @eval_coercer.with_eval_mode(enabled, **options, &block)
27
+ end
28
+
29
+ def apply_argument_coercions(positional_args, keyword_args)
30
+ ensure_modes_compatible!
31
+
32
+ if json_mode?
33
+ coerce_values!(positional_args, keyword_args) { |value| @json_coercer.coerce_json_value(value) }
34
+ end
35
+
36
+ if eval_mode?
37
+ coerce_values!(positional_args, keyword_args) { |value| @eval_coercer.coerce_eval_value(value) }
38
+ end
39
+ rescue ::ArgumentError => e
40
+ raise Rubycli::ArgumentError, e.message
41
+ end
42
+
43
+ private
44
+
45
+ def enforce_mutual_exclusion!(mode, enabled)
46
+ return unless enabled
47
+
48
+ case mode
49
+ when :json
50
+ raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax' if eval_mode?
51
+ when :eval
52
+ raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax' if json_mode?
53
+ end
54
+ end
55
+
56
+ def ensure_modes_compatible!
57
+ if json_mode? && eval_mode?
58
+ raise Rubycli::ArgumentError, '--json-args cannot be combined with --eval-args or --eval-lax'
59
+ end
60
+ end
61
+
62
+ def coerce_values!(positional_args, keyword_args)
63
+ positional_args.map! { |value| yield(value) }
64
+ keyword_args.keys.each do |key|
65
+ keyword_args[key] = yield(keyword_args[key])
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,4 +1,6 @@
1
1
  require_relative 'type_utils'
2
+ require_relative 'arguments/token_stream'
3
+ require_relative 'arguments/value_converter'
2
4
 
3
5
  module Rubycli
4
6
  class ArgumentParser
@@ -9,6 +11,7 @@ module Rubycli
9
11
  @documentation_registry = documentation_registry
10
12
  @json_coercer = json_coercer
11
13
  @debug_logger = debug_logger
14
+ @value_converter = Arguments::ValueConverter.new
12
15
  end
13
16
 
14
17
  def parse(args, method = nil)
@@ -24,21 +27,21 @@ module Rubycli
24
27
  option_lookup = build_option_lookup(option_defs)
25
28
  type_converters = build_type_converter_map(option_defs)
26
29
 
27
- i = 0
28
- while i < args.size
29
- token = args[i]
30
+ stream = Arguments::TokenStream.new(args)
31
+
32
+ until stream.finished?
33
+ token = stream.current
30
34
 
31
35
  if token == '--'
32
- rest_tokens = (args[(i + 1)..-1] || []).map { |value| convert_arg(value) }
36
+ stream.advance
37
+ rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) }
33
38
  pos_args.concat(rest_tokens)
34
39
  break
35
- end
36
-
37
- if option_token?(token)
38
- i = process_option_token(
40
+ elsif option_token?(token)
41
+ stream.advance
42
+ process_option_token(
39
43
  token,
40
- args,
41
- i,
44
+ stream,
42
45
  kw_param_names,
43
46
  kw_args,
44
47
  cli_aliases,
@@ -46,12 +49,12 @@ module Rubycli
46
49
  type_converters
47
50
  )
48
51
  elsif assignment_token?(token)
52
+ stream.advance
49
53
  process_assignment_token(token, kw_args)
50
54
  else
51
55
  pos_args << convert_arg(token)
56
+ stream.advance
52
57
  end
53
-
54
- i += 1
55
58
  end
56
59
 
57
60
  debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
@@ -84,8 +87,7 @@ module Rubycli
84
87
 
85
88
  def process_option_token(
86
89
  token,
87
- args,
88
- current_index,
90
+ stream,
89
91
  kw_param_names,
90
92
  kw_args,
91
93
  cli_aliases,
@@ -107,21 +109,19 @@ module Rubycli
107
109
  requires_value = option_meta ? option_meta[:requires_value] : nil
108
110
  option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
109
111
 
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
112
+ value_capture = if embedded_value
113
+ embedded_value
114
+ elsif option_meta
115
+ capture_option_value(
116
+ option_meta,
117
+ stream,
118
+ requires_value
119
+ )
120
+ elsif (next_token = stream.current) && !looks_like_option?(next_token)
121
+ stream.consume
122
+ else
123
+ 'true'
124
+ end
125
125
 
126
126
  if requires_value && (value_capture.nil? || value_capture == 'true')
127
127
  raise ArgumentError, "Option '#{option_label}' requires a value"
@@ -135,40 +135,30 @@ module Rubycli
135
135
  )
136
136
 
137
137
  kw_args[final_key_sym] = converted_value
138
- current_index
139
138
  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]
139
+ def capture_option_value(option_meta, stream, requires_value)
140
+ if option_meta[:boolean_flag]
141
+ if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
142
+ return stream.consume
143
+ end
144
+ return 'true'
145
+ elsif option_meta[:optional_value]
146
+ if (next_token = stream.current) && !looks_like_option?(next_token)
147
+ return stream.consume
148
+ end
149
+ return true
150
+ elsif requires_value == false
151
+ return 'true'
152
+ elsif requires_value
153
+ next_token = stream.current
154
+ raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
155
+
156
+ return stream.consume
157
+ elsif (next_token = stream.current) && !looks_like_option?(next_token)
158
+ return stream.consume
159
+ else
160
+ return 'true'
161
+ end
172
162
  end
173
163
 
174
164
  def process_assignment_token(token, kw_args)
@@ -203,21 +193,7 @@ module Rubycli
203
193
  end
204
194
 
205
195
  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/
196
+ @value_converter.convert(arg)
221
197
  end
222
198
 
223
199
 
@@ -286,7 +262,7 @@ module Rubycli
286
262
  when 'String'
287
263
  ->(value) { value }
288
264
  when 'Integer', 'Fixnum'
289
- ->(value) { Integer(value, 10) }
265
+ ->(value) { Integer(value) }
290
266
  when 'Float'
291
267
  ->(value) { Float(value) }
292
268
  when 'Numeric'
@@ -324,10 +300,25 @@ module Rubycli
324
300
  end
325
301
 
326
302
  def convert_option_value(keyword, value, option_meta, type_converters)
303
+ if Rubycli.eval_mode? || Rubycli.json_mode?
304
+ return convert_arg(value)
305
+ end
306
+
327
307
  converter = type_converters[keyword]
328
- return convert_arg(value) unless converter
308
+ converted_value = convert_arg(value)
309
+ return converted_value unless converter
310
+
311
+ original_input = value if value.is_a?(String)
312
+ expects_list = option_meta && option_meta.types.any? { |type|
313
+ type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
314
+ }
315
+
316
+ value_for_converter = converted_value
317
+ if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
318
+ value_for_converter = original_input
319
+ end
329
320
 
330
- converter.call(value)
321
+ converter.call(value_for_converter)
331
322
  rescue StandardError => e
332
323
  option_label = option_meta&.long || option_meta&.short || keyword
333
324
  raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
@@ -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,74 @@
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 literal_like?(trimmed)
19
+ literal = try_literal_parse(value)
20
+ return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
21
+ end
22
+
23
+ return nil if null_literal?(trimmed)
24
+
25
+ lower = trimmed.downcase
26
+ return true if lower == 'true'
27
+ return false if lower == 'false'
28
+ return value.to_i if integer_string?(trimmed)
29
+ return value.to_f if float_string?(trimmed)
30
+
31
+ value
32
+ end
33
+
34
+ private
35
+
36
+ def integer_string?(str)
37
+ str =~ /\A-?\d+\z/
38
+ end
39
+
40
+ def float_string?(str)
41
+ str =~ /\A-?\d+\.\d+\z/
42
+ end
43
+
44
+ def try_literal_parse(value)
45
+ return LITERAL_PARSE_FAILURE unless value.is_a?(String)
46
+
47
+ trimmed = value.strip
48
+ return value if trimmed.empty?
49
+
50
+ literal = Psych.safe_load(trimmed, aliases: false)
51
+ return literal unless literal.nil? && !null_literal?(trimmed)
52
+
53
+ LITERAL_PARSE_FAILURE
54
+ rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
55
+ LITERAL_PARSE_FAILURE
56
+ end
57
+
58
+ def null_literal?(value)
59
+ return false unless value
60
+
61
+ %w[null ~].include?(value.downcase)
62
+ end
63
+
64
+ def literal_like?(value)
65
+ return false unless value
66
+ return true if value.start_with?('[', '{', '"', "'")
67
+ return true if value.start_with?('---')
68
+ return true if value.match?(/\A(?:true|false|null|nil)\z/i)
69
+
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
data/lib/rubycli/cli.rb CHANGED
@@ -37,7 +37,7 @@ module Rubycli
37
37
 
38
38
  if should_show_help?(args)
39
39
  @help_renderer.print_help(target, catalog)
40
- exit(0)
40
+ return 0
41
41
  end
42
42
 
43
43
  command = args.shift
@@ -105,7 +105,7 @@ module Rubycli
105
105
  error_msg = "Command '#{command}' is not available."
106
106
  puts error_msg
107
107
  @help_renderer.print_help(target, catalog)
108
- exit(1)
108
+ 1
109
109
  end
110
110
  end
111
111
 
@@ -116,6 +116,7 @@ module Rubycli
116
116
  begin
117
117
  result = Rubycli.call_target(target, pos_args, kw_args)
118
118
  @result_emitter.emit(result)
119
+ 0
119
120
  rescue StandardError => e
120
121
  handle_execution_error(e, command, method, pos_args, kw_args, cli_mode)
121
122
  end
@@ -132,15 +133,16 @@ module Rubycli
132
133
  def execute_parameterless_method(method_obj, command, args, cli_mode)
133
134
  if help_requested_for_parameterless?(args)
134
135
  puts usage_for_method(command, method_obj)
135
- exit(0)
136
+ return 0
136
137
  end
137
138
 
138
139
  begin
139
140
  result = method_obj.call
140
141
  debug_log "Parameterless method returned: #{result.inspect}"
141
142
  if result
142
- run(result, args, false)
143
+ return run(result, args, false)
143
144
  end
145
+ 0
144
146
  rescue StandardError => e
145
147
  handle_execution_error(e, command, method_obj, [], {}, cli_mode)
146
148
  end
@@ -152,12 +154,13 @@ module Rubycli
152
154
 
153
155
  if should_show_method_help?(pos_args, kw_args)
154
156
  puts usage_for_method(command, method_obj)
155
- exit(0)
157
+ return 0
156
158
  end
157
159
 
158
160
  begin
159
161
  result = Rubycli.call_target(method_obj, pos_args, kw_args)
160
162
  @result_emitter.emit(result)
163
+ 0
161
164
  rescue StandardError => e
162
165
  handle_execution_error(e, command, method_obj, pos_args, kw_args, cli_mode)
163
166
  end
@@ -181,7 +184,7 @@ module Rubycli
181
184
  if cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
182
185
  puts "Error: #{error.message}"
183
186
  puts usage_for_method(command, method_obj)
184
- exit(1)
187
+ 1
185
188
  else
186
189
  raise error
187
190
  end
@@ -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 | --eval-args] TARGET_PATH [CLASS_OR_MODULE] [-- CLI_ARGS...]
6
+ Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] <target-path> [<class-or-module>] [-- <cli-args>...]
7
7
 
8
8
  Examples:
9
9
  rubycli scripts/sample_runner.rb echo --message hello
@@ -12,14 +12,18 @@ module Rubycli
12
12
 
13
13
  Options:
14
14
  --new, -n Instantiate the class/module before invoking CLI commands
15
- --pre-script SRC Evaluate Ruby code and use its result as the exposed target (--init alias)
16
- --json-args Treat all following arguments as JSON
17
- --eval-args Evaluate following arguments as Ruby code
18
- (Note: --json-args and --eval-args are mutually exclusive)
15
+ --pre-script=<src> Evaluate Ruby code and use its result as the exposed target (--init alias; also accepts space-separated form)
16
+ --json-args, -j Parse all following arguments strictly as JSON (no YAML literals)
17
+ --eval-args, -e Evaluate following arguments as Ruby code
18
+ --eval-lax, -E Evaluate as Ruby but fall back to raw strings when parsing fails
19
+ --auto-target, -a Auto-select the only callable constant when names don't match
20
+ (Note: --json-args cannot be combined with --eval-args or --eval-lax)
21
+ (Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
19
22
 
20
- When CLASS_OR_MODULE is omitted, Rubycli infers it from the file name in CamelCase.
23
+ When <class-or-module> is omitted, Rubycli infers it from the file name in CamelCase.
24
+ Arguments are parsed as safe literals by default; pick a mode above if you need strict JSON or Ruby eval.
21
25
  Method return values are printed to STDOUT by default.
22
- CLI_ARGS are forwarded to Rubycli unchanged.
26
+ <cli-args> are forwarded to Rubycli unchanged.
23
27
  USAGE
24
28
 
25
29
  module_function
@@ -36,6 +40,8 @@ module Rubycli
36
40
  new_flag = false
37
41
  json_mode = false
38
42
  eval_mode = false
43
+ eval_lax_mode = false
44
+ constant_mode = nil
39
45
  pre_script_sources = []
40
46
 
41
47
  loop do
@@ -63,13 +69,20 @@ module Rubycli
63
69
  end
64
70
  context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
65
71
  pre_script_sources << { value: src, context: context }
66
- when '--json-args'
72
+ when '--json-args', '-j'
67
73
  json_mode = true
68
74
  args.shift
69
- when '--eval-args'
75
+ when '--eval-args', '-e'
70
76
  eval_mode = true
71
77
  args.shift
78
+ when '--eval-lax', '-E'
79
+ eval_mode = true
80
+ eval_lax_mode = true
81
+ args.shift
72
82
  when '--print-result'
83
+ args.shift
84
+ when '--auto-target', '-a'
85
+ constant_mode = :auto
73
86
  args.shift
74
87
  when '--'
75
88
  args.shift
@@ -93,7 +106,7 @@ module Rubycli
93
106
  args.shift if args.first == '--'
94
107
 
95
108
  if json_mode && eval_mode
96
- warn '--json-args and --eval-args cannot be used at the same time'
109
+ warn '--json-args cannot be combined with --eval-args or --eval-lax'
97
110
  return 1
98
111
  end
99
112
 
@@ -104,13 +117,18 @@ module Rubycli
104
117
  new: new_flag,
105
118
  json: json_mode,
106
119
  eval_args: eval_mode,
107
- pre_scripts: pre_script_sources
120
+ eval_lax: eval_lax_mode,
121
+ pre_scripts: pre_script_sources,
122
+ constant_mode: constant_mode
108
123
  )
109
124
 
110
125
  0
111
126
  rescue Rubycli::Runner::PreScriptError => e
112
127
  warn e.message
113
128
  1
129
+ rescue Rubycli::Runner::Error => e
130
+ warn e.message
131
+ 1
114
132
  end
115
133
  end
116
134
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ # Observes constants defined while loading a file.
5
+ class ConstantCapture
6
+ def initialize
7
+ @captured = Hash.new { |hash, key| hash[key] = [] }
8
+ end
9
+
10
+ def capture(file)
11
+ trace = TracePoint.new(:class) do |tp|
12
+ location = tp.path
13
+ next unless location && same_file?(file, location)
14
+
15
+ constant_name = qualified_name_for(tp.self)
16
+ next unless constant_name
17
+
18
+ @captured[file] << constant_name
19
+ end
20
+
21
+ trace.enable
22
+ yield
23
+ ensure
24
+ trace&.disable
25
+ end
26
+
27
+ def constants_for(file)
28
+ Array(@captured[normalize(file)]).uniq
29
+ end
30
+
31
+ private
32
+
33
+ def same_file?(target, candidate)
34
+ normalize(target) == normalize(candidate)
35
+ end
36
+
37
+ def normalize(file)
38
+ File.expand_path(file.to_s)
39
+ end
40
+
41
+ def qualified_name_for(target)
42
+ return nil unless target.respond_to?(:name)
43
+
44
+ name = target.name
45
+ return nil unless name && !name.empty? && !name.start_with?('#<')
46
+
47
+ name
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ module Documentation
5
+ # Extracts contiguous comment blocks that appear immediately before a method.
6
+ class CommentExtractor
7
+ def initialize
8
+ @file_cache = {}
9
+ end
10
+
11
+ def extract(file, line_number)
12
+ return [] unless file && line_number
13
+
14
+ lines = cached_lines_for(file)
15
+ index = line_number - 2
16
+ block = []
17
+
18
+ while index >= 0
19
+ line = lines[index]
20
+ break unless comment_line?(line)
21
+
22
+ block << line
23
+ index -= 1
24
+ end
25
+
26
+ block.reverse.map { |line| strip_comment_prefix(line) }
27
+ rescue Errno::ENOENT
28
+ []
29
+ end
30
+
31
+ def reset!
32
+ @file_cache.clear
33
+ end
34
+
35
+ private
36
+
37
+ def cached_lines_for(file)
38
+ @file_cache[file] ||= File.readlines(file, chomp: true)
39
+ end
40
+
41
+ def comment_line?(line)
42
+ return false unless line
43
+
44
+ line.lstrip.start_with?('#')
45
+ end
46
+
47
+ def strip_comment_prefix(line)
48
+ line.lstrip.sub(/^#/, '').lstrip
49
+ end
50
+ end
51
+ end
52
+ end