rubycli 0.1.2 → 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.
@@ -24,7 +24,14 @@ module Rubycli
24
24
 
25
25
  def allow_param_comments?
26
26
  value = fetch_env_value('RUBYCLI_ALLOW_PARAM_COMMENT', 'ON')
27
- %w[on 1 true].include?(value.downcase)
27
+ %w[on 1 true].include?(value)
28
+ end
29
+
30
+ def constant_resolution_mode
31
+ value = fetch_env_value('RUBYCLI_AUTO_TARGET', 'strict')
32
+ return :auto if %w[auto on true yes 1].include?(value)
33
+
34
+ :strict
28
35
  end
29
36
 
30
37
  def handle_documentation_issue(message, file: nil, line: nil)
@@ -1,18 +1,26 @@
1
1
  module Rubycli
2
2
  class EvalCoercer
3
3
  THREAD_KEY = :rubycli_eval_mode
4
+ LAX_THREAD_KEY = :rubycli_eval_lax_mode
4
5
  EVAL_BINDING = Object.new.instance_eval { binding }
5
6
 
6
7
  def eval_mode?
7
8
  Thread.current[THREAD_KEY] == true
8
9
  end
9
10
 
10
- def with_eval_mode(enabled = true)
11
+ def eval_lax_mode?
12
+ Thread.current[LAX_THREAD_KEY] == true
13
+ end
14
+
15
+ def with_eval_mode(enabled = true, lax: false)
11
16
  previous = Thread.current[THREAD_KEY]
17
+ previous_lax = Thread.current[LAX_THREAD_KEY]
12
18
  Thread.current[THREAD_KEY] = enabled
19
+ Thread.current[LAX_THREAD_KEY] = enabled && lax
13
20
  yield
14
21
  ensure
15
22
  Thread.current[THREAD_KEY] = previous
23
+ Thread.current[LAX_THREAD_KEY] = previous_lax
16
24
  end
17
25
 
18
26
  def coerce_eval_value(value)
@@ -37,6 +45,13 @@ module Rubycli
37
45
  return trimmed if trimmed.empty?
38
46
 
39
47
  EVAL_BINDING.eval(trimmed)
48
+ rescue SyntaxError, NameError => e
49
+ if eval_lax_mode?
50
+ warn "[rubycli] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled."
51
+ expression
52
+ else
53
+ raise
54
+ end
40
55
  end
41
56
  end
42
57
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycli
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
data/lib/rubycli.rb CHANGED
@@ -12,11 +12,15 @@ require_relative 'rubycli/type_utils'
12
12
  require_relative 'rubycli/documentation_registry'
13
13
  require_relative 'rubycli/json_coercer'
14
14
  require_relative 'rubycli/eval_coercer'
15
+ require_relative 'rubycli/arguments/token_stream'
16
+ require_relative 'rubycli/arguments/value_converter'
17
+ require_relative 'rubycli/argument_mode_controller'
15
18
  require_relative 'rubycli/argument_parser'
16
19
  require_relative 'rubycli/help_renderer'
17
20
  require_relative 'rubycli/result_emitter'
18
21
  require_relative 'rubycli/cli'
19
22
  require_relative 'rubycli/command_line'
23
+ require_relative 'rubycli/constant_capture'
20
24
 
21
25
  module Rubycli
22
26
  class Error < StandardError; end
@@ -40,6 +44,13 @@ module Rubycli
40
44
  @eval_coercer ||= EvalCoercer.new
41
45
  end
42
46
 
47
+ def argument_mode_controller
48
+ @argument_mode_controller ||= ArgumentModeController.new(
49
+ json_coercer: json_coercer,
50
+ eval_coercer: eval_coercer
51
+ )
52
+ end
53
+
43
54
  def argument_parser
