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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -2
- data/LICENSE +1 -1
- data/README.ja.md +357 -252
- data/README.md +386 -250
- data/examples/documentation_style_showcase.rb +110 -0
- data/examples/fallback_example.rb +14 -0
- data/examples/fallback_example_with_extra_docs.rb +13 -0
- data/examples/hello_app.rb +10 -0
- data/examples/hello_app_with_docs.rb +17 -0
- data/examples/hello_app_with_require.rb +19 -0
- data/examples/mismatched_constant_runner.rb +16 -0
- data/examples/multi_constant_runner.rb +20 -0
- data/examples/new_mode_runner.rb +46 -0
- data/examples/strict_choices_demo.rb +22 -0
- data/examples/typed_arguments_demo.rb +42 -0
- data/lib/rubycli/argument_mode_controller.rb +7 -11
- data/lib/rubycli/argument_parser.rb +295 -96
- data/lib/rubycli/cli.rb +26 -30
- data/lib/rubycli/command_line.rb +151 -86
- data/lib/rubycli/constant_capture.rb +563 -6
- data/lib/rubycli/documentation/metadata_parser.rb +108 -74
- data/lib/rubycli/environment.rb +17 -9
- data/lib/rubycli/eval_coercer.rb +27 -10
- data/lib/rubycli/help_renderer.rb +67 -62
- data/lib/rubycli/json_coercer.rb +2 -0
- data/lib/rubycli/result_emitter.rb +18 -8
- data/lib/rubycli/runner.rb +513 -0
- data/lib/rubycli/type_utils.rb +8 -6
- data/lib/rubycli/types.rb +2 -0
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +7 -456
- metadata +59 -4
|
@@ -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
|
data/lib/rubycli/type_utils.rb
CHANGED
|
@@ -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 ? [
|
|
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
data/lib/rubycli/version.rb
CHANGED