rubycli 0.1.7 → 0.2.0

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,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ # Loads a target file, resolves the class/module to expose, and hands it to
5
+ # Rubycli::CLI. Also implements the --check documentation lint.
6
+ module Runner
7
+ class Error < Rubycli::Error; end
8
+ class PreScriptError < Error; end
9
+
10
+ ConstantCandidate = Struct.new(
11
+ :name,
12
+ :constant,
13
+ :class_methods,
14
+ :instance_methods,
15
+ keyword_init: true
16
+ ) do
17
+ def callable?(instantiate: false)
18
+ return true if class_methods.any?
19
+
20
+ instantiate && instance_methods.any?
21
+ end
22
+
23
+ def matches?(base_name)
24
+ name.split('::').last == base_name
25
+ end
26
+
27
+ def instance_only?
28
+ instance_methods.any? && class_methods.empty?
29
+ end
30
+
31
+ def summary
32
+ parts = []
33
+ parts << "class: #{format_methods(class_methods)}" if class_methods.any?
34
+ parts << "instance: #{format_methods(instance_methods)}" if instance_methods.any?
35
+ parts << 'no CLI methods' if parts.empty?
36
+ parts.join(' | ')
37
+ end
38
+
39
+ private
40
+
41
+ def format_methods(methods)
42
+ list = methods.first(3).map(&:to_s)
43
+ list << '...' if methods.size > 3
44
+ list.join(', ')
45
+ end
46
+ end
47
+
48
+ module_function
49
+
50
+ def execute(
51
+ target_path,
52
+ class_name = nil,
53
+ cli_args = nil,
54
+ new: false,
55
+ new_args: nil,
56
+ json: false,
57
+ eval_args: false,
58
+ eval_lax: false,
59
+ pre_scripts: [],
60
+ constant_mode: nil
61
+ )
62
+ raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
63
+ raise Error, '--json-args cannot be combined with --eval-args or --eval-lax' if json && eval_args
64
+
65
+ original_program_name = nil
66
+ original_argv = nil
67
+ execution = proc do
68
+ runner_target, full_path = prepare_runner_target(
69
+ target_path,
70
+ class_name,
71
+ new: new,
72
+ new_args: new_args,
73
+ json_mode: json,
74
+ eval_mode: eval_args,
75
+ eval_lax: eval_lax,
76
+ pre_scripts: pre_scripts,
77
+ constant_mode: constant_mode
78
+ )
79
+
80
+ original_program_name = $PROGRAM_NAME
81
+ $PROGRAM_NAME = File.basename(full_path)
82
+ original_argv = ARGV.dup
83
+ ARGV.replace(Array(cli_args).dup)
84
+ run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax)
85
+ end
86
+
87
+ if eval_args
88
+ Rubycli.eval_coercer.with_eval_binding(&execution)
89
+ else
90
+ execution.call
91
+ end
92
+ ensure
93
+ $PROGRAM_NAME = original_program_name if original_program_name
94
+ ARGV.replace(original_argv) if original_argv
95
+ end
96
+
97
+ def check(
98
+ target_path,
99
+ class_name = nil,
100
+ new: false,
101
+ new_args: nil,
102
+ pre_scripts: [],
103
+ constant_mode: nil,
104
+ json_mode: false,
105
+ eval_mode: false,
106
+ eval_lax: false
107
+ )
108
+ previous_doc_check = Rubycli.environment.doc_check_mode?
109
+ raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty?
110
+ raise Error, '--check cannot be combined with --pre-script or --init' unless Array(pre_scripts).empty?
111
+
112
+ Rubycli.environment.clear_documentation_issues!
113
+ Rubycli.environment.enable_doc_check!
114
+
115
+ runner_target, full_path = resolve_runner_target(
116
+ target_path,
117
+ class_name,
118
+ constant_mode: constant_mode,
119
+ instantiate: new
120
+ )
121
+
122
+ original_program_name = $PROGRAM_NAME
123
+ $PROGRAM_NAME = File.basename(full_path)
124
+
125
+ documentation_methods_for(runner_target, full_path, instantiate: new).each do |method_obj|
126
+ Rubycli.documentation_registry.metadata_for(method_obj)
127
+ end
128
+
129
+ issues = Rubycli.environment.documentation_issues
130
+ if issues.empty?
131
+ puts 'rubycli documentation OK'
132
+ 0
133
+ else
134
+ warn "[ERROR] rubycli documentation check failed (#{issues.size} issue#{'s' unless issues.size == 1})"
135
+ 1
136
+ end
137
+ ensure
138
+ Rubycli.environment.disable_doc_check! unless previous_doc_check
139
+ $PROGRAM_NAME = original_program_name if original_program_name
140
+ end
141
+
142
+ def apply_pre_scripts(sources, base_target, initial_target)
143
+ Array(sources).reduce(initial_target) do |current_target, source|
144
+ result = evaluate_pre_script(source, base_target, current_target)
145
+ result.nil? ? current_target : result
146
+ end
147
+ end
148
+
149
+ def evaluate_pre_script(source, base_target, current_target)
150
+ code, context = read_pre_script_code(source)
151
+ pre_binding = Object.new.instance_eval { binding }
152
+ pre_binding.local_variable_set(:target, base_target)
153
+ pre_binding.local_variable_set(:current, current_target)
154
+ pre_binding.local_variable_set(:instance, current_target)
155
+
156
+ Rubycli.with_eval_mode(true) do
157
+ pre_binding.eval(code, context)
158
+ end
159
+ rescue Errno::ENOENT
160
+ raise PreScriptError, "Pre-script file not found: #{context}"
161
+ rescue SyntaxError => e
162
+ raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}"
163
+ rescue StandardError => e
164
+ raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}"
165
+ end
166
+
167
+ def read_pre_script_code(source)
168
+ value = source[:value]
169
+ inline_context = source[:context] || '(inline pre-script)'
170
+
171
+ if !value.nil? && File.file?(value)
172
+ [File.read(value), File.expand_path(value)]
173
+ else
174
+ [String(value), inline_context]
175
+ end
176
+ end
177
+
178
+ def find_target_path(path)
179
+ resolved = if File.file?(path)
180
+ path
181
+ elsif File.file?("#{path}.rb")
182
+ "#{path}.rb"
183
+ else
184
+ raise Error, "File not found: #{path}"
185
+ end
186
+
187
+ # Reading the target happens in two places (source analysis and load), so
188
+ # check once here to report a curated error instead of an Errno backtrace.
189
+ raise Error, "File is not readable: #{resolved}" unless File.readable?(resolved)
190
+
191
+ File.expand_path(resolved)
192
+ end
193
+
194
+ def camelize(name)
195
+ name.split(/[^a-zA-Z0-9]+/).reject(&:empty?).map do |part|
196
+ part[0].upcase + part[1..].downcase
197
+ end.join
198
+ end
199
+
200
+ def constantize(name, defined_constants: nil, full_path: nil)
201
+ parts = name.to_s.split('::').reject(&:empty?)
202
+ raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty?
203
+
204
+ parts.reduce(Object) do |context, const_name|
205
+ context.const_get(const_name, false)
206
+ end
207
+ rescue NameError
208
+ message = build_missing_constant_message(name, defined_constants, full_path)
209
+ raise Error.new(message), cause: nil
210
+ end
211
+
212
+ def instantiate_target(target, initializer_args = nil)
213
+ positional_args, keyword_args = Array(initializer_args || [[], {}])
214
+ positional_args ||= []
215
+ keyword_args ||= {}
216
+
217
+ case target
218
+ when Class
219
+ if keyword_args.empty?
220
+ target.new(*positional_args)
221
+ else
222
+ target.new(*positional_args, **keyword_args)
223
+ end
224
+ when Module
225
+ Object.new.extend(target)
226
+ else
227
+ target
228
+ end
229
+ rescue ::ArgumentError, Rubycli::ArgumentError => e
230
+ raise Error, "Failed to instantiate target: #{e.message}"
231
+ end
232
+
233
+ def run_with_modes(target, json:, eval_args:, eval_lax:)
234
+ # Rubycli.run terminates the process, which is what an embedded
235
+ # `Rubycli.run(MyApp)` script wants. Here the status has to travel back to
236
+ # Rubycli::CommandLine.run so callers can decide what to do with it.
237
+ runner = proc { Rubycli.cli.run(target, ARGV.dup, true) }
238
+
239
+ if json
240
+ Rubycli.with_json_mode(true, &runner)
241
+ elsif eval_args
242
+ Rubycli.with_eval_mode(true, lax: eval_lax, reuse_binding: true, &runner)
243
+ else
244
+ runner.call
245
+ end
246
+ end
247
+
248
+ def parse_initializer_arguments(raw_value, target, json_mode:, eval_mode:, eval_lax:)
249
+ return [[], {}] if raw_value.nil?
250
+ if json_mode && eval_mode
251
+ raise Rubycli::ArgumentError,
252
+ '--json-args cannot be combined with --eval-args or --eval-lax'
253
+ end
254
+
255
+ initializer_method = initializer_method_for(target)
256
+ tokens = Array(raw_value)
257
+
258
+ positional_args = []
259
+ keyword_args = {}
260
+
261
+ Rubycli.argument_mode_controller.with_json_mode(json_mode) do
262
+ Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax, reuse_binding: true) do
263
+ positional_args, keyword_args = Rubycli.argument_parser.parse(tokens.dup, initializer_method)
264
+ Rubycli.apply_argument_coercions(positional_args, keyword_args)
265
+ Rubycli.argument_parser.validate_inputs(initializer_method, positional_args, keyword_args)
266
+ end
267
+ end
268
+
269
+ [positional_args || [], keyword_args || {}]
270
+ rescue Rubycli::ArgumentError => e
271
+ raise Runner::Error, "Failed to parse --new arguments: #{e.message}"
272
+ end
273
+
274
+ def initializer_method_for(target)
275
+ return nil unless target.is_a?(Class)
276
+
277
+ begin
278
+ target.instance_method(:initialize)
279
+ rescue NameError
280
+ nil
281
+ end
282
+ end
283
+
284
+ def prepare_runner_target(
285
+ target_path,
286
+ class_name,
287
+ new: false,
288
+ new_args: nil,
289
+ json_mode: false,
290
+ eval_mode: false,
291
+ eval_lax: false,
292
+ pre_scripts: [],
293
+ constant_mode: nil
294
+ )
295
+ # A pre-script builds the object that will be exposed, so instance methods
296
+ # count as commands even without --new; otherwise an instance-only class
297
+ # would be rejected before the pre-script had a chance to instantiate it.
298
+ target, full_path = resolve_runner_target(
299
+ target_path,
300
+ class_name,
301
+ constant_mode: constant_mode,
302
+ instantiate: new || !Array(pre_scripts).empty?
303
+ )
304
+
305
+ initializer_args = if new
306
+ parse_initializer_arguments(new_args, target, json_mode: json_mode,
307
+ eval_mode: eval_mode, eval_lax: eval_lax)
308
+ end
309
+
310
+ runner_target = new ? instantiate_target(target, initializer_args) : target
311
+ runner_target = apply_pre_scripts(pre_scripts, target, runner_target)
312
+ [runner_target, full_path]
313
+ end
314
+
315
+ def resolve_runner_target(target_path, class_name, constant_mode:, instantiate:)
316
+ full_path = find_target_path(target_path)
317
+ capture = Rubycli.constant_capture
318
+ capture.capture(full_path) { load full_path }
319
+ constant_mode ||= Rubycli.environment.constant_resolution_mode
320
+ candidates = build_constant_candidates(full_path, capture.constants_for(full_path))
321
+ defined_constants = candidates.map(&:name)
322
+
323
+ target = if class_name
324
+ constantize(
325
+ class_name,
326
+ defined_constants: defined_constants,
327
+ full_path: full_path
328
+ )
329
+ else
330
+ select_constant_candidate(
331
+ full_path,
332
+ camelize(File.basename(full_path, '.rb')),
333
+ candidates,
334
+ constant_mode,
335
+ instantiate: instantiate
336
+ )
337
+ end
338
+
339
+ [target, full_path]
340
+ end
341
+
342
+ def documentation_methods_for(target, _full_path, instantiate:)
343
+ unless target.is_a?(Module)
344
+ catalog = Rubycli.cli.command_catalog_for(target)
345
+ methods = Array(catalog&.entries).filter_map(&:method)
346
+ if methods.empty? && target.respond_to?(:call)
347
+ callable = begin
348
+ target.method(:call)
349
+ rescue NameError
350
+ nil
351
+ end
352
+ methods << callable if callable
353
+ end
354
+ return methods
355
+ end
356
+
357
+ class_methods = target.singleton_class.public_instance_methods(false)
358
+ .map { |name| target.method(name) }
359
+ .select { |method_obj| Rubycli.cli.exposable_method?(method_obj) }
360
+ return class_methods unless instantiate
361
+
362
+ instance_methods = target.public_instance_methods(false)
363
+ .map { |name| target.instance_method(name) }
364
+ .select { |method_obj| Rubycli.cli.exposable_method?(method_obj) }
365
+ target.is_a?(Class) ? instance_methods + class_methods : instance_methods
366
+ end
367
+
368
+ def build_constant_candidates(path, constant_names)
369
+ normalized = normalize_path(path)
370
+ Array(constant_names).each_with_object([]) do |const_name, memo|
371
+ constant = safe_constant_lookup(const_name)
372
+ next unless constant.is_a?(Module)
373
+
374
+ class_methods = collect_defined_methods(constant.singleton_class, normalized)
375
+ instance_methods = collect_defined_methods(constant, normalized)
376
+
377
+ memo << ConstantCandidate.new(
378
+ name: const_name,
379
+ constant: constant,
380
+ class_methods: class_methods,
381
+ instance_methods: instance_methods
382
+ )
383
+ end
384
+ end
385
+
386
+ def collect_defined_methods(owner, normalized_path)
387
+ owner.public_instance_methods(false).each_with_object([]) do |method_name, memo|
388
+ method_object = owner.instance_method(method_name)
389
+ location = method_object.source_location
390
+ next unless location && normalize_path(location[0]) == normalized_path
391
+
392
+ memo << method_name
393
+ end
394
+ rescue TypeError
395
+ []
396
+ end
397
+
398
+ def safe_constant_lookup(name)
399
+ parts = name.split('::').reject(&:empty?)
400
+ context = Object
401
+
402
+ parts.each do |const_name|
403
+ return nil unless context.const_defined?(const_name, false)
404
+
405
+ context = context.const_get(const_name)
406
+ end
407
+
408
+ context
409
+ rescue NameError
410
+ nil
411
+ end
412
+
413
+ def select_constant_candidate(path, base_const, candidates, constant_mode, instantiate: false)
414
+ if candidates.empty?
415
+ raise Error, build_missing_constant_message(
416
+ base_const,
417
+ [],
418
+ path,
419
+ details: 'Rubycli could not detect any constants in this file.'
420
+ )
421
+ end
422
+
423
+ matching = candidates.find { |candidate| candidate.matches?(base_const) }
424
+ if matching
425
+ return matching.constant if matching.callable?(instantiate: instantiate)
426
+
427
+ detail = if matching.instance_only?
428
+ "#{matching.name} only defines instance methods in this file. Run with --new to instantiate before invoking CLI commands."
429
+ else
430
+ "#{matching.name} does not define any CLI-callable methods in this file. Add a public class or instance method defined in this file."
431
+ end
432
+ raise Error, build_missing_constant_message(
433
+ base_const,
434
+ candidates.map(&:name),
435
+ path,
436
+ details: detail,
437
+ headline: "#{matching.name} cannot be used as a CLI target"
438
+ )
439
+ end
440
+
441
+ callable = candidates.select { |candidate| candidate.callable?(instantiate: instantiate) }
442
+ if callable.empty?
443
+ raise Error, build_missing_constant_message(
444
+ base_const,
445
+ candidates.map(&:name),
446
+ path,
447
+ details: 'Rubycli detected constants in this file, but none define CLI-callable methods. Add a public class or instance method defined in this file.'
448
+ )
449
+ end
450
+
451
+ return callable.first.constant if constant_mode == :auto && callable.size == 1
452
+
453
+ details = build_ambiguous_constant_details(callable, path)
454
+ raise Error, build_missing_constant_message(
455
+ base_const,
456
+ candidates.map(&:name),
457
+ path,
458
+ details: details
459
+ )
460
+ end
461
+
462
+ def build_ambiguous_constant_details(candidates, path)
463
+ command_target = File.basename(path)
464
+ if candidates.size == 1
465
+ candidate = candidates.first
466
+ lines = []
467
+ lines << "This file defines #{candidate.name}, but its name does not match #{command_target}."
468
+ lines << 'Re-run by specifying the constant explicitly:'
469
+ lines << " rubycli #{command_target} #{candidate.name} ..."
470
+ lines << 'Alternatively pass --auto-target (or RUBYCLI_AUTO_TARGET=auto) to auto-select it.'
471
+ return lines.join("\n")
472
+ end
473
+
474
+ lines = ['Multiple CLI-capable constants were found in this file:']
475
+ candidates.each do |candidate|
476
+ hint = candidate.instance_only? ? ' (instance methods only; use --new)' : ''
477
+ lines << " - #{candidate.name}: #{candidate.summary}#{hint}"
478
+ end
479
+ lines << "Specify one explicitly, e.g. rubycli #{command_target} MyRunner"
480
+ lines << 'Or pass --auto-target to allow Rubycli to auto-select a single candidate.'
481
+ lines.join("\n")
482
+ end
483
+
484
+ def normalize_path(path)
485
+ File.expand_path(path.to_s)
486
+ end
487
+
488
+ # +headline+ replaces the default first line when the constant was found but
489
+ # cannot be exposed, so the message does not claim it is missing.
490
+ def build_missing_constant_message(name, defined_constants, full_path, details: nil, headline: nil)
491
+ lines = [headline || "Could not find definition: #{name}"]
492
+ lines << ''
493
+ lines << "Loaded file: #{File.expand_path(full_path)}" if full_path
494
+
495
+ if defined_constants && !defined_constants.empty?
496
+ sample = defined_constants.first(5)
497
+ suffix = defined_constants.size > sample.size ? " ... (#{defined_constants.size} total)" : ''
498
+ lines << "Constants found in this file: #{sample.join(', ')}#{suffix}"
499
+ else
500
+ lines << 'Rubycli could not detect any publicly exposable constants in this file.'
501
+ end
502
+
503
+ if details
504
+ lines << ''
505
+ lines << details
506
+ end
507
+
508
+ lines << ''
509
+ lines << 'Hint: Ensure the CLASS_OR_MODULE argument is correct when invoking the CLI.'
510
+ lines.join("\n")
511
+ end
512
+ end
513
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rubycli
2
4
  module TypeUtils
