ergane 0.1.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53b7a242e903c28ff9ddb5f33ee399c044fd618f0420cd3868cecb02c4e277e6
4
- data.tar.gz: 9f8e7b16cc0f64f3547b98e41d973f4b90528215e516873600eb6e953bd2141a
3
+ metadata.gz: 5bac11b2362d538571dc708483d4cbc83ee2a66e51983c5e9c312e5a1a98d3f3
4
+ data.tar.gz: e27be5089a4034d7a67d26b17f4b5572d723a6ed38f3c29275d2089d6ead65cd
5
5
  SHA512:
6
- metadata.gz: fdcf833f1190263dc9f37f470c03d7d150e566175d927e2fec39c036a22111fca8b59c7af4d64a1c1c66af1a455eecb257f66cf2c5276f0f672a513eda123e9a
7
- data.tar.gz: c85a0eb19d99c6c5424e40655a6c64f33591e04a0d11d576076e2e9a03d2e9ed4f8b26080c21b052145fdff3097e4bd01687226e31abaf84f77013e155954dd9
6
+ metadata.gz: e136d486d292c5bac857f0cd8a950189d562aef43f76320b65c672bcc11f98af37658cac4b201a607c8ba103f73d84a94b5bfeafefa4044ccbd98a62707240ad
7
+ data.tar.gz: c36af86e50cb12b0cdd270744cea2f4c3a3f88570c8bd305bcd976f8466ffa8e6d34a827df3619b317b2c86fcc30d73fbcecc4b10d7a9b4eed250d028baf9913
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-05-25
6
+
7
+ ### Changed
8
+ - **Unknown subcommands on a command group now raise `CommandNotFound`** (with a did-you-mean suggestion) and exit non-zero, instead of silently printing help. Scoped to command groups — leaf commands still treat unmatched tokens as positional arguments.
9
+ - **Positional `argument` declarations are now enforced.** Required-ness is derived from the command's `run` signature — a required parameter (`run(name)`) makes the argument required, while an optional one (`run(name = nil)`) or a splat (`run(*)`) makes it optional; an explicit `required:` on the `argument` overrides the signature. A missing required argument raises `MissingArgument`; an absent optional argument takes its `default:`; values are coerced to the declared `type:` (`String` is identity, `Integer`/`Float` via Kernel conversion raising `InvalidOption` on bad input). Previously these keywords affected only help text.
10
+
11
+ ### Added
12
+ - `Ergane::DSL::Macros.dsl_value` — a class-level getter/setter accessor generator used by the DSL.
13
+
14
+ ### Fixed
15
+ - `PathRegistry#abbreviate` now expands its input before matching (mirroring `#register`), so abbreviation is consistent across platforms (notably Windows, where un-expanded inputs failed to match drive-qualified prefixes) and accepts `~`-relative input.
16
+ - `OptionParser#order_recognized!` no longer drops the trailing tokens of a multi-token unknown option, and preserves argument order.
17
+ - `String#blank?` is guarded against external definitions (e.g. ActiveSupport) instead of unconditionally overriding them.
18
+ - Tool-rooted abstract intermediate commands are no longer stranded in the tool's registry when marked abstract.
19
+
20
+ ### Internal
21
+ - Command registration unified into a single `register!` path (removed the duplicate `define_singleton_method`'d `inherited`/`inherited_command_name_set` hooks).
22
+ - `HelpFormatter` renders through a shared `section` helper with a per-render color cycler (no module-level mutable state).
23
+ - `Util::Debug` is no longer packaged in the gem (dev-only tooling).
24
+
5
25
  ## [0.1.0] - 2026-05-25
6
26
 
7
27
  First release of the rewritten framework. A near-complete rewrite of the
data/README.md CHANGED
@@ -174,6 +174,18 @@ run do |*args|
174
174
  end
175
175
  ```
176
176
 
177
+ ### Positional Arguments
178
+
179
+ An argument's required-ness is derived from the command's `run` signature — a required parameter makes it required, while an optional parameter or a splat makes it optional:
180
+
181
+ ```ruby
182
+ def run(source, destination = nil, *rest)
183
+ # source is required; destination is optional
184
+ end
185
+ ```
186
+
187
+ Pass `required:` on the `argument` to override the signature, `type:` to coerce the value (a non-`String` type is converted, raising on failure), and `default:` for an absent optional argument. A missing required argument raises `Ergane::MissingArgument`.
188
+
177
189
  ## Loading Commands from Files
178
190
 
179
191
  For larger CLIs, organize commands in separate files:
@@ -4,7 +4,9 @@ module Ergane
4
4
  class ArgumentDefinition
5
5
  attr_reader :name, :type, :description, :required, :default
6
6
 
7
- def initialize(name, type = String, description: nil, required: true, default: nil)
7
+ # +required+ defaults to nil, meaning "derive from the run signature";
8
+ # pass true/false to force it.
9
+ def initialize(name, type = String, description: nil, required: nil, default: nil)
8
10
  @name = name.to_sym
9
11
  @type = type
10
12
  @description = description
@@ -11,14 +11,7 @@ module Ergane
11
11
  class << self
12
12
  def command_name=(name)
13
13
  @command_name = name&.to_sym
14
- return unless @command_name
15
-
16
- parent = superclass
17
- if parent.respond_to?(:tool) && parent.abstract_class?
18
- parent.inherited_command_name_set(self)
19
- else
20
- register_subcommand(self)
21
- end
14
+ register!
22
15
  end
23
16
 
24
17
  def command_name
@@ -33,16 +26,36 @@ module Ergane
33
26
  @subcommands ||= {}
34
27
  end
35
28
 
29
+ # Effective required-ness of the positional argument at +index+. An
30
+ # explicit DSL `required:` (true/false) wins; otherwise it's derived from
31
+ # the run method's matching positional parameter: a required parameter
32
+ # (`run(name)`) means required, while an optional one (`run(name = nil)`)
33
+ # or a splat (`run(*)`) means optional.
34
+ def argument_required?(index)
35
+ defn = argument_definitions[index]
36
+ return false unless defn
37
+ return defn.required unless defn.required.nil?
38
+
39
+ param = run_positional_parameters[index]
40
+ param ? param.first == :req : false
41
+ end
42
+
36
43
  def inherited(subclass)
37
44
  super
38
45
  subclass.instance_variable_set(:@option_definitions, option_definitions.dup)
39
46
  subclass.instance_variable_set(:@argument_definitions, argument_definitions.dup)
40
47
  subclass.instance_variable_set(:@subcommands, {})
41
- register_subcommand(subclass)
48
+ subclass.send(:register!)
42
49
  end
43
50
 
44
51
  private
45
52
 
53
+ # The run method's positional parameters (required/optional), in order,
54
+ # which line up with declared arguments. Excludes splat/keyword params.
55
+ def run_positional_parameters
56
+ instance_method(:run).parameters.select { |kind, _| kind == :req || kind == :opt }
57
+ end
58
+
46
59
  def derive_command_name
47
60
  return nil if self == Command || abstract_class?
48
61
  base = name&.demodulize
@@ -50,12 +63,36 @@ module Ergane
50
63
  base.sub(/Command$/, "").underscore.to_sym
51
64
  end
52
65
 
53
- def register_subcommand(subclass)
54
- parent = subclass.superclass
55
- return if parent == Command || parent.abstract_class?
56
- cmd_name = subclass.command_name
57
- return unless cmd_name
58
- parent.subcommands[cmd_name] = subclass
66
+ # The registry this command belongs in: a tool's abstract command base
67
+ # registers under the tool itself; a concrete parent registers under
68
+ # that parent. A command rooted directly on Command, or under an
69
+ # abstract non-tool parent, registers nowhere.
70
+ def registration_target
71
+ parent = superclass
72
+ if parent.respond_to?(:tool) && parent.abstract_class?
73
+ parent.tool
74
+ elsif parent != Command && !parent.abstract_class?
75
+ parent
76
+ end
77
+ end
78
+
79
+ # Registers (or re-registers) this command in its target registry under
80
+ # its current command_name, removing any prior registration when the
81
+ # name changes or the command becomes abstract. Idempotent — safe to
82
+ # call from both .inherited and command_name=.
83
+ def register!
84
+ target = registration_target
85
+ return unless target
86
+
87
+ name = abstract_class? ? nil : command_name
88
+
89
+ target.subcommands.delete(@registered_as) if @registered_as && @registered_as != name
90
+ if name
91
+ target.subcommands[name] = self
92
+ @registered_as = name
93
+ else
94
+ @registered_as = nil
95
+ end
59
96
  end
60
97
  end
61
98
 
@@ -67,7 +104,7 @@ module Ergane
67
104
 
68
105
  def initialize(argv = [])
69
106
  @options = self.class.build_default_options
70
- @argv = parse_options(argv.dup)
107
+ @argv = process_arguments(parse_options(argv.dup))
71
108
  end
72
109
 
73
110
  def args
@@ -81,5 +118,42 @@ module Ergane
81
118
  raise AbstractCommand, "#{self.class.name}#run is not implemented"
82
119
  end
83
120
  end
121
+
122
+ private
123
+
124
+ # Validates and coerces positional args against the command's argument
125
+ # definitions: missing required args raise, absent optional args take
126
+ # their default, and present args are coerced by type. Extra positionals
127
+ # beyond the declared arguments pass through untouched (e.g. for run(*)).
128
+ def process_arguments(argv)
129
+ definitions = self.class.argument_definitions
130
+ return argv if definitions.empty?
131
+
132
+ declared = definitions.each_with_index.map do |defn, i|
133
+ if i < argv.length
134
+ coerce_argument(argv[i], defn)
135
+ elsif self.class.argument_required?(i)
136
+ raise MissingArgument, "Missing required argument: <#{defn.name}>"
137
+ else
138
+ defn.default
139
+ end
140
+ end
141
+ declared + argv.drop(definitions.length)
142
+ end
143
+
144
+ def coerce_argument(value, defn)
145
+ type = defn.type
146
+ return value if type.nil? || type == String
147
+
148
+ if type == Integer
149
+ Integer(value)
150
+ elsif type == Float
151
+ Float(value)
152
+ else
153
+ value
154
+ end
155
+ rescue ArgumentError
156
+ raise InvalidOption, "Invalid value for <#{defn.name}>: #{value.inspect} (expected #{type})"
157
+ end
84
158
  end
85
159
  end
@@ -10,11 +10,9 @@ module Ergane
10
10
  module ClassMethods
11
11
  def abstract_class=(value)
12
12
  @abstract_class = value
13
- # Unregister from parent's subcommands when marked abstract
14
- if value && respond_to?(:command_name) && self < Ergane::Command
15
- parent = superclass
16
- parent.subcommands.delete(command_name) if parent.respond_to?(:subcommands)
17
- end
13
+ # Re-run registration so the command leaves (or rejoins) its
14
+ # registry to match its new abstract state.
15
+ register! if self < Ergane::Command && respond_to?(:register!, true)
18
16
  end
19
17
 
20
18
  def abstract_class?
@@ -32,10 +32,14 @@ class TrueClass
32
32
  end
33
33
 
34
34
  class String
35
- # Override Object#blank? to also catch whitespace-only strings
35
+ # Catch whitespace-only strings, overriding the inherited Object#blank?.
36
+ # Guarded on String's OWN methods — not method_defined?, which would see the
37
+ # inherited Object#blank? and skip, leaving the whitespace-blind version. This
38
+ # still defines our version normally, but yields to any external String#blank?
39
+ # (e.g. ActiveSupport) rather than clobbering it.
36
40
  def blank?
37
41
  empty? || /\A[[:space:]]*\z/.match?(self)
38
- end
42
+ end unless instance_methods(false).include?(:blank?)
39
43
  end
40
44
 
41
45
  class Numeric
@@ -4,13 +4,15 @@ class OptionParser
4
4
  # Like order!, but leave any unrecognized --switches alone
5
5
  # instead of raising InvalidOption.
6
6
  def order_recognized!(args)
7
- extra_opts = []
8
- begin
9
- order!(args) { |a| extra_opts << a }
10
- rescue OptionParser::InvalidOption => e
11
- extra_opts << e.args[0]
12
- retry
7
+ leftover = []
8
+ until args.empty?
9
+ begin
10
+ order!(args) { |nonopt| leftover << nonopt }
11
+ break
12
+ rescue OptionParser::InvalidOption => e
13
+ leftover.concat(e.args)
14
+ end
13
15
  end
14
- args[0, 0] = extra_opts
16
+ args.replace(leftover)
15
17
  end
16
18
  end
@@ -3,9 +3,9 @@
3
3
  module Ergane
4
4
  module DSL
5
5
  module CommandDSL
6
- def description(text = nil)
7
- text ? (@description = text) : (@description || "")
8
- end
6
+ extend Macros
7
+
8
+ dsl_value :description, default: ""
9
9
 
10
10
  def aliases(*names)
11
11
  names.any? ? (@aliases = names.flatten.map(&:to_sym)) : (@aliases || [])
@@ -22,7 +22,7 @@ module Ergane
22
22
  option(name, nil, short: short, description: description, default: false)
23
23
  end
24
24
 
25
- def argument(name, type = String, description: nil, required: true, default: nil)
25
+ def argument(name, type = String, description: nil, required: nil, default: nil)
26
26
  argument_definitions << ArgumentDefinition.new(
27
27
  name, type, description: description, required: required, default: default
28
28
  )
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ module DSL
5
+ # Helpers for defining DSL methods on a host class/module.
6
+ module Macros
7
+ # Defines a class-level "value" accessor with combined getter/setter
8
+ # semantics: called with a truthy argument it stores and returns it;
9
+ # called with none (or a falsy value) it returns the stored value, or
10
+ # +default+ if unset.
11
+ #
12
+ # dsl_value :description, default: ""
13
+ # description "Deploy" # => "Deploy" (and stored)
14
+ # description # => "Deploy"
15
+ def dsl_value(name, default: nil)
16
+ ivar = "@#{name}"
17
+ define_method(name) do |value = nil|
18
+ value ? instance_variable_set(ivar, value) : (instance_variable_get(ivar) || default)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -37,8 +37,8 @@ module Ergane
37
37
  usage = path.light_red
38
38
  usage += " [options]".light_cyan if command_class.option_definitions.any?
39
39
  usage += " [subcommand]".light_black.underline if command_class.subcommands.any?
40
- command_class.argument_definitions.each do |arg|
41
- label = arg.required ? "<#{arg.name}>" : "[#{arg.name}]"
40
+ command_class.argument_definitions.each_with_index do |arg, i|
41
+ label = command_class.argument_required?(i) ? "<#{arg.name}>" : "[#{arg.name}]"
42
42
  usage += " #{label}".light_yellow
43
43
  end
44
44
  "Usage:".light_cyan + " " + usage
@@ -49,21 +49,18 @@ module Ergane
49
49
  return if subs.empty?
50
50
 
51
51
  max_width = subs.keys.map { |k| k.to_s.length }.max
52
- Util::Formatting.reset_colors!
53
-
54
- lines = []
55
- lines << "Subcommands:".light_cyan
56
- header_len = lines.last.uncolorize.length
57
- lines << (" \u250C" + ("\u2500" * (header_len - 2)) + "\u2518").light_black
52
+ colors = Util::Formatting::COLORS.cycle
53
+ title = "Subcommands"
58
54
 
55
+ lines = [(" \u250C" + ("\u2500" * (title.length - 1)) + "\u2518").light_black]
59
56
  subs.each do |name, sub_class|
60
57
  label = name.to_s.ljust(max_width + 2)
61
58
  desc = sub_class.description.present? ? sub_class.description.light_black : ""
62
- lines << " \u251C\u2500\u2510".light_black + " #{label.send(Util::Formatting.next_color)} #{desc}"
59
+ lines << " \u251C\u2500\u2510".light_black + " #{label.send(colors.next)} #{desc}"
63
60
  end
64
-
65
61
  lines << (" \u2514" + "\u2500" * 40).light_black
66
- lines.join("\n")
62
+
63
+ section(title, lines)
67
64
  end
68
65
 
69
66
  def options_section
@@ -72,14 +69,13 @@ module Ergane
72
69
 
73
70
  max_width = opts.values.map { |o| o.signature.length }.max
74
71
 
75
- lines = ["Options:".light_cyan]
76
- opts.each_value do |opt|
72
+ lines = opts.each_value.map do |opt|
77
73
  sig = opt.signature.ljust(max_width + 2)
78
74
  desc = opt.description || ""
79
75
  default_note = opt.default_value ? " (default: #{opt.default_value})".light_black : ""
80
- lines << " #{sig.light_green} #{desc}#{default_note}"
76
+ " #{sig.light_green} #{desc}#{default_note}"
81
77
  end
82
- lines.join("\n")
78
+ section("Options", lines)
83
79
  end
84
80
 
85
81
  def arguments_section
@@ -88,14 +84,21 @@ module Ergane
88
84
 
89
85
  max_width = args.map { |a| a.name.to_s.length }.max
90
86
 
91
- lines = ["Arguments:".light_cyan]
92
- args.each do |arg|
87
+ lines = args.each_with_index.map do |arg, i|
93
88
  label = arg.name.to_s.ljust(max_width + 2)
94
89
  desc = arg.description || ""
95
- req = arg.required ? " (required)".light_red : " (optional)".light_black
96
- lines << " #{label.light_yellow} #{desc}#{req}"
90
+ req = command_class.argument_required?(i) ? " (required)".light_red : " (optional)".light_black
91
+ " #{label.light_yellow} #{desc}#{req}"
97
92
  end
98
- lines.join("\n")
93
+ section("Arguments", lines)
94
+ end
95
+
96
+ # Renders a titled block: a cyan "Title:" header followed by its lines,
97
+ # or nil when there are no lines (so #format compacts it away).
98
+ def section(title, lines)
99
+ return if lines.empty?
100
+
101
+ ["#{title}:".light_cyan, *lines].join("\n")
99
102
  end
100
103
  end
101
104
  end
@@ -32,16 +32,19 @@ module Ergane
32
32
  self
33
33
  end
34
34
 
35
- # Collapse the longest matching prefix in +path+ to its label,
36
- # returning the path unchanged when nothing matches.
35
+ # Collapse the longest matching prefix in +path+ to its label, returning
36
+ # the path unchanged when nothing matches. The input is expanded before
37
+ # matching (mirroring how prefixes are stored on #register), so matching
38
+ # is consistent across platforms and "~"-relative input is accepted.
37
39
  def abbreviate(path)
38
- str = path.to_s
40
+ original = path.to_s
41
+ expanded = File.expand_path(original)
39
42
  best = @substitutions
40
- .select { |sub| str == sub.prefix || str.start_with?("#{sub.prefix}/") }
43
+ .select { |sub| expanded == sub.prefix || expanded.start_with?("#{sub.prefix}/") }
41
44
  .max_by { |sub| sub.prefix.length }
42
- return str unless best
45
+ return original unless best
43
46
 
44
- "#{best.label}#{str[best.prefix.length..]}"
47
+ "#{best.label}#{expanded[best.prefix.length..]}"
45
48
  end
46
49
  end
47
50
  end
data/lib/ergane/runner.rb CHANGED
@@ -47,6 +47,10 @@ module Ergane
47
47
  if sub
48
48
  args.shift
49
49
  resolve(sub, args, path)
50
+ elsif command_class.subcommands.any?
51
+ # The current command is a group, so an unmatched token is a bad
52
+ # subcommand — not a positional arg for a leaf command.
53
+ raise CommandNotFound.new(token, available: command_class.subcommands.keys)
50
54
  else
51
55
  [command_class, args, path]
52
56
  end
data/lib/ergane/tool.rb CHANGED
@@ -5,6 +5,10 @@ module Ergane
5
5
  self.abstract_class = true
6
6
 
7
7
  class << self
8
+ extend DSL::Macros
9
+
10
+ dsl_value :version
11
+
8
12
  def command_class(klass = nil)
9
13
  if klass
10
14
  @command_class = klass
@@ -18,10 +22,6 @@ module Ergane
18
22
  name ? (self.command_name = name) : command_name
19
23
  end
20
24
 
21
- def version(ver = nil)
22
- ver ? (@version = ver) : @version
23
- end
24
-
25
25
  def start(argv = ARGV)
26
26
  Runner.new(self, argv.dup).execute
27
27
  rescue Interrupt
@@ -58,27 +58,12 @@ module Ergane
58
58
  tool_subclass.command_class(base)
59
59
  end
60
60
 
61
+ # Gives the tool's command base (and everything below it) a reference
62
+ # back to the tool, so Command#registration_target can route
63
+ # subcommands to the tool's registry.
61
64
  def wire_command_class(klass)
62
65
  tool = self
63
66
  klass.define_singleton_method(:tool) { tool }
64
-
65
- klass.define_singleton_method(:inherited) do |subclass|
66
- super(subclass)
67
- cmd_name = subclass.command_name
68
- if cmd_name && !subclass.abstract_class? && subclass.superclass.abstract_class?
69
- subclass.instance_variable_set(:@_derived_name, cmd_name)
70
- tool.subcommands[cmd_name] = subclass
71
- end
72
- end
73
-
74
- klass.define_singleton_method(:inherited_command_name_set) do |subclass|
75
- cmd_name = subclass.command_name
76
- if cmd_name && !subclass.abstract_class? && subclass.superclass.abstract_class?
77
- derived = subclass.instance_variable_get(:@_derived_name)
78
- tool.subcommands.delete(derived) if derived && derived != cmd_name
79
- tool.subcommands[cmd_name] = subclass
80
- end
81
- end
82
67
  end
83
68
  end
84
69
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ergane
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ergane
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dale Stevens
@@ -160,6 +160,7 @@ files:
160
160
  - lib/ergane/core_ext/string.rb
161
161
  - lib/ergane/dsl/block_dsl.rb
162
162
  - lib/ergane/dsl/command_dsl.rb
163
+ - lib/ergane/dsl/macros.rb
163
164
  - lib/ergane/errors.rb
164
165
  - lib/ergane/formatter.rb
165
166
  - lib/ergane/help_formatter.rb
@@ -167,7 +168,6 @@ files:
167
168
  - lib/ergane/path_registry.rb
168
169
  - lib/ergane/runner.rb
169
170
  - lib/ergane/tool.rb
170
- - lib/ergane/util/debug.rb
171
171
  - lib/ergane/util/formatting.rb
172
172
  - lib/ergane/version.rb
173
173
  homepage: https://github.com/TwilightCoders/ergane
@@ -175,7 +175,6 @@ licenses:
175
175
  - MIT
176
176
  metadata:
177
177
  rubygems_mfa_required: 'true'
178
- homepage_uri: https://github.com/TwilightCoders/ergane
179
178
  source_code_uri: https://github.com/TwilightCoders/ergane
180
179
  changelog_uri: https://github.com/TwilightCoders/ergane/blob/main/CHANGELOG.md
181
180
  rdoc_options: []
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ergane
4
- module Util
5
- module Debug
6
- def self.enabled?
7
- !!$ergane_debug
8
- end
9
-
10
- def self.enable!
11
- $ergane_debug = true
12
- end
13
-
14
- def self.disable!
15
- $ergane_debug = false
16
- end
17
-
18
- def self.log(message)
19
- return unless enabled?
20
- $stderr.puts "[Ergane DEBUG] #{message}"
21
- end
22
- end
23
- end
24
- end