rubycli 0.1.6 → 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|
@@ -3,22 +3,25 @@
3
3
  module Rubycli
4
4
  module CommandLine
5
5
  USAGE = <<~USAGE
6
- Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] [--strict] [--check|-c] <target-path> [<class-or-module>] [-- <cli-args>...]
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
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"'
12
14
 
13
15
  Options:
14
- --new, -n Instantiate the class/module before invoking CLI commands
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)
15
17
  --pre-script=<src> Evaluate Ruby code and use its result as the exposed target (--init alias; also accepts space-separated form)
16
- --json-args, -j Parse all following arguments strictly as JSON (no YAML literals)
17
- --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)
18
20
  --eval-lax, -E Evaluate as Ruby but fall back to raw strings when parsing fails
19
21
  --auto-target, -a Auto-select the only callable constant when names don't match
20
22
  --strict Enforce documented input types/choices (invalid values abort)
21
23
  --check, -c Validate documentation/comments without executing commands
24
+ --help, -h Show this message (the bare word `help` works too)
22
25
  (Note: --json-args cannot be combined with --eval-args or --eval-lax)
23
26
  (Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
24
27
 
@@ -28,75 +31,104 @@ module Rubycli
28
31
  <cli-args> are forwarded to Rubycli unchanged.
29
32
  USAGE
30
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
+
31
60
  module_function
32
61
 
33
62
  def run(argv = ARGV)
63
+ previous_state = environment_state
34
64
  args = Array(argv).dup
35
65
  Rubycli.environment.enable_print_result!
66
+ return print_usage(1) if args.empty?
36
67
 
37
- if args.empty?
38
- $stdout.puts(USAGE)
39
- return 1
40
- end
68
+ options = Options.defaults
69
+ status = parse_flags!(args, options)
70
+ return status if status
71
+ return print_usage(1) if args.empty?
41
72
 
42
- new_flag = false
43
- json_mode = false
44
- eval_mode = false
45
- eval_lax_mode = false
46
- constant_mode = nil
47
- check_mode = false
48
- pre_script_sources = []
73
+ target_path, class_or_module = extract_target!(args)
74
+ status = reject_conflicting_flags(options, args)
75
+ return status if status
49
76
 
50
- loop do
51
- arg = args.first
52
- 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
53
85
 
54
- 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
55
91
  when '-h', '--help', 'help'
56
- $stdout.puts(USAGE)
57
- return 0
58
- when '--new', '-n'
59
- new_flag = true
92
+ return print_usage(0)
93
+ when /\A--new(?:=(.+))?\z/
94
+ inline_value = Regexp.last_match(1)
95
+ args.shift
96
+ consume_new_arguments!(args, options, inline_value)
97
+ when '-n'
60
98
  args.shift
99
+ consume_new_arguments!(args, options, nil)
61
100
  when /\A--pre-script=(.+)\z/, /\A--init=(.+)\z/
62
- label = Regexp.last_match(0).start_with?('--pre-script') ? '--pre-script' : '--init'
63
- expr = Regexp.last_match(1)
64
- 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})" }
65
103
  args.shift
66
104
  when '--pre-script', '--init'
67
- flag = args.shift
68
- src = args.shift
69
- unless src
70
- warn "[ERROR] #{flag} requires a file path or inline Ruby code"
71
- return 1
72
- end
73
- context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
74
- pre_script_sources << { value: src, context: context }
105
+ status = consume_pre_script_source!(args, options)
106
+ return status if status
75
107
  when '--json-args', '-j'
76
- json_mode = true
108
+ options.json_mode = true
77
109
  args.shift
78
110
  when '--eval-args', '-e'
79
- eval_mode = true
111
+ options.eval_mode = true
80
112
  args.shift
81
113
  when '--eval-lax', '-E'
82
- eval_mode = true
83
- eval_lax_mode = true
114
+ options.eval_mode = true
115
+ options.eval_lax = true
84
116
  args.shift
85
117
  when '--strict'
86
118
  Rubycli.environment.enable_strict_input!
87
119
  args.shift
88
120
  when '--check', '-c'
89
- check_mode = true
121
+ options.check_mode = true
90
122
  Rubycli.environment.enable_doc_check!
91
123
  args.shift
92
124
  when '--print-result'
93
125
  args.shift
94
126
  when '--debug'
95
127
  args.shift
96
- 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.'
97
129
  return 1
98
130
  when '--auto-target', '-a'
99
- constant_mode = :auto
131
+ options.constant_mode = :auto
100
132
  args.shift
101
133
  when '--'
102
134
  args.shift
@@ -106,65 +138,120 @@ module Rubycli
106
138
  end
107
139
  end
108
140
 
109
- if args.empty?
110
- $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"
111
160
  return 1
112
161
  end
113
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)
114
170
  target_path = args.shift
115
171
  class_or_module = nil
116
- possible_class = args.first
117
- if possible_class && possible_class != '--' && possible_class.match?(/\A[A-Z]/)
118
- class_or_module = args.shift
119
- end
172
+ candidate = args.first
173
+ class_or_module = args.shift if candidate && candidate != '--' && candidate.match?(/\A[A-Z]/)
120
174
  args.shift if args.first == '--'
175
+ [target_path, class_or_module]
176
+ end
121
177
 
122
- 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
123
181
  warn '[ERROR] --json-args cannot be combined with --eval-args or --eval-lax'
124
182
  return 1
125
183
  end
126
184
 
127
- if check_mode && (json_mode || eval_mode)
185
+ return nil unless options.check_mode
186
+
187
+ if options.json_mode || options.eval_mode
128
188
  warn '[ERROR] --check cannot be combined with --json-args or --eval-args'
129
189
  return 1
130
190
  end
131
191
 
132
- if check_mode && !args.empty?
192
+ unless args.empty?
133
193
  warn '[ERROR] --check does not accept command arguments'
134
194
  return 1
135
195
  end
136
196
 
137
- Rubycli.environment.clear_documentation_issues!
197
+ nil
198
+ end
138
199
 
139
- if check_mode
200
+ def dispatch(target_path, class_or_module, args, options)
201
+ if options.check_mode
140
202
  return Rubycli::Runner.check(
141
203
  target_path,
142
204
  class_or_module,
143
- new: new_flag,
144
- pre_scripts: pre_script_sources,
145
- 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
146
209
  )
147
210
  end
148
211
 
149
- Rubycli::Runner.execute(
212
+ status = Rubycli::Runner.execute(
150
213
  target_path,
151
214
  class_or_module,
152
215
  args,
153
- new: new_flag,
154
- json: json_mode,
155
- eval_args: eval_mode,
156
- eval_lax: eval_lax_mode,
157
- pre_scripts: pre_script_sources,
158
- 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
159
223
  )
160
224
 
161
- 0
162
- rescue Rubycli::Runner::PreScriptError => e
163
- warn "[ERROR] #{e.message}"
164
- 1
165
- rescue Rubycli::Runner::Error => e
166
- warn "[ERROR] #{e.message}"
167
- 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!
251
+ end
252
+
253
+ def likely_new_args_value?(token)
254
+ token.include?(',') || token.start_with?('[', '{')
168
255
  end
169
256
  end
170
257
  end