3
5
  module_function
@@ -41,7 +43,7 @@ module Rubycli
41
43
  optional = trimmed.start_with?('[') && trimmed.end_with?(']')
42
44
  core = optional ? trimmed[1..-2].strip : trimmed.dup
43
45
  sanitized = core.gsub(/\[|\]/, '')
44
- sanitized = sanitized.gsub(/\.\.\./, '')
46
+ sanitized = sanitized.gsub('...', '')
45
47
  list = sanitized.include?(',') || core.include?('...') || core.include?('[,')
46
48
  token = sanitized.split(',').first.to_s.strip
47
49
  token = token.gsub(/[^A-Za-z0-9_]/, '')
@@ -58,7 +60,7 @@ module Rubycli
58
60
  inferred = if placeholder_info[:list]
59
61
  include_optional_boolean ? ['Boolean', 'String[]'] : ['String[]']
60
62
  else
61
- include_optional_boolean ? ['Boolean', 'String'] : ['String']
63
+ include_optional_boolean ? %w[Boolean String] : ['String']
62
64
  end
63
65
  working.concat(inferred)
64
66
  elsif placeholder_info[:list]
@@ -76,7 +78,7 @@ module Rubycli
76
78
  working = working.map do |type|
77
79
  next type if type.nil? || type.empty?
