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.
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,36 +211,81 @@ 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
- original_program_name = $PROGRAM_NAME
184
219
  if json && eval_args
185
- raise Error, '--json-args and --eval-args cannot be used together'
220
+ raise Error, '--json-args cannot be combined with --eval-args or --eval-lax'
186
221
  end
187
222
 
188
- full_path = find_target_path(target_path)
189
- load full_path
190
- $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
223
+ runner_target, full_path = prepare_runner_target(
224
+ target_path,
225
+ class_name,
226
+ new: new,
227
+ pre_scripts: pre_scripts,
228
+ constant_mode: constant_mode
198
229
  )
199
- runner_target = new ? instantiate_target(target) : target
200
- runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
201
230
 
231
+ original_program_name = $PROGRAM_NAME
232
+ original_argv = nil
233
+ $PROGRAM_NAME = File.basename(full_path)
202
234
  original_argv = ARGV.dup
203
235
  ARGV.replace(Array(cli_args).dup)
204
- run_with_modes(runner_target, json: json, eval_args: eval_args)
236
+ run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax)
205
237
  ensure
206
238
  $PROGRAM_NAME = original_program_name if original_program_name
207
239
  ARGV.replace(original_argv) if original_argv
208
240
  end
209
241
 
242
+ def check(
243
+ target_path,
244
+ class_name = nil,
245
+ new: false,
246
+ pre_scripts: [],
247
+ constant_mode: nil
248
+ )
249
+ raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
250
+ previous_doc_check = Rubycli.environment.doc_check_mode?
251
+ Rubycli.environment.clear_documentation_issues!
252
+ Rubycli.environment.enable_doc_check!
253
+
254
+ runner_target, full_path = prepare_runner_target(
255
+ target_path,
256
+ class_name,
257
+ new: new,
258
+ pre_scripts: pre_scripts,
259
+ constant_mode: constant_mode
260
+ )
261
+
262
+ original_program_name = $PROGRAM_NAME
263
+ $PROGRAM_NAME = File.basename(full_path)
264
+
265
+ catalog = Rubycli.cli.command_catalog_for(runner_target)
266
+ Array(catalog&.entries).each do |entry|
267
+ method_obj = entry&.method
268
+ Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
269
+ end
270
+
271
+ if catalog&.entries&.empty? && runner_target.respond_to?(:call)
272
+ method_obj = runner_target.method(:call) rescue nil
273
+ Rubycli.documentation_registry.metadata_for(method_obj) if method_obj
274
+ end
275
+
276
+ issues = Rubycli.environment.documentation_issues
277
+ if issues.empty?
278
+ puts 'rubycli documentation OK'
279
+ 0
280
+ else
281
+ warn "[ERROR] rubycli documentation check failed (#{issues.size} issue#{issues.size == 1 ? '' : 's'})"
282
+ 1
283
+ end
284
+ ensure
285
+ Rubycli.environment.disable_doc_check! unless previous_doc_check
286
+ $PROGRAM_NAME = original_program_name if original_program_name
287
+ end
288
+
210
289
  def apply_pre_scripts(sources, base_target, initial_target)
211
290
  Array(sources).reduce(initial_target) do |current_target, source|
212
291
  result = evaluate_pre_script(source, base_target, current_target)
@@ -251,12 +330,6 @@ module Rubycli
251
330
  end
252
331
  end
253
332
 
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
333
  def camelize(name)
