ace-support-cli 0.6.2

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,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "errors"
5
+
6
+ module Ace
7
+ module Support
8
+ module Cli
9
+ class Parser
10
+ def initialize(command_class, command_name: nil)
11
+ @command_class = command_class
12
+ @command_name = command_name
13
+ end
14
+
15
+ def parse(args)
16
+ options = build_defaults
17
+ remaining = args.dup
18
+
19
+ if help_requested?(remaining) && rich_help?
20
+ render_help(remaining)
21
+ end
22
+
23
+ parser = OptionParser.new
24
+ parser.banner = "Usage: #{File.basename($0)} #{command_label} [options]"
25
+
26
+ configure_options(parser, options)
27
+
28
+ parser.parse!(remaining)
29
+
30
+ apply_positionals(options, remaining)
31
+ validate_required_options!(options)
32
+
33
+ options
34
+ rescue OptionParser::ParseError => e
35
+ raise ParseError, parse_error_message(e)
36
+ rescue ArgumentError => e
37
+ raise ParseError, e.message
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :command_class
43
+
44
+ def help_requested?(args)
45
+ tokens = tokens_before_separator(args)
46
+ tokens.include?("--help") || tokens.include?("-h")
47
+ end
48
+
49
+ def rich_help?
50
+ has_desc = command_class.respond_to?(:description) && !command_class.description.nil?
51
+ has_examples = command_class.respond_to?(:examples) && !command_class.examples.empty?
52
+ has_desc || has_examples
53
+ end
54
+
55
+ def render_help(args)
56
+ name = resolved_command_name
57
+ output = Ace::Support::Cli::Help::TwoTierHelp.render(command_class, name, args: args)
58
+ raise HelpRendered.new(output)
59
+ end
60
+
61
+ def tokens_before_separator(args)
62
+ separator = args.index("--")
63
+ separator ? args.first(separator) : args
64
+ end
65
+
66
+ def resolved_command_name
67
+ return @command_name if @command_name && !@command_name.empty?
68
+
69
+ program = File.basename($PROGRAM_NAME)
70
+ label = command_label
71
+ (label == "command") ? program : "#{program} #{label}"
72
+ end
73
+
74
+ def build_defaults
75
+ command_class.options.each_with_object({}) do |option, hash|
76
+ hash[option.name] = duplicate_default(option.default)
77
+ end
78
+ end
79
+
80
+ def duplicate_default(value)
81
+ case value
82
+ when Array then value.dup
83
+ when Hash then value.dup
84
+ else value
85
+ end
86
+ end
87
+
88
+ def configure_options(parser, options)
89
+ command_class.options.each do |option|
90
+ desc = option.desc.to_s
91
+
92
+ case option.type
93
+ when :boolean
94
+ parser.on(*option.aliases, "--[no-]#{option.name.to_s.tr("_", "-")}", desc) do |value|
95
+ options[option.name] = value
96
+ end
97
+ when :integer
98
+ switches = value_switches(option, "N")
99
+ parser.on(*switches, Integer, desc) { |value| assign_option_value(options, option, value) }
100
+ when :float
101
+ switches = value_switches(option, "N")
102
+ parser.on(*switches, Float, desc) { |value| assign_option_value(options, option, value) }
103
+ when :array
104
+ switches = value_switches(option, "A,B")
105
+ parser.on(*switches, Array, desc) do |value|
106
+ current = Array(options[option.name])
107
+ parsed_values = value.nil? ? [] : Array(value)
108
+ options[option.name] = current + parsed_values
109
+ end
110
+ when :hash
111
+ switches = value_switches(option, "KEY=VALUE")
112
+ parser.on(*switches, String, desc) do |value|
113
+ key, parsed_value = parse_hash_pair(value, option)
114
+ current = options[option.name] || {}
115
+ options[option.name] = current.merge(key => parsed_value)
116
+ end
117
+ else
118
+ switches = value_switches(option, "VALUE")
119
+ parser.on(*switches, String, desc) { |value| assign_option_value(options, option, value) }
120
+ end
121
+ end
122
+ end
123
+
124
+ def assign_option_value(options, option, value)
125
+ if option.repeat
126
+ current = Array(options[option.name])
127
+ options[option.name] = current + [value]
128
+ else
129
+ options[option.name] = value
130
+ end
131
+ end
132
+
133
+ def value_switches(option, value_label)
134
+ (option.aliases + [option.long_switch]).map do |switch|
135
+ "#{switch} #{value_label}"
136
+ end
137
+ end
138
+
139
+ def parse_hash_pair(value, option)
140
+ parts = value.split(/[=:]/, 2)
141
+ raise ArgumentError, "Invalid value for #{option.long_switch}: expected key=value" if parts.length < 2
142
+
143
+ parts
144
+ end
145
+
146
+ def apply_positionals(options, remaining)
147
+ cursor = 0
148
+ command_class.arguments.each do |argument|
149
+ if argument.type == :array
150
+ values = remaining.drop(cursor)
151
+ if values.empty? && argument.required
152
+ raise ArgumentError, "Missing required argument: #{argument.name}"
153
+ end
154
+
155
+ options[argument.name] = values
156
+ cursor = remaining.length
157
+ break
158
+ end
159
+
160
+ raw_value = remaining[cursor]
161
+ if raw_value.nil?
162
+ raise ArgumentError, "Missing required argument: #{argument.name}" if argument.required
163
+ next
164
+ end
165
+
166
+ options[argument.name] = coerce_argument(raw_value, argument)
167
+ cursor += 1
168
+ end
169
+
170
+ return unless remaining.length > cursor
171
+ if accepts_args_keyword?
172
+ options[:args] = remaining.drop(cursor)
173
+ return
174
+ end
175
+
176
+ extra = remaining.drop(cursor).join(" ")
177
+ raise ArgumentError, "Unexpected arguments: #{extra}"
178
+ end
179
+
180
+ def accepts_args_keyword?
181
+ command_class.instance_method(:call).parameters.any? do |kind, name|
182
+ %i[key keyreq].include?(kind) && name == :args
183
+ end
184
+ rescue NameError
185
+ false
186
+ end
187
+
188
+ def coerce_argument(value, argument)
189
+ case argument.type
190
+ when :integer
191
+ Integer(value)
192
+ when :float
193
+ Float(value)
194
+ when :boolean
195
+ coerce_boolean(value)
196
+ else
197
+ value
198
+ end
199
+ rescue ArgumentError
200
+ raise ArgumentError, "Invalid value for argument #{argument.name}: expected #{argument.type}"
201
+ end
202
+
203
+ def coerce_boolean(value)
204
+ return true if value == true || value.to_s.casecmp("true").zero?
205
+ return false if value == false || value.to_s.casecmp("false").zero?
206
+
207
+ raise ArgumentError
208
+ end
209
+
210
+ def validate_required_options!(options)
211
+ missing = command_class.options.select do |option|
212
+ option.required && (options[option.name].nil? || options[option.name] == "")
213
+ end
214
+ return if missing.empty?
215
+
216
+ flags = missing.map(&:long_switch).join(", ")
217
+ raise ArgumentError, "Missing required options: #{flags}"
218
+ end
219
+
220
+ def command_label
221
+ raw = command_class.name.to_s
222
+ token = raw.empty? ? "command" : (raw.split("::").last || "command")
223
+ token.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
224
+ end
225
+
226
+ def parse_error_message(error)
227
+ return "#{error.message}. Did you mean: #{suggest_for(error)}" if error.is_a?(OptionParser::InvalidOption)
228
+
229
+ error.message
230
+ end
231
+
232
+ def suggest_for(error)
233
+ token = error.args.first.to_s
234
+ return "(no suggestion)" if token.empty?
235
+
236
+ candidates = command_class.options.flat_map do |option|
237
+ option.aliases + [option.long_switch]
238
+ end
239
+
240
+ ranked = candidates.sort_by do |candidate|
241
+ levenshtein(token, candidate)
242
+ end
243
+
244
+ ranked.first || "(no suggestion)"
245
+ end
246
+
247
+ def levenshtein(source, target)
248
+ m = source.length
249
+ n = target.length
250
+ return n if m.zero?
251
+ return m if n.zero?
252
+
253
+ matrix = Array.new(m + 1) { |i| [i] + [0] * n }
254
+ (0..n).each { |j| matrix[0][j] = j }
255
+
256
+ (1..m).each do |i|
257
+ (1..n).each do |j|
258
+ cost = (source[i - 1] == target[j - 1]) ? 0 : 1
259
+ matrix[i][j] = [
260
+ matrix[i - 1][j] + 1,
261
+ matrix[i][j - 1] + 1,
262
+ matrix[i - 1][j - 1] + cost
263
+ ].min
264
+ end
265
+ end
266
+
267
+ matrix[m][n]
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+
5
+ module Ace
6
+ module Support
7
+ module Cli
8
+ class Registry
9
+ Node = Struct.new(:command, :children)
10
+
11
+ attr_reader :version
12
+
13
+ def initialize(version: nil)
14
+ @version = version
15
+ @root = Node.new(nil, {})
16
+ end
17
+
18
+ def register(name, command_class = nil)
19
+ node = ensure_path(name)
20
+ node.command = command_class if command_class
21
+ yield NestedRegistry.new(node) if block_given?
22
+ self
23
+ end
24
+
25
+ def resolve(args)
26
+ raise CommandNotFoundError, "No commands registered" if @root.children.empty?
27
+ raise CommandNotFoundError, "No command provided" if args.empty?
28
+
29
+ tokens = args.dup
30
+ node = @root
31
+ consumed = 0
32
+
33
+ tokens.each do |token|
34
+ child = node.children[token]
35
+ break unless child
36
+
37
+ node = child
38
+ consumed += 1
39
+ end
40
+
41
+ unless node.command
42
+ attempted = tokens.first(consumed + 1).join(" ")
43
+ raise CommandNotFoundError, "Command not found: #{attempted.strip}"
44
+ end
45
+
46
+ [node.command, tokens.drop(consumed)]
47
+ end
48
+
49
+ def commands
50
+ @root.children.each_with_object({}) do |(name, node), hash|
51
+ hash[name] = node.command || NestedRegistry.new(node)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def ensure_path(name)
58
+ parts = name.to_s.split(" ")
59
+ raise ArgumentError, "Command name cannot be empty" if parts.empty?
60
+
61
+ parts.reduce(@root) do |node, part|
62
+ node.children[part] ||= Node.new(nil, {})
63
+ end
64
+ end
65
+
66
+ class NestedRegistry
67
+ def initialize(node)
68
+ @node = node
69
+ end
70
+
71
+ def register(name, command_class = nil)
72
+ parts = name.to_s.split(" ")
73
+ raise ArgumentError, "Command name cannot be empty" if parts.empty?
74
+
75
+ node = parts.reduce(@node) do |current, part|
76
+ current.children[part] ||= Node.new(nil, {})
77
+ end
78
+ node.command = command_class if command_class
79
+ yield NestedRegistry.new(node) if block_given?
80
+ self
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "registry"
4
+ require_relative "error"
5
+
6
+ module Ace
7
+ module Support
8
+ module Cli
9
+ # DSL adapter allowing CLI modules to keep `register` semantics
10
+ # while using Ace::Support::Cli::Registry under the hood.
11
+ module RegistryDsl
12
+ def self.extended(base)
13
+ base.instance_variable_set(:@registry, Ace::Support::Cli::Registry.new)
14
+ end
15
+
16
+ def register(name, command_class = nil, *_args, aliases: nil, **_kwargs)
17
+ registry.register(name, normalize_command(command_class))
18
+ Array(aliases).each do |aliaz|
19
+ registry.register(aliaz, normalize_command(command_class))
20
+ end
21
+ self
22
+ end
23
+
24
+ def resolve(args)
25
+ return registry.resolve(args) unless args.empty?
26
+
27
+ registry.resolve(["--help"])
28
+ rescue Ace::Support::Cli::CommandNotFoundError
29
+ raise Ace::Support::Cli::Error.new("unknown command", exit_code: 1)
30
+ end
31
+
32
+ private
33
+
34
+ def registry
35
+ @registry ||= Ace::Support::Cli::Registry.new
36
+ end
37
+
38
+ def normalize_command(command_class)
39
+ command_class
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parser"
4
+
5
+ module Ace
6
+ module Support
7
+ module Cli
8
+ class Runner
9
+ def initialize(registry, parser_class: Parser)
10
+ @registry = registry
11
+ @parser_class = parser_class
12
+ end
13
+
14
+ def call(args: ARGV)
15
+ if root_help_request?(args)
16
+ puts Ace::Support::Cli::Help::Usage.new(@registry, program_name: resolve_program_name).render
17
+ return 0
18
+ end
19
+
20
+ command_target, remaining, command_name = resolve_target(args)
21
+ command_class = command_target.is_a?(Class) ? command_target : command_target.class
22
+ parsed = @parser_class.new(command_class, command_name: command_name).parse(remaining)
23
+ result = if command_target.is_a?(Class)
24
+ command_target.new.call(**parsed)
25
+ else
26
+ command_target.call(**parsed)
27
+ end
28
+ result.nil? ? 0 : result
29
+ rescue Ace::Support::Cli::HelpRendered => e
30
+ puts e.output
31
+ e.status
32
+ rescue Ace::Support::Cli::ParseError => e
33
+ raise Ace::Support::Cli::Error.new(e.message)
34
+ rescue Ace::Support::Cli::CommandNotFoundError => e
35
+ raise Ace::Support::Cli::Error.new(e.message)
36
+ end
37
+
38
+ private
39
+
40
+ def root_help_request?(args)
41
+ @registry.respond_to?(:resolve) && (%w[--help -h].include?(args.first) || args.empty?)
42
+ end
43
+
44
+ def resolve_target(args)
45
+ program = resolve_program_name
46
+ if @registry.respond_to?(:resolve)
47
+ command, remaining = @registry.resolve(args)
48
+ consumed = args.length - remaining.length
49
+ name = ([program] + args.first(consumed)).join(" ")
50
+ [command, remaining, name]
51
+ else
52
+ normalized = args.dup
53
+ token = @registry.name.to_s.split("::").last.gsub(/([a-z])([A-Z])/, '\1-\2').downcase
54
+ normalized.shift if !token.empty? && normalized.first == token
55
+ [@registry, normalized, program]
56
+ end
57
+ end
58
+
59
+ def resolve_program_name
60
+ if @registry.respond_to?(:const_defined?) && @registry.const_defined?(:PROGRAM_NAME)
61
+ @registry.const_get(:PROGRAM_NAME)
62
+ else
63
+ File.basename($PROGRAM_NAME)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Cli
6
+ module StandardOptions
7
+ QUIET_DESC = "Suppress non-essential output"
8
+ VERBOSE_DESC = "Show verbose output"
9
+ DEBUG_DESC = "Show debug output"
10
+ HELP_DESC = "Show this help"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Support
5
+ module Cli
6
+ VERSION = "0.6.2"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/version"
4
+ require_relative "cli/errors"
5
+ require_relative "cli/error"
6
+ require_relative "cli/standard_options"
7
+ require_relative "cli/base"
8
+ require_relative "cli/registry_dsl"
9
+ require_relative "cli/models/option"
10
+ require_relative "cli/models/argument"
11
+ require_relative "cli/command"
12
+ require_relative "cli/parser"
13
+ require_relative "cli/argv_coalescer"
14
+ require_relative "cli/registry"
15
+ require_relative "cli/runner"
16
+ require_relative "cli/help/banner"
17
+ require_relative "cli/help/usage"
18
+ require_relative "cli/help/concise"
19
+ require_relative "cli/help/help_command"
20
+ require_relative "cli/help/version_command"
21
+ require_relative "cli/help/two_tier_help"
22
+
23
+ module Ace
24
+ module Support
25
+ module Cli
26
+ Banner = Help::Banner
27
+ HelpConcise = Help::Concise
28
+ HelpCommand = Help::HelpCommand
29
+ VersionCommand = Help::VersionCommand
30
+ TwoTierHelp = Help::TwoTierHelp
31
+
32
+ class Usage < Help::Usage
33
+ end
34
+ end
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ace-support-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.2
5
+ platform: ruby
6
+ authors:
7
+ - Michal Czyz
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Provides command DSL, option parsing, registry routing, and runner primitives
13
+ for ACE CLI tools.
14
+ email:
15
+ - mc@cs3b.com
16
+ executables:
17
+ - ace-support-cli
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - Rakefile
25
+ - exe/ace-support-cli
26
+ - lib/ace/support/cli.rb
27
+ - lib/ace/support/cli/argv_coalescer.rb
28
+ - lib/ace/support/cli/base.rb
29
+ - lib/ace/support/cli/command.rb
30
+ - lib/ace/support/cli/error.rb
31
+ - lib/ace/support/cli/errors.rb
32
+ - lib/ace/support/cli/help/banner.rb
33
+ - lib/ace/support/cli/help/concise.rb
34
+ - lib/ace/support/cli/help/help_command.rb
35
+ - lib/ace/support/cli/help/two_tier_help.rb
36
+ - lib/ace/support/cli/help/usage.rb
37
+ - lib/ace/support/cli/help/version_command.rb
38
+ - lib/ace/support/cli/models/argument.rb
39
+ - lib/ace/support/cli/models/option.rb
40
+ - lib/ace/support/cli/parser.rb
41
+ - lib/ace/support/cli/registry.rb
42
+ - lib/ace/support/cli/registry_dsl.rb
43
+ - lib/ace/support/cli/runner.rb
44
+ - lib/ace/support/cli/standard_options.rb
45
+ - lib/ace/support/cli/version.rb
46
+ homepage: https://github.com/cs3b/ace
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ allowed_push_host: https://rubygems.org
51
+ homepage_uri: https://github.com/cs3b/ace
52
+ source_code_uri: https://github.com/cs3b/ace/tree/main/ace-support-cli/
53
+ changelog_uri: https://github.com/cs3b/ace/blob/main/ace-support-cli/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.2.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.9
69
+ specification_version: 4
70
+ summary: CLI command framework for ACE gems
71
+ test_files: []