rubycli 0.1.1

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,343 @@
1
+ require_relative 'type_utils'
2
+
3
+ module Rubycli
4
+ class ArgumentParser
5
+ include TypeUtils
6
+
7
+ def initialize(environment:, documentation_registry:, json_coercer:, debug_logger:)
8
+ @environment = environment
9
+ @documentation_registry = documentation_registry
10
+ @json_coercer = json_coercer
11
+ @debug_logger = debug_logger
12
+ end
13
+
14
+ def parse(args, method = nil)
15
+ pos_args = []
16
+ kw_args = {}
17
+
18
+ kw_param_names = extract_keyword_parameter_names(method)
19
+ debug_log "Available keyword parameters: #{kw_param_names.inspect}"
20
+
21
+ metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil }
22
+ option_defs = metadata[:options] || []
23
+ cli_aliases = build_cli_alias_map(option_defs)
24
+ option_lookup = build_option_lookup(option_defs)
25
+ type_converters = build_type_converter_map(option_defs)
26
+
27
+ i = 0
28
+ while i < args.size
29
+ token = args[i]
30
+
31
+ if token == '--'
32
+ rest_tokens = (args[(i + 1)..-1] || []).map { |value| convert_arg(value) }
33
+ pos_args.concat(rest_tokens)
34
+ break
35
+ end
36
+
37
+ if option_token?(token)
38
+ i = process_option_token(
39
+ token,
40
+ args,
41
+ i,
42
+ kw_param_names,
43
+ kw_args,
44
+ cli_aliases,
45
+ option_lookup,
46
+ type_converters
47
+ )
48
+ elsif assignment_token?(token)
49
+ process_assignment_token(token, kw_args)
50
+ else
51
+ pos_args << convert_arg(token)
52
+ end
53
+
54
+ i += 1
55
+ end
56
+
57
+ debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
58
+ [pos_args, kw_args]
59
+ end
60
+
61
+ private
62
+
63
+ def debug_log(message)
64
+ return unless @debug_logger
65
+
66
+ @debug_logger.call(message)
67
+ end
68
+
69
+ def extract_keyword_parameter_names(method)
70
+ return [] unless method
71
+
72
+ method.parameters
73
+ .select { |type, _| %i[key keyreq].include?(type) }
74
+ .map { |_, name| name.to_s }
75
+ end
76
+
77
+ def option_token?(token)
78
+ token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
79
+ end
80
+
81
+ def assignment_token?(token)
82
+ !split_assignment_token(token).nil?
83
+ end
84
+
85
+ def process_option_token(
86
+ token,
87
+ args,
88
+ current_index,
89
+ kw_param_names,
90
+ kw_args,
91
+ cli_aliases,
92
+ option_lookup,
93
+ type_converters
94
+ )
95
+ token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
96
+ cli_key = Regexp.last_match(1).tr('-', '_')
97
+ embedded_value = Regexp.last_match(2)
98
+
99
+ resolved_cli_key = cli_aliases.fetch(cli_key, cli_key)
100
+ debug_log "Processing option '#{Regexp.last_match(1)}' -> '#{resolved_cli_key}'"
101
+
102
+ resolved_key = resolve_keyword_parameter(resolved_cli_key, kw_param_names)
103
+ final_key = resolved_key || resolved_cli_key
104
+ final_key_sym = final_key.to_sym
105
+
106
+ option_meta = option_lookup[final_key_sym]
107
+ requires_value = option_meta ? option_meta[:requires_value] : nil
108
+ option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
109
+
110
+ value_capture, current_index = if embedded_value
111
+ [embedded_value, current_index]
112
+ elsif option_meta
113
+ capture_option_value(
114
+ option_meta,
115
+ args,
116
+ current_index,
117
+ requires_value
118
+ )
119
+ elsif current_index + 1 < args.size && !looks_like_option?(args[current_index + 1])
120
+ current_index += 1
121
+ [args[current_index], current_index]
122
+ else
123
+ ['true', current_index]
124
+ end
125
+
126
+ if requires_value && (value_capture.nil? || value_capture == 'true')
127
+ raise ArgumentError, "Option '#{option_label}' requires a value"
128
+ end
129
+
130
+ converted_value = convert_option_value(
131
+ final_key_sym,
132
+ value_capture,
133
+ option_meta,
134
+ type_converters
135
+ )
136
+
137
+ kw_args[final_key_sym] = converted_value
138
+ current_index
139
+ end
140
+
141
+ def capture_option_value(option_meta, args, current_index, requires_value)
142
+ new_index = current_index
143
+ value = if option_meta[:boolean_flag]
144
+ if new_index + 1 < args.size && TypeUtils.boolean_string?(args[new_index + 1])
145
+ new_index += 1
146
+ args[new_index]
147
+ else
148
+ 'true'
149
+ end
150
+ elsif option_meta[:optional_value]
151
+ if new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
152
+ new_index += 1
153
+ args[new_index]
154
+ else
155
+ true
156
+ end
157
+ elsif requires_value == false
158
+ 'true'
159
+ elsif requires_value
160
+ if new_index + 1 >= args.size
161
+ raise ArgumentError, "Option '#{option_meta.long}' requires a value"
162
+ end
163
+ new_index += 1
164
+ args[new_index]
165
+ elsif new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
166
+ new_index += 1
167
+ args[new_index]
168
+ else
169
+ 'true'
170
+ end
171
+ [value, new_index]
172
+ end
173
+
174
+ def process_assignment_token(token, kw_args)
175
+ key, value = split_assignment_token(token)
176
+ kw_args[key.to_sym] = convert_arg(value)
177
+ end
178
+
179
+ def split_assignment_token(token)
180
+ return nil unless token&.include?('=')
181
+
182
+ key, value = token.split('=', 2)
183
+ return nil if key.nil? || key.empty? || value.nil?
184
+ return nil unless key.match?(/\A[a-zA-Z_]\w*\z/)
185
+
186
+ [key, value]
187
+ end
188
+
189
+ def resolve_keyword_parameter(cli_key, kw_param_names)
190
+ exact_match = kw_param_names.find { |name| name == cli_key }
191
+ return exact_match if exact_match
192
+
193
+ matching_keys = kw_param_names.select { |name| name.start_with?(cli_key) }
194
+ debug_log "Prefix matching for '#{cli_key}': found #{matching_keys.inspect}"
195
+
196
+ if matching_keys.size == 1
197
+ debug_log "Unambiguous prefix match found: '#{matching_keys.first}'"
198
+ matching_keys.first
199
+ else
200
+ debug_log "No unique match found for '#{cli_key}'"
201
+ nil
202
+ end
203
+ end
204
+
205
+ def convert_arg(arg)
206
+ return nil if arg.nil? || arg.casecmp('nil').zero?
207
+ return true if arg.casecmp('true').zero?
208
+ return false if arg.casecmp('false').zero?
209
+ return arg.to_i if integer_string?(arg)
210
+ return arg.to_f if float_string?(arg)
211
+
212
+ arg
213
+ end
214
+
215
+ def integer_string?(str)
216
+ str =~ /\A-?\d+\z/
217
+ end
218
+
219
+ def float_string?(str)
220
+ str =~ /\A-?\d+\.\d+\z/
221
+ end
222
+
223
+
224
+ def build_cli_alias_map(option_defs)
225
+ option_defs.each_with_object({}) do |opt, memo|
226
+ next unless opt.short
227
+
228
+ key = opt.short.delete_prefix('-')
229
+ memo[key] = opt.keyword.to_s
230
+ end
231
+ end
232
+
233
+ def build_option_lookup(option_defs)
234
+ option_defs.each_with_object({}) do |opt, memo|
235
+ memo[opt.keyword] = opt
236
+ end
237
+ end
238
+
239
+ def build_type_converter_map(option_defs)
240
+ option_defs.each_with_object({}) do |opt, memo|
241
+ next if opt.types.nil? || opt.types.empty?
242
+
243
+ converter = build_converter_for_types(opt.types)
244
+ memo[opt.keyword] = converter if converter
245
+ end
246
+ end
247
+
248
+ def build_converter_for_types(types)
249
+ return nil if types.empty?
250
+
251
+ allow_nil = types.any? { |type| nil_type?(type) }
252
+ converters = types.map { |type| converter_for_single_type(type) }.compact
253
+
254
+ return nil if converters.empty? && !allow_nil
255
+
256
+ lambda do |value|
257
+ return nil if value.nil? && allow_nil
258
+
259
+ if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
260
+ next nil
261
+ end
262
+
263
+ if converters.empty?
264
+ value
265
+ else
266
+ last_error = nil
267
+ converters.each do |converter|
268
+ begin
269
+ result = converter.call(value)
270
+ return result
271
+ rescue StandardError => e
272
+ last_error = e
273
+ end
274
+ end
275
+ raise last_error || ArgumentError.new("Could not convert value '#{value}'")
276
+ end
277
+ end
278
+ end
279
+
280
+ def converter_for_single_type(type)
281
+ normalized = type.to_s.strip
282
+
283
+ return nil if nil_type?(normalized)
284
+
285
+ case normalized
286
+ when 'String'
287
+ ->(value) { value }
288
+ when 'Integer', 'Fixnum'
289
+ ->(value) { Integer(value, 10) }
290
+ when 'Float'
291
+ ->(value) { Float(value) }
292
+ when 'Numeric'
293
+ ->(value) { Float(value) }
294
+ when 'Boolean', 'TrueClass', 'FalseClass'
295
+ ->(value) { TypeUtils.convert_boolean(value) }
296
+ when 'Symbol'
297
+ ->(value) { value.to_sym }
298
+ when 'BigDecimal', 'Decimal'
299
+ require 'bigdecimal'
300
+ ->(value) { BigDecimal(value) }
301
+ when 'Date'
302
+ require 'date'
303
+ ->(value) { Date.parse(value) }
304
+ when 'Time', 'DateTime'
305
+ require 'time'
306
+ ->(value) { Time.parse(value) }
307
+ when 'JSON', 'Hash'
308
+ ->(value) { JSON.parse(value) }
309
+ else
310
+ if normalized.start_with?('Array<') && normalized.end_with?('>')
311
+ inner = normalized[6..-2].strip
312
+ element_converter = converter_for_single_type(inner)
313
+ ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
314
+ elsif normalized.end_with?('[]')
315
+ inner = normalized[0..-3]
316
+ element_converter = converter_for_single_type(inner)
317
+ ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
318
+ elsif normalized == 'Array'
319
+ ->(value) { TypeUtils.parse_list(value) }
320
+ else
321
+ nil
322
+ end
323
+ end
324
+ end
325
+
326
+ def convert_option_value(keyword, value, option_meta, type_converters)
327
+ converter = type_converters[keyword]
328
+ return convert_arg(value) unless converter
329
+
330
+ converter.call(value)
331
+ rescue StandardError => e
332
+ option_label = option_meta&.long || option_meta&.short || keyword
333
+ raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
334
+ end
335
+
336
+ def looks_like_option?(token)
337
+ return false unless token
338
+ return false if token == '--'
339
+
340
+ token.start_with?('-') && !(token =~ /\A-?\d+(\.\d+)?\z/)
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,341 @@
1
+ require 'set'
2
+
3
+ module Rubycli
4
+ class CLI
5
+ CommandEntry = Struct.new(:command, :method, :category, :aliases, keyword_init: true) do
6
+ def all_commands
7
+ [command] + aliases
8
+ end
9
+ end
10
+
11
+ CommandCatalog = Struct.new(:entries, :alias_map, :duplicates, keyword_init: true) do
12
+ def lookup(command)
13
+ alias_map[command]
14
+ end
15
+
16
+ def entries_for(category)
17
+ entries.select { |entry| entry.category == category }
18
+ end
19
+
20
+ def commands
21
+ entries.map(&:command)
22
+ end
23
+ end
24
+
25
+ def initialize(environment:, argument_parser:, documentation_registry:, help_renderer:, result_emitter:)
26
+ @environment = environment
27
+ @argument_parser = argument_parser
28
+ @documentation_registry = documentation_registry
29
+ @help_renderer = help_renderer
30
+ @result_emitter = result_emitter
31
+ @file_cache = {}
32
+ end
33
+
34
+ def run(target, args = ARGV, cli_mode = true)
35
+ debug_log "Starting rubycli with args: #{args.inspect}"
36
+ catalog = command_catalog(target)
37
+
38
+ if should_show_help?(args)
39
+ @help_renderer.print_help(target, catalog)
40
+ exit(0)
41
+ end
42
+
43
+ command = args.shift
44
+ entry = resolve_command_entry(catalog, command)
45
+
46
+ if entry.nil?
47
+ handle_missing_method(target, catalog, command, args, cli_mode)
48
+ else
49
+ execute_method(entry.method, command, args, cli_mode)
50
+ end
51
+ end
52
+
53
+ def available_commands(target)
54
+ command_catalog(target).commands.sort
55
+ end
56
+
57
+ def find_method(target, command)
58
+ catalog = command_catalog(target)
59
+ entry = catalog.lookup(command)
60
+ entry ||= catalog.lookup(normalize_command(command)) if command.include?('-')
61
+ entry&.method
62
+ end
63
+
64
+ def usage_for_method(command, method)
65
+ @help_renderer.usage_for_method(command, method)
66
+ end
67
+
68
+ def method_description(method)
69
+ @help_renderer.method_description(method)
70
+ end
71
+
72
+ def command_catalog_for(target)
73
+ command_catalog(target)
74
+ end
75
+
76
+ private
77
+
78
+ def debug_log(message)
79
+ return unless @environment.debug?
80
+
81
+ puts "[DEBUG] #{message}"
82
+ end
83
+
84
+ def should_show_help?(args)
85
+ args.empty? || ['help', '--help', '-h'].include?(args[0])
86
+ end
87
+
88
+ def resolve_command_entry(catalog, command)
89
+ entry = catalog.lookup(command)
90
+ return entry if entry
91
+
92
+ return entry unless command.include?('-')
93
+
94
+ normalized = normalize_command(command)
95
+ debug_log "Tried snake_case conversion: #{command} -> #{normalized}" if normalized != command
96
+ catalog.lookup(normalized)
97
+ end
98
+
99
+ def handle_missing_method(target, catalog, command, args, cli_mode)
100
+ if target.respond_to?(:call)
101
+ debug_log "Target is callable, treating as lambda/proc"
102
+ args.unshift(command)
103
+ execute_callable(target, args, command, cli_mode)
104
+ else
105
+ error_msg = "Command '#{command}' is not available."
106
+ puts error_msg
107
+ @help_renderer.print_help(target, catalog)
108
+ exit(1)
109
+ end
110
+ end
111
+
112
+ def execute_callable(target, args, command, cli_mode)
113
+ method = target.method(:call)
114
+ pos_args, kw_args = @argument_parser.parse(args, method)
115
+ Rubycli.apply_argument_coercions(pos_args, kw_args)
116
+ begin
117
+ result = Rubycli.call_target(target, pos_args, kw_args)
118
+ @result_emitter.emit(result)
119
+ rescue StandardError => e
120
+ handle_execution_error(e, command, method, pos_args, kw_args, cli_mode)
121
+ end
122
+ end
123
+
124
+ def execute_method(method_obj, command, args, cli_mode)
125
+ if method_obj.parameters.empty? && !args.empty?
126
+ execute_parameterless_method(method_obj, command, args, cli_mode)
127
+ else
128
+ execute_method_with_params(method_obj, command, args, cli_mode)
129
+ end
130
+ end
131
+
132
+ def execute_parameterless_method(method_obj, command, args, cli_mode)
133
+ if help_requested_for_parameterless?(args)
134
+ puts usage_for_method(command, method_obj)
135
+ exit(0)
136
+ end
137
+
138
+ begin
139
+ result = method_obj.call
140
+ debug_log "Parameterless method returned: #{result.inspect}"
141
+ if result
142
+ run(result, args, false)
143
+ end
144
+ rescue StandardError => e
145
+ handle_execution_error(e, command, method_obj, [], {}, cli_mode)
146
+ end
147
+ end
148
+
149
+ def execute_method_with_params(method_obj, command, args, cli_mode)
150
+ pos_args, kw_args = @argument_parser.parse(args, method_obj)
151
+ Rubycli.apply_argument_coercions(pos_args, kw_args)
152
+
153
+ if should_show_method_help?(pos_args, kw_args)
154
+ puts usage_for_method(command, method_obj)
155
+ exit(0)
156
+ end
157
+
158
+ begin
159
+ result = Rubycli.call_target(method_obj, pos_args, kw_args)
160
+ @result_emitter.emit(result)
161
+ rescue StandardError => e
162
+ handle_execution_error(e, command, method_obj, pos_args, kw_args, cli_mode)
163
+ end
164
+ end
165
+
166
+ def should_show_method_help?(pos_args, kw_args)
167
+ (kw_args.key?(:help) && kw_args.delete(:help)) || pos_args.include?('help')
168
+ end
169
+
170
+ def help_requested_for_parameterless?(args)
171
+ return false if args.nil? || args.empty?
172
+
173
+ args.all? { |arg| help_argument?(arg) }
174
+ end
175
+
176
+ def help_argument?(arg)
177
+ %w[help --help -h].include?(arg)
178
+ end
179
+
180
+ def handle_execution_error(error, command, method_obj, pos_args, kw_args, cli_mode)
181
+ if cli_mode && !arguments_match?(method_obj, pos_args, kw_args) && usage_error?(error)
182
+ puts "Error: #{error.message}"
183
+ puts usage_for_method(command, method_obj)
184
+ exit(1)
185
+ else
186
+ raise error
187
+ end
188
+ end
189
+
190
+ def usage_error?(error)
191
+ msg = error.message
192
+ msg.include?('wrong number of arguments') ||
193
+ msg.include?('missing keyword') ||
194
+ msg.include?('unknown keyword') ||
195
+ msg.include?('no implicit conversion')
196
+ end
197
+
198
+ def arguments_match?(method, pos_args, kw_args)
199
+ return false unless method
200
+
201
+ params = method.parameters
202
+ positional_match = check_positional_arguments(params, pos_args)
203
+ keyword_match = check_keyword_arguments(params, kw_args)
204
+
205
+ positional_match && keyword_match
206
+ end
207
+
208
+ def check_positional_arguments(params, pos_args)
209
+ req_pos = params.count { |type, _| type == :req }
210
+ opt_pos = params.count { |type, _| type == :opt }
211
+ has_rest = params.any? { |type, _| type == :rest }
212
+
213
+ if has_rest
214
+ pos_args.size >= req_pos
215
+ else
216
+ (req_pos..(req_pos + opt_pos)).cover?(pos_args.size)
217
+ end
218
+ end
219
+
220
+ def check_keyword_arguments(params, kw_args)
221
+ known_keys = params.select { |t, _| %i[key keyreq keyrest].include?(t) }.map { |_, n| n }
222
+ unknown_keys = kw_args.keys - known_keys
223
+ req_kw = params.select { |type, _| type == :keyreq }.map { |_, name| name }
224
+ has_keyrest = params.any? { |type, _| type == :keyrest }
225
+
226
+ required_keywords_present = req_kw.all? { |k| kw_args.key?(k) }
227
+ no_unknown_keywords = has_keyrest || unknown_keys.empty?
228
+
229
+ required_keywords_present && no_unknown_keywords
230
+ end
231
+
232
+ def command_catalog(target)
233
+ if target.is_a?(Module) || target.is_a?(Class)
234
+ entries = collect_singleton_entries(target)
235
+ alias_map = build_alias_map(entries)
236
+ CommandCatalog.new(entries: entries, alias_map: alias_map, duplicates: Set.new)
237
+ else
238
+ build_instance_catalog(target)
239
+ end
240
+ end
241
+
242
+ def collect_singleton_entries(target)
243
+ method_names = target.singleton_class.public_instance_methods(false)
244
+ method_names.each_with_object([]) do |name, memo|
245
+ method_obj = safe_method_lookup(target, name)
246
+ next unless exposable_method?(method_obj)
247
+
248
+ memo << CommandEntry.new(command: name.to_s, method: method_obj, category: :class, aliases: [])
249
+ end.sort_by { |entry| entry.command }
250
+ end
251
+
252
+ def build_instance_catalog(target)
253
+ instance_methods = collect_instance_methods(target)
254
+ class_methods = collect_class_methods(target)
255
+
256
+ duplicate_names = instance_methods.keys & class_methods.keys
257
+ entries = []
258
+
259
+ instance_methods.keys.sort.each do |name|
260
+ method_obj = instance_methods[name]
261
+ aliases = duplicate_names.include?(name) ? ["instance::#{name}"] : []
262
+ entries << CommandEntry.new(command: name, method: method_obj, category: :instance, aliases: aliases)
263
+ end
264
+
265
+ class_methods.keys.sort.each do |name|
266
+ method_obj = class_methods[name]
267
+ entries << CommandEntry.new(command: "class::#{name}", method: method_obj, category: :class, aliases: [])
268
+ end
269
+
270
+ alias_map = build_alias_map(entries)
271
+ CommandCatalog.new(entries: entries, alias_map: alias_map, duplicates: duplicate_names.sort)
272
+ end
273
+
274
+ def collect_instance_methods(target)
275
+ collect_method_map(target.public_methods(false)) { |name| safe_method_lookup(target, name) }
276
+ end
277
+
278
+ def collect_class_methods(target)
279
+ klass = target.class
280
+ collect_method_map(klass.singleton_class.public_instance_methods(false)) { |name| safe_method_lookup(klass, name) }
281
+ end
282
+
283
+ def collect_method_map(method_names)
284
+ method_names.each_with_object({}) do |name, memo|
285
+ method_obj = yield(name)
286
+ next unless exposable_method?(method_obj)
287
+
288
+ memo[name.to_s] = method_obj
289
+ end
290
+ end
291
+
292
+ def safe_method_lookup(target, name)
293
+ target.method(name)
294
+ rescue NameError
295
+ nil
296
+ end
297
+
298
+ def exposable_method?(method_obj)
299
+ return false unless method_obj&.source_location
300
+ return false if accessor_generated_method?(method_obj)
301
+
302
+ true
303
+ end
304
+
305
+ def build_alias_map(entries)
306
+ entries.each_with_object({}) do |entry, memo|
307
+ entry.all_commands.each do |command|
308
+ memo[command] = entry
309
+ end
310
+ end
311
+ end
312
+
313
+ def normalize_command(command)
314
+ return command unless command
315
+
316
+ if command.start_with?('class::')
317
+ base = command.split('class::', 2).last
318
+ "class::#{base.tr('-', '_')}"
319
+ elsif command.start_with?('instance::')
320
+ base = command.split('instance::', 2).last
321
+ "instance::#{base.tr('-', '_')}"
322
+ else
323
+ command.tr('-', '_')
324
+ end
325
+ end
326
+
327
+ def accessor_generated_method?(method_obj)
328
+ location = method_obj.source_location
329
+ return false unless location
330
+
331
+ file, line = location
332
+ lines = (@file_cache[file] ||= File.readlines(file, chomp: true))
333
+ line_content = lines[line - 1]
334
+ return false unless line_content
335
+
336
+ line_content.match?(/\battr_(reader|writer|accessor)\b/)
337
+ rescue Errno::ENOENT
338
+ false
339
+ end
340
+ end
341
+ end