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,77 @@
1
+ module Rubycli
2
+ # Environment captures runtime flags and configuration derived from ENV / argv.
3
+ class Environment
4
+ def initialize(env: ENV, argv: nil)
5
+ @env = env
6
+ @argv = argv
7
+ @debug = env['RUBYCLI_DEBUG'] == 'true'
8
+ @print_result = env['RUBYCLI_PRINT_RESULT'] == 'true'
9
+ scrub_argv_flags!
10
+ end
11
+
12
+ def debug?
13
+ @debug
14
+ end
15
+
16
+ def print_result?
17
+ @print_result
18
+ end
19
+
20
+ def strict_mode?
21
+ value = fetch_env_value('RUBYCLI_STRICT', 'OFF')
22
+ !%w[off 0 false].include?(value.downcase)
23
+ end
24
+
25
+ def allow_param_comments?
26
+ value = fetch_env_value('RUBYCLI_ALLOW_PARAM_COMMENT', 'ON')
27
+ %w[on 1 true].include?(value.downcase)
28
+ end
29
+
30
+ def handle_documentation_issue(message, file: nil, line: nil)
31
+ location = nil
32
+ if file
33
+ expanded = File.expand_path(file)
34
+ relative_prefix = File.expand_path(Dir.pwd) + '/'
35
+ display_path = expanded.start_with?(relative_prefix) ? expanded.delete_prefix(relative_prefix) : expanded
36
+ location = line ? "#{display_path}:#{line}" : display_path
37
+ end
38
+
39
+ formatted_message = if location
40
+ "#{location} #{message}"
41
+ else
42
+ message
43
+ end
44
+
45
+ warn "[WARN] Rubycli documentation mismatch: #{formatted_message}" if strict_mode?
46
+ end
47
+
48
+ def enable_print_result!
49
+ @print_result = true
50
+ end
51
+
52
+ private
53
+
54
+ def fetch_env_value(key, default)
55
+ (@env.fetch(key, default) || default).to_s.strip.downcase
56
+ end
57
+
58
+ def scrub_argv_flags!
59
+ return unless @argv
60
+
61
+ remove_all_flags!(@argv, '--debug') { @debug = true }
62
+ remove_all_flags!(@argv, '--print-result') { @print_result = true }
63
+ end
64
+
65
+ def remove_all_flags!(argv, flag)
66
+ found = false
67
+ loop do
68
+ index = argv.index(flag)
69
+ break unless index
70
+
71
+ argv.delete_at(index)
72
+ found = true
73
+ end
74
+ yield if found && block_given?
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,42 @@
1
+ module Rubycli
2
+ class EvalCoercer
3
+ THREAD_KEY = :rubycli_eval_mode
4
+ EVAL_BINDING = Object.new.instance_eval { binding }
5
+
6
+ def eval_mode?
7
+ Thread.current[THREAD_KEY] == true
8
+ end
9
+
10
+ def with_eval_mode(enabled = true)
11
+ previous = Thread.current[THREAD_KEY]
12
+ Thread.current[THREAD_KEY] = enabled
13
+ yield
14
+ ensure
15
+ Thread.current[THREAD_KEY] = previous
16
+ end
17
+
18
+ def coerce_eval_value(value)
19
+ case value
20
+ when String
21
+ evaluate_string(value)
22
+ when Array
23
+ value.map { |item| coerce_eval_value(item) }
24
+ when Hash
25
+ value.transform_values { |item| coerce_eval_value(item) }
26
+ else
27
+ value
28
+ end
29
+ rescue StandardError => e
30
+ raise Rubycli::ArgumentError, "Failed to evaluate Ruby code: #{e.message}"
31
+ end
32
+
33
+ private
34
+
35
+ def evaluate_string(expression)
36
+ trimmed = expression.strip
37
+ return trimmed if trimmed.empty?
38
+
39
+ EVAL_BINDING.eval(trimmed)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,298 @@
1
+ require_relative "type_utils"
2
+
3
+ module Rubycli
4
+ class HelpRenderer
5
+ include TypeUtils
6
+
7
+ def initialize(documentation_registry:)
8
+ @documentation_registry = documentation_registry
9
+ end
10
+
11
+ def print_help(target, catalog)
12
+ puts "Usage: #{File.basename($PROGRAM_NAME)} COMMAND [arguments]"
13
+ puts
14
+
15
+ instance_entries = catalog.entries_for(:instance)
16
+ class_entries = catalog.entries_for(:class)
17
+ groups = []
18
+ groups << { label: "Instance methods", entries: instance_entries } unless instance_entries.empty?
19
+ groups << { label: "Class methods", entries: class_entries } unless class_entries.empty?
20
+
21
+ if groups.empty?
22
+ puts "No commands available."
23
+ return
24
+ end
25
+
26
+ puts "Available commands:"
27
+ groups.each do |group|
28
+ puts " #{group[:label]}:"
29
+ group[:entries].each do |entry|
30
+ description = method_description(entry.method)
31
+ line = " #{entry.command.ljust(20)}"
32
+ line += " #{description}" unless description.empty?
33
+ puts line.rstrip
34
+
35
+ unless entry.aliases.empty?
36
+ puts " Aliases: #{entry.aliases.join(", ")}"
37
+ end
38
+ end
39
+ puts unless group.equal?(groups.last)
40
+ end
41
+
42
+ if catalog.duplicates.any?
43
+ puts "Methods with the same name can be invoked via instance::NAME / class::NAME."
44
+ end
45
+
46
+ puts
47
+ puts "Detailed command help: #{File.basename($PROGRAM_NAME)} COMMAND help"
48
+ puts "Enable debug logging: --debug or RUBYCLI_DEBUG=true"
49
+ end
50
+
51
+ def method_description(method_obj)
52
+ metadata = @documentation_registry.metadata_for(method_obj)
53
+ summary = metadata[:summary]
54
+ return summary if summary && !summary.empty?
55
+
56
+ params = method_obj.parameters
57
+ return "(no arguments)" if params.empty?
58
+
59
+ param_desc = params.map { |type, name|
60
+ case type
61
+ when :req then "<#{name}>"
62
+ when :opt then "[<#{name}>]"
63
+ when :rest then "[<#{name}>...]"
64
+ when :keyreq then "--#{name.to_s.tr("_", "-")}=<value>"
65
+ when :key then "[--#{name.to_s.tr("_", "-")}=<value>]"
66
+ when :keyrest then "[--<option>...]"
67
+ end
68
+ }.compact.join(" ")
69
+
70
+ param_desc.empty? ? "(no arguments)" : param_desc
71
+ end
72
+
73
+ def usage_for_method(command, method)
74
+ metadata = @documentation_registry.metadata_for(method)
75
+ params_str = format_method_parameters(method.parameters, metadata)
76
+ usage_lines = ["Usage: #{File.basename($PROGRAM_NAME)} #{command} #{params_str}".strip]
77
+
78
+ options = metadata[:options] || []
79
+ positionals_in_order = ordered_positionals(method, metadata)
80
+
81
+ if positionals_in_order.any?
82
+ labels = positionals_in_order.map { |info| info[:label] }
83
+ max_label_length = labels.map(&:length).max || 0
84
+
85
+ usage_lines << ""
86
+ usage_lines << "Positional arguments:"
87
+ positionals_in_order.each do |info|
88
+ definition = info[:definition]
89
+ description_parts = []
90
+ if definition&.inline_type_annotation && definition.inline_type_text
91
+ description_parts << definition.inline_type_text
92
+ end
93
+ type_info = positional_type_display(definition)
94
+ if type_info && type_info_first?(definition)
95
+ description_parts << type_info
96
+ end
97
+ description_parts << info[:description] if info[:description]
98
+ description_parts << type_info if type_info && !type_info_first?(definition)
99
+ default_text = positional_default_display(definition)
100
+ description_parts << default_text if default_text
101
+ description_text = description_parts.join(' ')
102
+ line = " #{info[:label].ljust(max_label_length)}"
103
+ line += " #{description_text}" unless description_text.empty?
104
+ usage_lines << line
105
+ end
106
+ end
107
+
108
+ if options.any?
109
+ option_labels = options.map { |opt| option_flag_with_placeholder(opt) }
110
+ max_label_length = option_labels.map(&:length).max || 0
111
+
112
+ usage_lines << "" unless usage_lines.last == ""
113
+ usage_lines << "Options:"
114
+ options.each_with_index do |opt, idx|
115
+ label = option_labels[idx]
116
+ description_parts = []
117
+ if opt.inline_type_annotation && opt.inline_type_text
118
+ description_parts << opt.inline_type_text
119
+ end
120
+ type_info = option_type_display(opt)
121
+ if type_info && type_info_first?(opt)
122
+ description_parts << type_info
123
+ end
124
+ description_parts << opt.description if opt.description
125
+ description_parts << type_info if type_info && !type_info_first?(opt)
126
+ default_info = option_default_display(opt)
127
+ description_parts << default_info if default_info
128
+ description_text = description_parts.join(' ')
129
+ line = " #{label.ljust(max_label_length)}"
130
+ line += " #{description_text}" unless description_text.empty?
131
+ usage_lines << line
132
+ end
133
+ end
134
+
135
+ returns = metadata[:returns] || []
136
+ if returns.any?
137
+ usage_lines << "" unless usage_lines.last == ""
138
+ usage_lines << "Return values:"
139
+ returns.each do |ret|
140
+ type_label = (ret.types || []).join(" | ")
141
+ line = " #{type_label}"
142
+ line += " #{ret.description}" if ret.description && !ret.description.empty?
143
+ usage_lines << line
144
+ end
145
+ end
146
+
147
+ usage_lines.pop while usage_lines.last == ""
148
+ usage_block = usage_lines.join("\n")
149
+
150
+ sections = []
151
+ summary_lines = metadata[:summary_lines] || []
152
+ summary_block = summary_lines.join("\n").rstrip
153
+ sections << summary_block unless summary_block.empty?
154
+ sections << usage_block unless usage_block.empty?
155
+ detail_lines = metadata[:detail_lines] || []
156
+ detail_block = detail_lines.join("\n").rstrip
157
+ sections << detail_block unless detail_block.empty?
158
+
159
+ sections.join("\n\n")
160
+ end
161
+
162
+ private
163
+
164
+ def format_method_parameters(parameters, metadata)
165
+ option_map = (metadata[:options] || []).each_with_object({}) { |opt, h| h[opt[:keyword]] = opt }
166
+ positional_map = metadata[:positionals_map] || {}
167
+
168
+ parameters.map { |type, name|
169
+ case type
170
+ when :req
171
+ doc = positional_map[name]
172
+ label = doc&.label || name.to_s.upcase
173
+ "<#{label}>"
174
+ when :opt
175
+ doc = positional_map[name]
176
+ label = doc&.label || name.to_s.upcase
177
+ "[<#{label}>]"
178
+ when :rest then "[<#{name}>...]"
179
+ when :keyreq
180
+ opt = option_map[name]
181
+ if opt
182
+ if opt.doc_format == :auto_generated
183
+ auto_generated_option_usage_label(name, opt)
184
+ else
185
+ option_flag_with_placeholder(opt)
186
+ end
187
+ else
188
+ "--#{name.to_s.tr('_', '-')}=<value>"
189
+ end
190
+ when :key
191
+ opt = option_map[name]
192
+ label = if opt
193
+ if opt.doc_format == :auto_generated
194
+ auto_generated_option_usage_label(name, opt)
195
+ else
196
+ option_flag_with_placeholder(opt)
197
+ end
198
+ else
199
+ "--#{name.to_s.tr('_', '-')}=<value>"
200
+ end
201
+ "[#{label}]"
202
+ when :keyrest then "[--<option>...]"
203
+ else ""
204
+ end
205
+ }.reject(&:empty?).join(" ")
206
+ end
207
+
208
+ def auto_generated_option_usage_label(name, opt)
209
+ base_flag = "--#{name.to_s.tr('_', '-')}"
210
+ return base_flag if opt.boolean_flag
211
+
212
+ "#{base_flag}=<value>"
213
+ end
214
+
215
+ def ordered_positionals(method, metadata)
216
+ positional_map = metadata[:positionals_map] || {}
217
+ method.parameters.each_with_object([]) do |(type, name), memo|
218
+ next unless %i[req opt].include?(type)
219
+
220
+ definition = positional_map[name]
221
+ label = if definition
222
+ type == :opt ? "[#{definition.label}]" : definition.label
223
+ else
224
+ base = name.to_s.upcase
225
+ type == :opt ? "[#{base}]" : base
226
+ end
227
+ description = definition&.description
228
+ memo << { label: label, description: description, definition: definition }
229
+ end
230
+ end
231
+
232
+ def positional_type_display(definition)
233
+ return nil unless definition
234
+ return nil if definition.inline_type_annotation
235
+ return nil if definition.types.nil? || definition.types.empty?
236
+
237
+ unique_types = definition.types.reject(&:empty?).uniq
238
+ return nil if unique_types.empty?
239
+
240
+ if definition.respond_to?(:doc_format) && definition.doc_format == :rubycli
241
+ "[#{unique_types.join(', ')}]"
242
+ else
243
+ "(type: #{unique_types.join(' | ')})"
244
+ end
245
+ end
246
+
247
+ def positional_default_display(definition)
248
+ return nil unless definition && definition.default_value
249
+ return nil if definition.default_value.to_s.empty?
250
+
251
+ "(default: #{definition.default_value})"
252
+ end
253
+
254
+ def option_flag_with_placeholder(opt)
255
+ flags = [opt.short, opt.long].compact
256
+ flags = ["--#{opt.keyword.to_s.tr("_", "-")}"] if flags.empty?
257
+ flag_label = flags.join(", ")
258
+ placeholder = option_value_placeholder(opt)
259
+ if placeholder
260
+ "#{flag_label} #{placeholder}"
261
+ else
262
+ flag_label
263
+ end
264
+ end
265
+
266
+ def option_value_placeholder(opt)
267
+ return nil if opt.boolean_flag
268
+ return opt.value_name if opt.value_name && !opt.value_name.empty?
269
+
270
+ first_non_nil_type = opt.types&.find { |type| !nil_type?(type) && !boolean_type?(type) }
271
+ first_non_nil_type
272
+ end
273
+
274
+ def option_type_display(opt)
275
+ return nil if opt.inline_type_annotation
276
+ return nil if opt.types.nil? || opt.types.empty?
277
+
278
+ unique_types = opt.types.reject(&:empty?).uniq
279
+ return nil if unique_types.empty?
280
+
281
+ if opt.respond_to?(:doc_format) && opt.doc_format == :rubycli
282
+ "[#{unique_types.join(', ')}]"
283
+ else
284
+ "(type: #{unique_types.join(' | ')})"
285
+ end
286
+ end
287
+
288
+ def option_default_display(opt)
289
+ return nil if opt.default_value.nil? || opt.default_value.to_s.empty?
290
+
291
+ "(default: #{opt.default_value})"
292
+ end
293
+
294
+ def type_info_first?(definition)
295
+ definition.respond_to?(:doc_format) && definition.doc_format == :rubycli
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,32 @@
1
+ module Rubycli
2
+ class JsonCoercer
3
+ THREAD_KEY = :rubycli_json_mode
4
+
5
+ def json_mode?
6
+ Thread.current[THREAD_KEY] == true
7
+ end
8
+
9
+ def with_json_mode(enabled = true)
10
+ previous = Thread.current[THREAD_KEY]
11
+ Thread.current[THREAD_KEY] = enabled
12
+ yield
13
+ ensure
14
+ Thread.current[THREAD_KEY] = previous
15
+ end
16
+
17
+ def coerce_json_value(value)
18
+ case value
19
+ when String
20
+ JSON.parse(value)
21
+ when Array
22
+ value.map { |item| coerce_json_value(item) }
23
+ when Hash
24
+ value.transform_values { |item| coerce_json_value(item) }
25
+ else
26
+ value
27
+ end
28
+ rescue JSON::ParserError => e
29
+ raise Rubycli::ArgumentError, "Failed to parse as JSON: #{e.message}"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,41 @@
1
+ module Rubycli
2
+ class ResultEmitter
3
+ def initialize(environment:)
4
+ @environment = environment
5
+ end
6
+
7
+ def emit(result)
8
+ return unless @environment.print_result?
9
+ return if result.nil?
10
+ return if result.is_a?(Module) || result.is_a?(Class)
11
+
12
+ formatted = format_result_output(result)
13
+ return if formatted.nil? || (formatted.respond_to?(:empty?) && formatted.empty?)
14
+
15
+ puts formatted
16
+ end
17
+
18
+ private
19
+
20
+ def format_result_output(result)
21
+ case result
22
+ when String
23
+ result
24
+ when Numeric, TrueClass, FalseClass
25
+ result.to_s
26
+ when Array, Hash
27
+ JSON.pretty_generate(result)
28
+ else
29
+ if result.respond_to?(:to_h)
30
+ JSON.pretty_generate(result.to_h)
31
+ elsif result.respond_to?(:to_ary)
32
+ JSON.pretty_generate(result.to_ary)
33
+ else
34
+ result.inspect
35
+ end
36
+ end
37
+ rescue JSON::GeneratorError
38
+ result.inspect
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,128 @@
1
+ module Rubycli
2
+ module TypeUtils
3
+ module_function
4
+
5
+ def nil_type?(type)
6
+ %w[nil NilClass].include?(type)
7
+ end
8
+
9
+ def boolean_type?(type)
10
+ %w[Boolean TrueClass FalseClass].include?(type)
11
+ end
12
+
13
+ def normalize_type_list(types)
14
+ Array(types).compact.flat_map { |type| normalize_type_token(type) }.map(&:strip).reject(&:empty?).uniq
15
+ end
16
+
17
+ def normalize_type_token(token)
18
+ trimmed = token.to_s.strip
19
+ trimmed = trimmed.delete_prefix('@')
20
+ return '' if trimmed.empty?
21
+
22
+ if trimmed.include?('<') && trimmed.end_with?('>')
23
+ trimmed
24
+ elsif trimmed.end_with?('[]')
25
+ base = trimmed[0..-3]
26
+ base = base.capitalize if base == base.downcase
27
+ "#{base}[]"
28
+ else
29
+ trimmed
30
+ end
31
+ end
32
+
33
+ def analyze_placeholder(value_placeholder)
34
+ return { optional: false, list: false, base: nil } unless value_placeholder
35
+
36
+ trimmed = value_placeholder.strip
37
+ optional = trimmed.start_with?('[') && trimmed.end_with?(']')
38
+ core = optional ? trimmed[1..-2].strip : trimmed.dup
39
+ sanitized = core.gsub(/\[|\]/, '')
40
+ sanitized = sanitized.gsub(/\.\.\./, '')
41
+ list = sanitized.include?(',') || core.include?('...') || core.include?('[,')
42
+ token = sanitized.split(',').first.to_s.strip
43
+ token = token.gsub(/[^A-Za-z0-9_]/, '')
44
+ token = nil if token.empty?
45
+
46
+ { optional: optional, list: list, base: token }
47
+ end
48
+
49
+ def infer_types_from_placeholder(types, placeholder_info, include_optional_boolean: true)
50
+ working = types.dup
51
+
52
+ if working.empty?
53
+ if placeholder_info[:optional]
54
+ inferred = if placeholder_info[:list]
55
+ include_optional_boolean ? ['Boolean', 'String[]'] : ['String[]']
56
+ else
57
+ include_optional_boolean ? ['Boolean', 'String'] : ['String']
58
+ end
59
+ working.concat(inferred)
60
+ elsif placeholder_info[:list]
61
+ working << 'String[]'
62
+ elsif placeholder_info[:base]
63
+ working << 'String'
64
+ end
65
+ elsif placeholder_info[:optional] && include_optional_boolean
66
+ working.unshift('Boolean') unless working.any? { |type| boolean_type?(type) }
67
+ end
68
+
69
+ working.uniq
70
+ end
71
+
72
+ def determine_requires_value(value_placeholder:, types:, boolean_flag:, optional_value:)
73
+ return nil if optional_value
74
+ return false if boolean_flag
75
+
76
+ value_present = !value_placeholder.nil?
77
+ non_boolean_types = types.reject { |type| boolean_type?(type) || nil_type?(type) }
78
+
79
+ if value_present
80
+ true
81
+ elsif non_boolean_types.any?
82
+ true
83
+ else
84
+ false
85
+ end
86
+ end
87
+
88
+ def normalize_long_option(option)
89
+ return nil unless option
90
+ option.start_with?('--') ? option : "--#{option.delete_prefix('-')}"
91
+ end
92
+
93
+ def normalize_short_option(option)
94
+ return nil unless option
95
+ option.start_with?('-') ? option : "-#{option}"
96
+ end
97
+
98
+ def default_placeholder_for(keyword)
99
+ keyword.to_s.upcase
100
+ end
101
+
102
+ def parse_list(value)
103
+ return [] if value.nil?
104
+
105
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
106
+ end
107
+
108
+ def convert_boolean(value)
109
+ return value if [true, false].include?(value)
110
+
111
+ str = value.to_s.strip.downcase
112
+ case str
113
+ when 'true', 't', 'yes', 'y', '1'
114
+ true
115
+ when 'false', 'f', 'no', 'n', '0'
116
+ false
117
+ else
118
+ raise ArgumentError, "Cannot convert to boolean: #{value}"
119
+ end
120
+ end
121
+
122
+ def boolean_string?(value)
123
+ return false if value.nil?
124
+
125
+ %w[true false t f yes no y n 1 0].include?(value.to_s.strip.downcase)
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,16 @@
1
+ module Rubycli
2
+ OptionDefinition = Struct.new(
3
+ :keyword, :long, :short, :value_name, :types, :description, :requires_value,
4
+ :boolean_flag, :optional_value, :default_value, :inline_type_annotation,
5
+ :inline_type_text, :doc_format,
6
+ keyword_init: true
7
+ )
8
+
9
+ PositionalDefinition = Struct.new(
10
+ :placeholder, :label, :types, :description, :param_name, :default_value,
11
+ :inline_type_annotation, :inline_type_text, :doc_format,
12
+ keyword_init: true
13
+ )
14
+
15
+ ReturnDefinition = Struct.new(:types, :description, keyword_init: true)
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ VERSION = '0.1.1'
5
+ end