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.
data/lib/rubycli/cli.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'set'
2
4
 
3
5
  module Rubycli
@@ -76,6 +78,15 @@ module Rubycli
76
78
  command_catalog(target)
77
79
  end
78
80
 
81
+ # True when the method can be exposed as a CLI command. Rubycli::Runner uses
82
+ # this to lint the same methods the CLI would expose.
83
+ def exposable_method?(method_obj)
84
+ return false unless method_obj&.source_location
85
+ return false if accessor_generated_method?(method_obj)
86
+
87
+ true
88
+ end
89
+
79
90
  private
80
91
 
81
92
  def debug_log(message)
@@ -101,13 +112,12 @@ module Rubycli
101
112
 
102
113
  def handle_missing_method(target, catalog, command, args, cli_mode)
103
114
  if target.respond_to?(:call)
104
- debug_log "Target is callable, treating as lambda/proc"
115
+ debug_log 'Target is callable, treating as lambda/proc'
105
116
  args.unshift(command)
106
117
  execute_callable(target, args, command, cli_mode)
107
118
  else
108
- error_msg = "Command '#{command}' is not available."
109
- puts error_msg
110
- @help_renderer.print_help(target, catalog)
119
+ warn "Command '#{command}' is not available."
120
+ @help_renderer.print_help(target, catalog, io: $stderr)
111
121
  1
112
122
  end
113
123
  end
@@ -134,22 +144,15 @@ module Rubycli
134
144
  end
135
145
  end
136
146
 
137
- def execute_parameterless_method(method_obj, command, args, cli_mode)
147
+ def execute_parameterless_method(method_obj, command, args, _cli_mode)
138
148
  if help_requested_for_parameterless?(args)
139
149
  puts usage_for_method(command, method_obj)
140
150
  return 0
141
151
  end
142
152
 
143
- begin
144
- result = method_obj.call
145
- debug_log "Parameterless method returned: #{result.inspect}"
146
- if result
147
- return run(result, args, false)
148
- end
149
- 0
150
- rescue StandardError => e
151
- handle_execution_error(e, command, method_obj, [], {}, cli_mode)
152
- end
153
+ warn "Command '#{command}' does not accept arguments."
154
+ warn usage_for_method(command, method_obj)
155
+ 1
153
156
  end
154
157
 
155
158
  def execute_method_with_params(method_obj, command, args, cli_mode)
@@ -185,13 +188,11 @@ module Rubycli
185
188
  end
186
189
 
187
190
  def handle_execution_error(error, command, method_obj, pos_args, kw_args, cli_mode)
188
- if cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
189
- puts "Error: #{error.message}"
190
- puts usage_for_method(command, method_obj)
191
- 1
192
- else
193
- raise error
194
- end
191
+ raise error unless cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
192
+
193
+ warn "Error: #{error.message}"
194
+ warn usage_for_method(command, method_obj)
195
+ 1
195
196
  end
196
197
 
197
198
  def usage_error?(error)
@@ -284,7 +285,9 @@ module Rubycli
284
285
 
285
286
  def collect_class_methods(target)
286
287
  klass = target.class
287
- collect_method_map(klass.singleton_class.public_instance_methods(false)) { |name| safe_method_lookup(klass, name) }
288
+ collect_method_map(klass.singleton_class.public_instance_methods(false)) do |name|
289
+ safe_method_lookup(klass, name)
290
+ end
288
291
  end
289
292
 
290
293
  def collect_method_map(method_names)
@@ -302,13 +305,6 @@ module Rubycli
302
305
  nil
303
306
  end
304
307
 
305
- def exposable_method?(method_obj)
306
- return false unless method_obj&.source_location
307
- return false if accessor_generated_method?(method_obj)
308
-
309
- true
310
- end
311
-
312
308
  def build_alias_map(entries)
313
309
  entries.each_with_object({}) do |entry, memo|
314
310
  entry.all_commands.each do |command|
@@ -6,20 +6,22 @@ module Rubycli
6
6
  Usage: rubycli [--new|-n[=<value>]] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] [--strict] [--check|-c] <target-path> [<class-or-module>] [-- <cli-args>...]
7
7
 
8
8
  Examples:
9
- rubycli scripts/sample_runner.rb echo --message hello
10
- rubycli scripts/sample_runner.rb AlternateRunner greet --name Ruby
11
- rubycli --new lib/akiya_fetcher.rb fetch_simplified_html https://example.com
12
- rubycli --json-args --new='["a","b"]' examples/new_mode_runner.rb run --mode summary --options '{"source":"json"}'
9
+ rubycli path/to/greeter.rb greet Hanako
10
+ rubycli path/to/multi_runner.rb AlternateRunner greet --name Ruby
11
+ rubycli --auto-target path/to/mismatched_runner.rb greet Hanako
12
+ rubycli --new='["a","b"]' path/to/collection_runner.rb run --mode summary
13
+ rubycli --json-args --new='["a","b"]' path/to/collection_runner.rb run --mode '"summary"'
13
14
 
14
15
  Options:
15
- --new, -n [<value>] Instantiate the class/module before invoking CLI commands; optional constructor arguments can follow (array/hash recommended; respects --json-args/--eval-args/--eval-lax)
16
+ --new, -n [<value>] Instantiate the class/module before invoking CLI commands; one optional literal can follow and is bound to the constructor's first parameter (respects --json-args/--eval-args/--eval-lax; use --pre-script for several or keyword arguments)
16
17
  --pre-script=<src> Evaluate Ruby code and use its result as the exposed target (--init alias; also accepts space-separated form)
17
- --json-args, -j Parse all following arguments strictly as JSON (no YAML literals)
18
- --eval-args, -e Evaluate following arguments as Ruby code
18
+ --json-args, -j Parse all following arguments strictly as JSON (no YAML literals; even plain words need quoting)
19
+ --eval-args, -e Evaluate following arguments as Ruby code (every argument must be valid Ruby)
19
20
  --eval-lax, -E Evaluate as Ruby but fall back to raw strings when parsing fails
20
21
  --auto-target, -a Auto-select the only callable constant when names don't match
21
22
  --strict Enforce documented input types/choices (invalid values abort)
22
23
  --check, -c Validate documentation/comments without executing commands
24
+ --help, -h Show this message (the bare word `help` works too)
23
25
  (Note: --json-args cannot be combined with --eval-args or --eval-lax)