261
334
  name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map { |part|
262
335
  part[0].upcase + part[1..].downcase
@@ -288,106 +361,175 @@ module Rubycli
288
361
  raise Error, "Failed to instantiate target: #{e.message}"
289
362
  end
290
363
 
291
- def run_with_modes(target, json:, eval_args:)
364
+ def run_with_modes(target, json:, eval_args:, eval_lax:)
292
365
  runner = proc { Rubycli.run(target) }
293
366
 
294
367
  if json
295
368
  Rubycli.with_json_mode(true, &runner)
296
369
  elsif eval_args
297
- Rubycli.with_eval_mode(true, &runner)
370
+ Rubycli.with_eval_mode(true, lax: eval_lax, &runner)
298
371
  else
299
372
  runner.call
300
373
  end
301
374
  end
302
375
 
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
376
+ def prepare_runner_target(
377
+ target_path,
378
+ class_name,
379
+ new: false,
380
+ pre_scripts: [],
381
+ constant_mode: nil
382
+ )
383
+ full_path = find_target_path(target_path)
384
+ capture = Rubycli.constant_capture
385
+ capture.capture(full_path) { load full_path }
386
+ constant_mode ||= Rubycli.environment.constant_resolution_mode
387
+ candidates = build_constant_candidates(full_path, capture.constants_for(full_path))
388
+ defined_constants = candidates.map(&:name)
389
+
390
+ target = if class_name
391
+ constantize(
392
+ class_name,
393
+ defined_constants: defined_constants,
394
+ full_path: full_path
395
+ )
396
+ else
397
+ select_constant_candidate(
398
+ full_path,
399
+ camelize(File.basename(full_path, '.rb')),
400
+ candidates,
401
+ constant_mode,
402
+ instantiate: new
403
+ )
404
+ end
308
405
 
309
- def constants_defined_in_file(path)
310
- return [] unless Module.method_defined?(:const_source_location)
406
+ runner_target = new ? instantiate_target(target) : target
407
+ runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
408
+ [runner_target, full_path]
409
+ end
311
410
 
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?
411
+ def build_constant_candidates(path, constant_names)
412
+ normalized = normalize_path(path)
413
+ Array(constant_names).each_with_object([]) do |const_name, memo|
414
+ constant = safe_constant_lookup(const_name)
415
+ next unless constant.is_a?(Module)
316
416
 
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
417
+ class_methods = collect_defined_methods(constant.singleton_class, normalized)
418
+ instance_methods = collect_defined_methods(constant, normalized)
321
419
 
322
- memo << qualified_constant_name(mod_name, const_name.to_s)
323
- end
324
- end.uniq.sort_by { |name| [name.count('::'), name] }
420
+ memo << ConstantCandidate.new(
421
+ name: const_name,
422
+ constant: constant,
423
+ class_methods: class_methods,
424
+ instance_methods: instance_methods
425
+ )
426
+ end
325
427
  end
326
428
 
327
- def runtime_constant_candidates(full_path, base_const)
328
- return [] unless Module.method_defined?(:const_source_location)
329
-
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)
429
+ def collect_defined_methods(owner, normalized_path)
430
+ owner.public_instance_methods(false).each_with_object([]) do |method_name, memo|
431
+ method_object = owner.instance_method(method_name)
432
+ location = method_object.source_location
433
+ next unless location && normalize_path(location[0]) == normalized_path
335
434
 
336
- location = safe_const_source_location(mod, base_const)
337
- next unless location && File.expand_path(location[0]) == normalized
338
-
339
- memo << qualified_constant_name(mod_name, base_const)
340
- end.uniq.sort_by { |name| [-name.count('::'), name] }
435
+ memo << method_name
436
+ end
437
+ rescue TypeError
438
+ []
341
439
  end
342
440
 
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?('#<')
441
+ def safe_constant_lookup(name)
442
+ parts = name.split('::').reject(&:empty?)
443
+ context = Object
348
444
 
349
- name
350
- end
445
+ parts.each do |const_name|
446
+ return nil unless context.const_defined?(const_name, false)
351
447
 
352
- def qualified_constant_name(mod_name, base_const)
353
- mod_name.empty? ? base_const : "#{mod_name}::#{base_const}"
354
- end
448
+ context = context.const_get(const_name)
449
+ end
355
450
 
356
- def safe_module_constants(mod)
357
- mod.constants(false)
451
+ context
358
452
  rescue NameError
359
- []
453
+ nil
360
454
  end
361
455
 
362
- def safe_const_defined?(mod, const_name)
363
- mod.const_defined?(const_name, false)
364
- rescue NameError
365
- false
366
- end
456
+ def select_constant_candidate(path, base_const, candidates, constant_mode, instantiate: false)
457
+ if candidates.empty?
458
+ raise Error, build_missing_constant_message(
459
+ base_const,
460
+ [],
461
+ path,
462
+ details: 'Rubycli could not detect any constants in this file.'
463
+ )
464
+ end
367
465
 
