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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/LICENSE +21 -0
- data/README.ja.md +404 -0
- data/README.md +399 -0
- data/exe/rubycli +6 -0
- data/lib/rubycli/argument_parser.rb +343 -0
- data/lib/rubycli/cli.rb +341 -0
- data/lib/rubycli/command_line.rb +116 -0
- data/lib/rubycli/documentation_registry.rb +836 -0
- data/lib/rubycli/environment.rb +77 -0
- data/lib/rubycli/eval_coercer.rb +42 -0
- data/lib/rubycli/help_renderer.rb +298 -0
- data/lib/rubycli/json_coercer.rb +32 -0
- data/lib/rubycli/result_emitter.rb +41 -0
- data/lib/rubycli/type_utils.rb +128 -0
- data/lib/rubycli/types.rb +16 -0
- data/lib/rubycli/version.rb +5 -0
- data/lib/rubycli.rb +406 -0
- metadata +65 -0
|
@@ -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
|