24
26
  (Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
25
27
 
@@ -29,90 +31,104 @@ module Rubycli
29
31
  <cli-args> are forwarded to Rubycli unchanged.
30
32
  USAGE
31
33
 
34
+ # Flags collected before the target path; forwarded to Rubycli::Runner.
35
+ Options = Struct.new(
36
+ :new_flag,
37
+ :new_args,
38
+ :json_mode,
39
+ :eval_mode,
40
+ :eval_lax,
41
+ :constant_mode,
42
+ :check_mode,
43
+ :pre_scripts,
44
+ keyword_init: true
45
+ ) do
46
+ def self.defaults
47
+ new(
48
+ new_flag: false,
49
+ new_args: nil,
50
+ json_mode: false,
51
+ eval_mode: false,
52
+ eval_lax: false,
53
+ constant_mode: nil,
54
+ check_mode: false,
55
+ pre_scripts: []
56
+ )
57
+ end
58
+ end
59
+
32
60
  module_function
33
61
 
34
62
  def run(argv = ARGV)
63
+ previous_state = environment_state
35
64
  args = Array(argv).dup
36
65
  Rubycli.environment.enable_print_result!
66
+ return print_usage(1) if args.empty?
37
67
 
38
- if args.empty?
39
- $stdout.puts(USAGE)
40
- return 1
41
- end
68
+ options = Options.defaults
69
+ status = parse_flags!(args, options)
70
+ return status if status
71
+ return print_usage(1) if args.empty?
42
72
 
43
- new_flag = false
44
- new_args_expr = nil
45
- json_mode = false
46
- eval_mode = false
47
- eval_lax_mode = false
48
- constant_mode = nil
49
- check_mode = false
50
- pre_script_sources = []
73
+ target_path, class_or_module = extract_target!(args)
74
+ status = reject_conflicting_flags(options, args)
75
+ return status if status
51
76
 
52
- loop do
53
- arg = args.first
54
- break unless arg
77
+ Rubycli.environment.clear_documentation_issues!
78
+ dispatch(target_path, class_or_module, args, options)
79
+ rescue Rubycli::Runner::Error => e
80
+ warn "[ERROR] #{e.message}"
81
+ 1
82
+ ensure
83
+ restore_environment(previous_state)
84
+ end
55
85
 
56
- case arg
86
+ # Consumes leading flags from +args+ and fills +options+.
87
+ # Returns an exit status when the CLI should stop, otherwise nil.
88
+ def parse_flags!(args, options)
89
+ until args.empty?
90
+ case args.first
57
91
  when '-h', '--help', 'help'
58
- $stdout.puts(USAGE)
59
- return 0
92
+ return print_usage(0)
60
93
  when /\A--new(?:=(.+))?\z/
61
- new_flag = true
62
- new_args_expr = Regexp.last_match(1)
94
+ inline_value = Regexp.last_match(1)
63
95
  args.shift
64
- if new_args_expr.nil?
65
- possible = args.first
66
- if possible && !possible.start_with?('-') && likely_new_args_value?(possible)
67
- new_args_expr = args.shift
68
- end
69
- end
96
+ consume_new_arguments!(args, options, inline_value)
70
97
  when '-n'
71
- new_flag = true
72
98
  args.shift
73
- possible = args.first
74
- if possible && !possible.start_with?('-') && likely_new_args_value?(possible)
75
- new_args_expr = args.shift
76
- end
99
+ consume_new_arguments!(args, options, nil)
77
100
  when /\A--pre-script=(.+)\z/, /\A--init=(.+)\z/
78
- label = Regexp.last_match(0).start_with?('--pre-script') ? '--pre-script' : '--init'
79
- expr = Regexp.last_match(1)
80
- pre_script_sources << { value: expr, context: "(inline #{label})" }
101
+ flag = Regexp.last_match(0).start_with?('--pre-script') ? '--pre-script' : '--init'
102
+ options.pre_scripts << { value: Regexp.last_match(1), context: "(inline #{flag})" }
81
103
  args.shift
82
104
  when '--pre-script', '--init'
83
- flag = args.shift
84
- src = args.shift
85
- unless src
86
- warn "[ERROR] #{flag} requires a file path or inline Ruby code"
87
- return 1
88
- end
89
- context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
90
- pre_script_sources << { value: src, context: context }
105
+ status = consume_pre_script_source!(args, options)
106
+ return status if status
91
107
  when '--json-args', '-j'
92
- json_mode = true
108
+ options.json_mode = true
93
109
  args.shift
94
110
  when '--eval-args', '-e'
95
- eval_mode = true
111
+ options.eval_mode = true
96
112
  args.shift
97
113
  when '--eval-lax', '-E'
98
- eval_mode = true
99
- eval_lax_mode = true
114
+ options.eval_mode = true
115
+ options.eval_lax = true
100
116
  args.shift
101
117
  when '--strict'
102
118
  Rubycli.environment.enable_strict_input!
103
119
  args.shift
104
120
  when '--check', '-c'
105
- check_mode = true
121
+ options.check_mode = true
106
122
  Rubycli.environment.enable_doc_check!
107
123
  args.shift
108
124
  when '--print-result'
109
125
  args.shift
110
126
  when '--debug'
111
127
  args.shift
112
- warn "[ERROR] --debug flag has been removed; set RUBYCLI_DEBUG=true instead."
128
+ warn '[ERROR] --debug flag has been removed; set RUBYCLI_DEBUG=true instead.'
113
129
  return 1
114
130
  when '--auto-target', '-a'
115
- constant_mode = :auto
131
+ options.constant_mode = :auto
116
132
  args.shift
117
133
  when '--'
118
134
  args.shift
@@ -122,67 +138,116 @@ module Rubycli
122
138
  end
123
139
  end
124
140
 
125
- if args.empty?
126
- $stdout.puts(USAGE)
141
+ nil
142
+ end
143
+
144
+ def consume_new_arguments!(args, options, inline_value)
145
+ options.new_flag = true
146
+ options.new_args = inline_value
147
+ return unless inline_value.nil?
148
+
149
+ candidate = args.first
150
+ return unless candidate && !candidate.start_with?('-') && likely_new_args_value?(candidate)
151
+
152
+ options.new_args = args.shift
153
+ end
154
+
155
+ def consume_pre_script_source!(args, options)
156
+ flag = args.shift
157
+ source = args.shift
158
+ unless source
159
+ warn "[ERROR] #{flag} requires a file path or inline Ruby code"
127
160
  return 1
128
161
  end
129
162
 
163
+ context = File.file?(source) ? File.expand_path(source) : "(inline #{flag})"
164
+ options.pre_scripts << { value: source, context: context }
165
+ nil
166
+ end
167
+
168
+ # Removes the target path and the optional explicit constant from +args+.
169
+ def extract_target!(args)
130
170
  target_path = args.shift
131
171
  class_or_module = nil
132
- possible_class = args.first
133
- if possible_class && possible_class != '--' && possible_class.match?(/\A[A-Z]/)
134
- class_or_module = args.shift
135
- end
172
+ candidate = args.first
173
+ class_or_module = args.shift if candidate && candidate != '--' && candidate.match?(/\A[A-Z]/)
136
174
  args.shift if args.first == '--'
175
+ [target_path, class_or_module]
176
+ end
137
177
 
138
- if json_mode && eval_mode
178
+ # Returns an exit status when the flag combination is unusable, otherwise nil.
179
+ def reject_conflicting_flags(options, args)
180
+ if options.json_mode && options.eval_mode
139
181
  warn '[ERROR] --json-args cannot be combined with --eval-args or --eval-lax'
140
182
  return 1
141
183
  end
142
184
 
143
- if check_mode && (json_mode || eval_mode)
185
+ return nil unless options.check_mode
186
+
187
+ if options.json_mode || options.eval_mode
144
188
  warn '[ERROR] --check cannot be combined with --json-args or --eval-args'
145
189
  return 1
146
190
  end
147
191
 
148
- if check_mode && !args.empty?
192
+ unless args.empty?
149
193
  warn '[ERROR] --check does not accept command arguments'
150
194
  return 1
151
195
  end
152
196
 
153
- Rubycli.environment.clear_documentation_issues!
197
+ nil
198
+ end
154
199
 
155
- if check_mode
200
+ def dispatch(target_path, class_or_module, args, options)
201
+ if options.check_mode
156
202
  return Rubycli::Runner.check(
157
203
  target_path,
158
204
  class_or_module,
159
- new: new_flag,
160
- new_args: new_args_expr,
161
- pre_scripts: pre_script_sources,
162
- constant_mode: constant_mode
205
+ new: options.new_flag,
206
+ new_args: options.new_args,
207
+ pre_scripts: options.pre_scripts,
208
+ constant_mode: options.constant_mode
163
209
  )
164
210
  end
165
211
 
166
- Rubycli::Runner.execute(
212
+ status = Rubycli::Runner.execute(
167
213
  target_path,
168
214
  class_or_module,
169
215
  args,
170
- new: new_flag,
171
- new_args: new_args_expr,
172
- json: json_mode,
173
- eval_args: eval_mode,
174
- eval_lax: eval_lax_mode,
175
- pre_scripts: pre_script_sources,
176
- constant_mode: constant_mode
216
+ new: options.new_flag,
217
+ new_args: options.new_args,
218
+ json: options.json_mode,
219
+ eval_args: options.eval_mode,
220
+ eval_lax: options.eval_lax,
221
+ pre_scripts: options.pre_scripts,
222
+ constant_mode: options.constant_mode
177
223
  )
178
224
 
179
- 0
180
- rescue Rubycli::Runner::PreScriptError => e
181
- warn "[ERROR] #{e.message}"
182
- 1
183
- rescue Rubycli::Runner::Error => e
184
- warn "[ERROR] #{e.message}"
185
- 1
225
+ # The runner reports the CLI status; treat anything else as success so the
226
+ # process always exits with a defined status.
227
+ status.is_a?(Integer) ? status : 0
228
+ end
229
+
230
+ # Usage requested with --help goes to stdout; usage shown because the
231
+ # invocation was wrong is a diagnostic and belongs on stderr.
232
+ def print_usage(status)
233
+ (status.zero? ? $stdout : $stderr).puts(USAGE)
234
+ status
235
+ end
236
+
237
+ def environment_state
238
+ environment = Rubycli.environment
239
+ {
240
+ doc_check: environment.doc_check_mode?,
241
+ strict_input: environment.strict_input?,
242
+ print_result: environment.print_result?
243
+ }
244
+ end
245
+
246
+ def restore_environment(state)
247
+ environment = Rubycli.environment
248
+ state[:doc_check] ? environment.enable_doc_check! : environment.disable_doc_check!
249
+ state[:strict_input] ? environment.enable_strict_input! : environment.disable_strict_input!
250
+ state[:print_result] ? environment.enable_print_result! : environment.disable_print_result!
186
251
  end
187
252
 
188
253
  def likely_new_args_value?(token)