368
- def safe_const_source_location(mod, const_name)
369
- return nil unless mod.respond_to?(:const_source_location)
466
+ matching = candidates.find { |candidate| candidate.matches?(base_const) }
467
+ if matching
468
+ return matching.constant if matching.callable?(instantiate: instantiate)
469
+
470
+ detail = if matching.instance_only?
471
+ "#{matching.name} only defines instance methods in this file. Run with --new to instantiate before invoking CLI commands."
472
+ else
473
+ "#{matching.name} does not define any CLI-callable methods in this file. Add a public class or instance method defined in this file."
474
+ end
475
+ raise Error, build_missing_constant_message(
476
+ base_const,
477
+ candidates.map(&:name),
478
+ path,
479
+ details: detail
480
+ )
481
+ end
370
482
 
371
- mod.const_source_location(const_name, false)
372
- rescue NameError
373
- nil
374
- end
483
+ callable = candidates.select { |candidate| candidate.callable?(instantiate: instantiate) }
484
+ if callable.empty?
485
+ raise Error, build_missing_constant_message(
486
+ base_const,
487
+ candidates.map(&:name),
488
+ path,
489
+ details: 'Rubycli detected constants in this file, but none define CLI-callable methods. Add a public class or instance method defined in this file.'
490
+ )
491
+ end
375
492
 
376
- def constant_defined?(name)
377
- parts = name.split('::').reject(&:empty?)
378
- context = Object
493
+ if constant_mode == :auto && callable.size == 1
494
+ return callable.first.constant
495
+ end
379
496
 
380
- parts.each do |const_name|
381
- return false unless context.const_defined?(const_name, false)
497
+ details = build_ambiguous_constant_details(callable, path)
498
+ raise Error, build_missing_constant_message(
499
+ base_const,
500
+ candidates.map(&:name),
501
+ path,
502
+ details: details
503
+ )
504
+ end
382
505
 
383
- context = context.const_get(const_name)
506
+ def build_ambiguous_constant_details(candidates, path)
507
+ command_target = File.basename(path)
508
+ if candidates.size == 1
509
+ candidate = candidates.first
510
+ lines = []
511
+ lines << "This file defines #{candidate.name}, but its name does not match #{command_target}."
512
+ lines << 'Re-run by specifying the constant explicitly:'
513
+ lines << " rubycli #{command_target} #{candidate.name} ..."
514
+ lines << 'Alternatively pass --auto-target (or RUBYCLI_AUTO_TARGET=auto) to auto-select it.'
515
+ return lines.join("\n")
384
516
  end
385
- true
386
- rescue NameError
387
- false
517
+
518
+ lines = ['Multiple CLI-capable constants were found in this file:']
519
+ candidates.each do |candidate|
520
+ hint = candidate.instance_only? ? ' (instance methods only; use --new)' : ''
521
+ lines << " - #{candidate.name}: #{candidate.summary}#{hint}"
522
+ end
523
+ lines << "Specify one explicitly, e.g. rubycli #{command_target} MyRunner"
524
+ lines << 'Or pass --auto-target to allow Rubycli to auto-select a single candidate.'
525
+ lines.join("\n")
388
526
  end
389
527
 
390
- def build_missing_constant_message(name, defined_constants, full_path)
528
+ def normalize_path(path)
529
+ File.expand_path(path.to_s)
530
+ end
531
+
532
+ def build_missing_constant_message(name, defined_constants, full_path, details: nil)
391
533
  lines = ["Could not find definition: #{name}"]
392
534
  lines << " Loaded file: #{File.expand_path(full_path)}" if full_path
393
535
 
@@ -399,6 +541,8 @@ module Rubycli
399
541
  lines << " Rubycli could not detect any publicly exposable constants in this file."
400
542
  end
401
543
 
544
+ lines << " #{details}" if details
545
+
402
546
  lines << " Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI."
403
547
  lines.join("\n")
404
548
  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.5
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-09 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