44
55
  @argument_parser ||= ArgumentParser.new(
45
56
  environment: environment,
@@ -57,6 +68,10 @@ module Rubycli
57
68
  @result_emitter ||= ResultEmitter.new(environment: environment)
58
69
  end
59
70
 
71
+ def constant_capture
72
+ @constant_capture ||= ConstantCapture.new
73
+ end
74
+
60
75
  def cli
61
76
  @cli ||= CLI.new(
62
77
  environment: environment,
@@ -68,7 +83,10 @@ module Rubycli
68
83
  end
69
84
 
70
85
  def run(target, args = ARGV, cli_mode = true)
71
- cli.run(target, args.dup, cli_mode)
86
+ status = cli.run(target, args.dup, cli_mode)
87
+ return status unless cli_mode
88
+
89
+ exit(status.to_i)
72
90
  end
73
91
 
74
92
  def parse_arguments(args, method = nil)
@@ -106,15 +124,11 @@ module Rubycli
106
124
  end
107
125
 
108
126
  def json_mode?
109
- json_coercer.json_mode?
127
+ argument_mode_controller.json_mode?
110
128
  end
111
129
 
112
130
  def with_json_mode(enabled = true)
113
- if enabled && eval_mode?
114
- raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
115
- end
116
-
117
- json_coercer.with_json_mode(enabled) { yield }
131
+ argument_mode_controller.with_json_mode(enabled) { yield }
118
132
  end
119
133
 
120
134
  def coerce_json_value(value)
@@ -122,15 +136,15 @@ module Rubycli
122
136
  end
123
137
 
124
138
  def eval_mode?
125
- eval_coercer.eval_mode?
139
+ argument_mode_controller.eval_mode?
126
140
  end
127
141
 
128
- def with_eval_mode(enabled = true)
129
- if enabled && json_mode?
130
- raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
131
- end
142
+ def eval_lax_mode?
143
+ eval_coercer.eval_lax_mode?
144
+ end
132
145
 
133
- eval_coercer.with_eval_mode(enabled) { yield }
146
+ def with_eval_mode(enabled = true, **options)
147
+ argument_mode_controller.with_eval_mode(enabled, **options) { yield }
134
148
  end
135
149
 
136
150
  def coerce_eval_value(value)
@@ -138,25 +152,7 @@ module Rubycli
138
152
  end
139
153
 
140
154
  def apply_argument_coercions(pos_args, kw_args)
141
- if json_mode? && eval_mode?
142
- raise Rubycli::ArgumentError, '--json-args and --eval-args cannot be used together'
143
- end
144
-
145
- if json_mode?
146
- pos_args.map! { |value| coerce_json_value(value) }
147
- kw_args.keys.each do |key|
148
- kw_args[key] = coerce_json_value(kw_args[key])
149
- end
150
- end
151
-
152
- return unless eval_mode?
153
-
154
- pos_args.map! { |value| coerce_eval_value(value) }
155
- kw_args.keys.each do |key|
156
- kw_args[key] = coerce_eval_value(kw_args[key])
157
- end
158
- rescue ArgumentError => e
159
- raise Rubycli::ArgumentError, e.message
155
+ argument_mode_controller.apply_argument_coercions(pos_args, kw_args)
160
156
  end
161
157
 
162
158
  def apply_json_coercion(pos_args, kw_args)
@@ -168,6 +164,44 @@ module Rubycli
168
164
  class Error < Rubycli::Error; end
169
165
  class PreScriptError < Error; end
170
166
 
167
+ ConstantCandidate = Struct.new(
168
+ :name,
169
+ :constant,
170
+ :class_methods,
171
+ :instance_methods,
172
+ keyword_init: true
173
+ ) do
174
+ def callable?(instantiate: false)
175
+ return true if class_methods.any?
176
+
177
+ instantiate && instance_methods.any?
178
+ end
179
+
180
+ def matches?(base_name)
181
+ name.split('::').last == base_name
182
+ end
183
+
184
+ def instance_only?
185
+ instance_methods.any? && class_methods.empty?
186
+ end
187
+
188
+ def summary
189
+ parts = []
190
+ parts << "class: #{format_methods(class_methods)}" if class_methods.any?
191
+ parts << "instance: #{format_methods(instance_methods)}" if instance_methods.any?
192
+ parts << 'no CLI methods' if parts.empty?
193
+ parts.join(' | ')
194
+ end
195
+
196
+ private
197
+
198
+ def format_methods(methods)
199
+ list = methods.first(3).map(&:to_s)
200
+ list << '...' if methods.size > 3
201
+ list.join(', ')
202
+ end
203
+ end
204
+
171
205
  module_function
172
206
 
173
207
  def execute(
@@ -177,31 +211,45 @@ module Rubycli
177
211
  new: false,
178
212
  json: false,
179
213
  eval_args: false,
180
- pre_scripts: []
214
+ eval_lax: false,
215
+ pre_scripts: [],
216
+ constant_mode: nil
181
217
  )
182
218
  raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
183
219
  original_program_name = $PROGRAM_NAME
184
220
  if json && eval_args
185
- raise Error, '--json-args and --eval-args cannot be used together'
221
+ raise Error, '--json-args cannot be combined with --eval-args or --eval-lax'
186
222
  end
187
223
 
188
224
  full_path = find_target_path(target_path)
189
- load full_path
225
+ capture = Rubycli.constant_capture
226
+ capture.capture(full_path) { load full_path }
190
227
  $PROGRAM_NAME = File.basename(full_path)
191
- defined_constants = constants_defined_in_file(full_path)
192
-
193
- constant_name = class_name || infer_class_name(full_path)
194
- target = constantize(
195
- constant_name,
196
- defined_constants: defined_constants,
197
- full_path: full_path
198
- )
228
+ constant_mode ||= Rubycli.environment.constant_resolution_mode
229
+ candidates = build_constant_candidates(full_path, capture.constants_for(full_path))
230
+ defined_constants = candidates.map(&:name)
231
+
232
+ target = if class_name
233
+ constantize(
234
+ class_name,
235
+ defined_constants: defined_constants,
236
+ full_path: full_path
237
+ )
238
+ else
239
+ select_constant_candidate(
240
+ full_path,
241
+ camelize(File.basename(full_path, '.rb')),
242
+ candidates,
243
+ constant_mode,
244
+ instantiate: new
245
+ )
246
+ end
199
247
  runner_target = new ? instantiate_target(target) : target
200
248
  runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
201
249
 
202
250
  original_argv = ARGV.dup
203
251
  ARGV.replace(Array(cli_args).dup)
204
- run_with_modes(runner_target, json: json, eval_args: eval_args)
252
+ run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax)
205
253
  ensure
206
254
  $PROGRAM_NAME = original_program_name if original_program_name
207
255
  ARGV.replace(original_argv) if original_argv
@@ -251,12 +299,6 @@ module Rubycli
251
299
  end
252
300
  end
253
301
 
254
- def infer_class_name(path)
255
- base = File.basename(path, '.rb')
256
- base_const = camelize(base)
257
- detect_constant_for_file(path, base_const) || base_const
258
- end
259
-
260
302
  def camelize(name)
261
303
  name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part|
262
304
  part[0].upcase + part[1..].downcase
@@ -288,106 +330,140 @@ module Rubycli
288
330
  raise Error, "Failed to instantiate target: #{e.message}"
289
331
  end
290
332
 
291
- def run_with_modes(target, json:, eval_args:)
333
+ def run_with_modes(target, json:, eval_args:, eval_lax:)
292
334
  runner = proc { Rubycli.run(target) }
293
335
 
294
336
  if json
295
337
  Rubycli.with_json_mode(true, &runner)
296
338
  elsif eval_args
297
- Rubycli.with_eval_mode(true, &runner)
339
+ Rubycli.with_eval_mode(true, lax: eval_lax, &runner)
298
340
  else
299
341
  runner.call
300
342
  end
301
343
  end
302
344
 
303
- def detect_constant_for_file(path, base_const)
304
- full_path = File.expand_path(path)
305
- candidates = runtime_constant_candidates(full_path, base_const)
306
- candidates.find { |name| constant_defined?(name) }
307
- end
345
+ def build_constant_candidates(path, constant_names)
346
+ normalized = normalize_path(path)
347
+ Array(constant_names).each_with_object([]) do |const_name, memo|
348
+ constant = safe_constant_lookup(const_name)
349
+ next unless constant.is_a?(Module)
308
350
 
309
- def constants_defined_in_file(path)
310
- return [] unless Module.method_defined?(:const_source_location)
351
+ class_methods = collect_defined_methods(constant.singleton_class, normalized)
352
+ instance_methods = collect_defined_methods(constant, normalized)
311
353
 
312
- normalized = File.expand_path(path)
313
- ObjectSpace.each_object(Module).each_with_object([]) do |mod, memo|
314
- mod_name = module_name_for(mod)
315
- next if mod_name.nil?
354
+ memo << ConstantCandidate.new(
355
+ name: const_name,
356
+ constant: constant,
357
+ class_methods: class_methods,
358
+ instance_methods: instance_methods
359
+ )
360
+ end
361
+ end
316
362
 
317
- safe_module_constants(mod).each do |const_name|
318
- location = safe_const_source_location(mod, const_name)
319
- next unless location && location[0]
320
- next unless File.expand_path(location[0]) == normalized
363
+ def collect_defined_methods(owner, normalized_path)
364
+ owner.public_instance_methods(false).each_with_object([]) do |method_name, memo|
365
+ method_object = owner.instance_method(method_name)
366
+ location = method_object.source_location
367
+ next unless location && normalize_path(location[0]) == normalized_path
321
368
 
322
- memo << qualified_constant_name(mod_name, const_name.to_s)
323
- end
324
- end.uniq.sort_by { |name| [name.count('::'), name] }
369
+ memo << method_name
370
+ end
371
+ rescue TypeError
372
+ []
325
373
  end
326
374
 
327
- def runtime_constant_candidates(full_path, base_const)
328
- return [] unless Module.method_defined?(:const_source_location)
375
+ def safe_constant_lookup(name)
376
+ parts = name.split('::').reject(&:empty?)
377
+ context = Object
329
378
 
330
- normalized = File.expand_path(full_path)
331
- ObjectSpace.each_object(Module).each_with_object([]) do |mod, memo|
332
- mod_name = module_name_for(mod)
333
- next unless mod_name
334
- next unless safe_const_defined?(mod, base_const)
379
+ parts.each do |const_name|
380
+ return nil unless context.const_defined?(const_name, false)
335
381
 
336
- location = safe_const_source_location(mod, base_const)
337
- next unless location && File.expand_path(location[0]) == normalized
382
+ context = context.const_get(const_name)
383
+ end
338
384
 
339
- memo << qualified_constant_name(mod_name, base_const)
340
- end.uniq.sort_by { |name| [-name.count('::'), name] }
385
+ context
386
+ rescue NameError
387
+ nil
341
388
  end
342
389
 
343
- def module_name_for(mod)
344
- return '' if mod.equal?(Object)
345
-
346
- name = mod.name
347
- return nil if name.nil? || name.start_with?('#<')
390
+ def select_constant_candidate(path, base_const, candidates, constant_mode, instantiate: false)
391
+ if candidates.empty?
392
+ raise Error, build_missing_constant_message(
393
+ base_const,
394
+ [],
395
+ path,
396
+ details: 'Rubycli could not detect any constants in this file.'
397
+ )
398
+ end
348
399
 
349
- name
350
- end
400
+ matching = candidates.find { |candidate| candidate.matches?(base_const) }
401
+ if matching
402
+ return matching.constant if matching.callable?(instantiate: instantiate)
403
+
404
+ detail = if matching.instance_only?
405
+ "#{matching.name} only defines instance methods in this file. Run with --new to instantiate before invoking CLI commands."
406
+ else
407
+ "#{matching.name} does not define any CLI-callable methods in this file. Add a public class or instance method defined in this file."
408
+ end
409
+ raise Error, build_missing_constant_message(
410
+ base_const,
411
+ candidates.map(&:name),
412
+ path,
413
+ details: detail
414
+ )
415
+ end
351
416
 
352
- def qualified_constant_name(mod_name, base_const)
353
- mod_name.empty? ? base_const : "#{mod_name}::#{base_const}"
354
- end
417
+ callable = candidates.select { |candidate| candidate.callable?(instantiate: instantiate) }
418
+ if callable.empty?
419
+ raise Error, build_missing_constant_message(
420
+ base_const,
421
+ candidates.map(&:name),
422
+ path,
423
+ details: 'Rubycli detected constants in this file, but none define CLI-callable methods. Add a public class or instance method defined in this file.'
424
+ )
425
+ end
355
426
 
356
- def safe_module_constants(mod)
357
- mod.constants(false)
358
- rescue NameError
359
- []
360
- end
427
+ if constant_mode == :auto && callable.size == 1
428
+ return callable.first.constant
429
+ end
361
430
 
362
- def safe_const_defined?(mod, const_name)
363
- mod.const_defined?(const_name, false)
364
- rescue NameError
365
- false
431
+ details = build_ambiguous_constant_details(callable, path)
432
+ raise Error, build_missing_constant_message(
433
+ base_const,
434
+ candidates.map(&:name),
435
+ path,
436
+ details: details
437
+ )
366
438
  end
367
439
 
368
- def safe_const_source_location(mod, const_name)
369
- return nil unless mod.respond_to?(:const_source_location)
440
+ def build_ambiguous_constant_details(candidates, path)
441
+ command_target = File.basename(path)
442
+ if candidates.size == 1
443
+ candidate = candidates.first
444
+ lines = []
445
+ lines << "This file defines #{candidate.name}, but its name does not match #{command_target}."
446
+ lines << 'Re-run by specifying the constant explicitly:'
447
+ lines << " rubycli #{command_target} #{candidate.name} ..."
448
+ lines << 'Alternatively pass --auto-target (or RUBYCLI_AUTO_TARGET=auto) to auto-select it.'
449
+ return lines.join("\n")
450
+ end
370
451
 
371
- mod.const_source_location(const_name, false)
372
- rescue NameError
373
- nil
452
+ lines = ['Multiple CLI-capable constants were found in this file:']
453
+ candidates.each do |candidate|
454
+ hint = candidate.instance_only? ? ' (instance methods only; use --new)' : ''
455
+ lines << " - #{candidate.name}: #{candidate.summary}#{hint}"
456
+ end
457
+ lines << "Specify one explicitly, e.g. rubycli #{command_target} MyRunner"
458
+ lines << 'Or pass --auto-target to allow Rubycli to auto-select a single candidate.'
459
+ lines.join("\n")
374
460
  end
375
461
 
376
- def constant_defined?(name)
377
- parts = name.split('::').reject(&:empty?)
378
- context = Object
379
-
380
- parts.each do |const_name|
381
- return false unless context.const_defined?(const_name, false)
382
-
383
- context = context.const_get(const_name)
384
- end
385
- true
386
- rescue NameError
387
- false
462
+ def normalize_path(path)
463
+ File.expand_path(path.to_s)
388
464
  end
389
465
 
390
- def build_missing_constant_message(name, defined_constants, full_path)
466
+ def build_missing_constant_message(name, defined_constants, full_path, details: nil)
391
467
  lines = ["Could not find definition: #{name}"]
392
468
  lines << " Loaded file: #{File.expand_path(full_path)}" if full_path
393
469
 
@@ -399,6 +475,8 @@ module Rubycli
399
475
  lines << " Rubycli could not detect any publicly exposable constants in this file."
400
476
  end
401
477
 
478
+ lines << " #{details}" if details
479
+
402
480
  lines << " Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI."
403
481
  lines.join("\n")
404
482
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubycli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - inakaegg
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-11-06 00:00:00.000000000 Z
10
+ date: 2025-11-07 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Rubycli turns plain Ruby classes and modules into command-line interfaces
13
13
  by reading their documentation comments, inspired by Python Fire but tailored for
@@ -25,9 +25,15 @@ files:
25
25
  - README.md
26
26
  - exe/rubycli
27
27
  - lib/rubycli.rb
28
+ - lib/rubycli/argument_mode_controller.rb
28
29
  - lib/rubycli/argument_parser.rb
30
+ - lib/rubycli/arguments/token_stream.rb
31
+ - lib/rubycli/arguments/value_converter.rb
29
32
  - lib/rubycli/cli.rb
30
33
  - lib/rubycli/command_line.rb
34
+ - lib/rubycli/constant_capture.rb
35
+ - lib/rubycli/documentation/comment_extractor.rb
36
+ - lib/rubycli/documentation/metadata_parser.rb
31
37
  - lib/rubycli/documentation_registry.rb
32
38
  - lib/rubycli/environment.rb
33
39
  - lib/rubycli/eval_coercer.rb