78
80
 
79
- if boolean_type?(type) || nil_type?(type)
81
+ if (boolean_type?(type) && placeholder_info[:optional]) || nil_type?(type)
80
82
  type
81
83
  elsif type.end_with?('[]') || type.start_with?('Array<') || type.casecmp('Array').zero?
82
84
  array_type_present = true
@@ -87,9 +89,7 @@ module Rubycli
87
89
  end
88
90
  end
89
91
 
90
- unless array_type_present
91
- working << 'String[]'
92
- end
92
+ working << 'String[]' unless array_type_present
93
93
  end
94
94
 
95
95
  working.compact.uniq
@@ -113,11 +113,13 @@ module Rubycli
113
113
 
114
114
  def normalize_long_option(option)
115
115
  return nil unless option
116
+
116
117
  option.start_with?('--') ? option : "--#{option.delete_prefix('-')}"
117
118
  end
118
119
 
119
120
  def normalize_short_option(option)
120
121
  return nil unless option
122
+
121
123
  option.start_with?('-') ? option : "-#{option}"
122
124
  end
123
125
 
data/lib/rubycli/types.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rubycli
2
4
  OptionDefinition = Struct.new(
3
5
  :keyword, :long, :short, :value_name, :types, :description, :requires_value,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rubycli
4
- VERSION = '0.1.7'
4
+ VERSION = '0.2.0'
5
5
  end