ergane 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +44 -1
- data/LICENSE +18 -17
- data/README.md +271 -10
- data/lib/ergane/argument_definition.rb +17 -0
- data/lib/ergane/command.rb +159 -0
- data/lib/ergane/concerns/inheritance.rb +42 -0
- data/lib/ergane/concerns/option_handling.rb +43 -0
- data/lib/ergane/core_ext/array.rb +13 -0
- data/lib/ergane/core_ext/hash.rb +10 -0
- data/lib/ergane/core_ext/object.rb +47 -0
- data/lib/ergane/core_ext/option_parser.rb +18 -0
- data/lib/ergane/core_ext/string.rb +22 -0
- data/lib/ergane/dsl/block_dsl.rb +29 -0
- data/lib/ergane/dsl/command_dsl.rb +50 -0
- data/lib/ergane/dsl/macros.rb +23 -0
- data/lib/ergane/errors.rb +51 -0
- data/lib/ergane/formatter.rb +29 -0
- data/lib/ergane/help_formatter.rb +104 -0
- data/lib/ergane/option_definition.rb +49 -0
- data/lib/ergane/path_registry.rb +50 -0
- data/lib/ergane/runner.rb +64 -0
- data/lib/ergane/tool.rb +49 -95
- data/lib/ergane/util/formatting.rb +31 -0
- data/lib/ergane/version.rb +3 -1
- data/lib/ergane.rb +32 -77
- metadata +68 -72
- data/app/commands/ergane/console.rb +0 -22
- data/bin/ergane +0 -24
- data/lib/core_ext/option_parser.rb +0 -15
- data/lib/ergane/command_definition.rb +0 -176
- data/lib/ergane/debug.rb +0 -75
- data/lib/ergane/helpers/hashall.rb +0 -8
- data/lib/ergane/switch_definition.rb +0 -49
- data/lib/ergane/util.rb +0 -77
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Object
|
|
4
|
+
def blank?
|
|
5
|
+
respond_to?(:empty?) ? !!empty? : !self
|
|
6
|
+
end unless method_defined?(:blank?)
|
|
7
|
+
|
|
8
|
+
def present?
|
|
9
|
+
!blank?
|
|
10
|
+
end unless method_defined?(:present?)
|
|
11
|
+
|
|
12
|
+
def try(method_name = nil, *args, &block)
|
|
13
|
+
if method_name
|
|
14
|
+
respond_to?(method_name) ? public_send(method_name, *args, &block) : nil
|
|
15
|
+
elsif block
|
|
16
|
+
yield self
|
|
17
|
+
end
|
|
18
|
+
end unless method_defined?(:try)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class NilClass
|
|
22
|
+
def blank? = true unless method_defined?(:blank?)
|
|
23
|
+
def try(*) = nil unless method_defined?(:try)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class FalseClass
|
|
27
|
+
def blank? = true unless method_defined?(:blank?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class TrueClass
|
|
31
|
+
def blank? = false unless method_defined?(:blank?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class String
|
|
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.
|
|
40
|
+
def blank?
|
|
41
|
+
empty? || /\A[[:space:]]*\z/.match?(self)
|
|
42
|
+
end unless instance_methods(false).include?(:blank?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class Numeric
|
|
46
|
+
def blank? = false unless method_defined?(:blank?)
|
|
47
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class OptionParser
|
|
4
|
+
# Like order!, but leave any unrecognized --switches alone
|
|
5
|
+
# instead of raising InvalidOption.
|
|
6
|
+
def order_recognized!(args)
|
|
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
|
|
15
|
+
end
|
|
16
|
+
args.replace(leftover)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class String
|
|
4
|
+
# "DeployCommand" -> "deploy_command"
|
|
5
|
+
# "Ergane::Deploy" -> "ergane/deploy"
|
|
6
|
+
def underscore
|
|
7
|
+
gsub("::", "/")
|
|
8
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
9
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
10
|
+
.tr("-", "_")
|
|
11
|
+
.downcase
|
|
12
|
+
end unless method_defined?(:underscore)
|
|
13
|
+
|
|
14
|
+
# "Ergane::Deploy" -> "Deploy"
|
|
15
|
+
def demodulize
|
|
16
|
+
if (index = rindex("::"))
|
|
17
|
+
self[(index + 2)..]
|
|
18
|
+
else
|
|
19
|
+
dup
|
|
20
|
+
end
|
|
21
|
+
end unless method_defined?(:demodulize)
|
|
22
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
module DSL
|
|
5
|
+
class BlockDSL
|
|
6
|
+
def initialize(command_class)
|
|
7
|
+
@command_class = command_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run(&block)
|
|
11
|
+
@command_class.define_method(:run) { |*args| instance_exec(*args, &block) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def respond_to_missing?(name, include_private = false)
|
|
17
|
+
@command_class.respond_to?(name) || super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def method_missing(name, *args, **opts, &block)
|
|
21
|
+
if @command_class.respond_to?(name)
|
|
22
|
+
@command_class.public_send(name, *args, **opts, &block)
|
|
23
|
+
else
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
module DSL
|
|
5
|
+
module CommandDSL
|
|
6
|
+
extend Macros
|
|
7
|
+
|
|
8
|
+
dsl_value :description, default: ""
|
|
9
|
+
|
|
10
|
+
def aliases(*names)
|
|
11
|
+
names.any? ? (@aliases = names.flatten.map(&:to_sym)) : (@aliases || [])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def option(name, type = nil, short: nil, description: nil, default: nil, required: false, optional: false)
|
|
15
|
+
option_definitions[name.to_sym] = OptionDefinition.new(
|
|
16
|
+
name, type, short: short, description: description,
|
|
17
|
+
default: default, required: required, optional: optional
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def flag(name, short: nil, description: nil)
|
|
22
|
+
option(name, nil, short: short, description: description, default: false)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def argument(name, type = String, description: nil, required: nil, default: nil)
|
|
26
|
+
argument_definitions << ArgumentDefinition.new(
|
|
27
|
+
name, type, description: description, required: required, default: default
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def command(name, aliases: [], &block)
|
|
32
|
+
klass = Class.new(command_base_for(name))
|
|
33
|
+
klass.command_name = name.to_sym
|
|
34
|
+
klass.aliases(*aliases) if aliases.any?
|
|
35
|
+
|
|
36
|
+
const_name = name.to_s.split("_").map(&:capitalize).join
|
|
37
|
+
const_set(const_name, klass) if const_name.match?(/\A[A-Z]/)
|
|
38
|
+
|
|
39
|
+
BlockDSL.new(klass).instance_eval(&block) if block
|
|
40
|
+
klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def command_base_for(_name)
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class CommandNotFound < Error
|
|
7
|
+
attr_reader :token, :available
|
|
8
|
+
|
|
9
|
+
def initialize(token, available: [])
|
|
10
|
+
@token = token
|
|
11
|
+
@available = available
|
|
12
|
+
suggestion = find_suggestion
|
|
13
|
+
msg = "Unknown command: '#{token}'"
|
|
14
|
+
msg += ". Did you mean '#{suggestion}'?" if suggestion
|
|
15
|
+
msg += "\nAvailable commands: #{available.join(', ')}" if available.any?
|
|
16
|
+
super(msg)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def find_suggestion
|
|
22
|
+
return nil if available.empty?
|
|
23
|
+
best = available.min_by { |cmd| levenshtein(token.to_s, cmd.to_s) }
|
|
24
|
+
dist = levenshtein(token.to_s, best.to_s)
|
|
25
|
+
dist <= [token.to_s.length / 2, 3].max ? best : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def levenshtein(a, b)
|
|
29
|
+
m, n = a.length, b.length
|
|
30
|
+
return n if m.zero?
|
|
31
|
+
return m if n.zero?
|
|
32
|
+
|
|
33
|
+
d = Array.new(m + 1) { |i| i }
|
|
34
|
+
(1..n).each do |j|
|
|
35
|
+
prev = d[0]
|
|
36
|
+
d[0] = j
|
|
37
|
+
(1..m).each do |i|
|
|
38
|
+
cost = a[i - 1] == b[j - 1] ? 0 : 1
|
|
39
|
+
temp = d[i]
|
|
40
|
+
d[i] = [d[i] + 1, d[i - 1] + 1, prev + cost].min
|
|
41
|
+
prev = temp
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
d[m]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class MissingArgument < Error; end
|
|
49
|
+
class InvalidOption < Error; end
|
|
50
|
+
class AbstractCommand < Error; end
|
|
51
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
# Small, generic output helpers for interactive CLIs.
|
|
5
|
+
module Formatter
|
|
6
|
+
class << self
|
|
7
|
+
# Prompt on stderr and return true only for an affirmative answer.
|
|
8
|
+
# Returns false when stdin isn't a TTY (non-interactive / piped input).
|
|
9
|
+
def confirm?(prompt)
|
|
10
|
+
$stderr.print "#{prompt} [y/N] "
|
|
11
|
+
return false unless $stdin.tty?
|
|
12
|
+
|
|
13
|
+
answer = $stdin.gets&.strip
|
|
14
|
+
answer&.match?(/\Ay(es)?\z/i)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Humanize the distance from +time+ to now: "just now", "5m ago",
|
|
18
|
+
# "3h ago", "2d ago".
|
|
19
|
+
def time_ago(time)
|
|
20
|
+
seconds = (Time.now - time).to_i
|
|
21
|
+
return 'just now' if seconds < 60
|
|
22
|
+
return "#{seconds / 60}m ago" if seconds < 3600
|
|
23
|
+
return "#{seconds / 3600}h ago" if seconds < 86400
|
|
24
|
+
|
|
25
|
+
"#{seconds / 86400}d ago"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class HelpFormatter
|
|
5
|
+
attr_reader :command_class, :command_path
|
|
6
|
+
|
|
7
|
+
def initialize(command_class, command_path: [])
|
|
8
|
+
@command_class = command_class
|
|
9
|
+
@command_path = command_path
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def format
|
|
13
|
+
[
|
|
14
|
+
description_section,
|
|
15
|
+
version_section,
|
|
16
|
+
usage_section,
|
|
17
|
+
subcommands_section,
|
|
18
|
+
options_section,
|
|
19
|
+
arguments_section
|
|
20
|
+
].compact.join("\n\n") + "\n"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def description_section
|
|
26
|
+
desc = command_class.description
|
|
27
|
+
desc if desc.present?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def version_section
|
|
31
|
+
ver = command_class.respond_to?(:version) && command_class.version
|
|
32
|
+
"Version: #{ver.to_s.light_blue}" if ver
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def usage_section
|
|
36
|
+
path = command_path.any? ? command_path.join(" ") : command_class.command_name.to_s
|
|
37
|
+
usage = path.light_red
|
|
38
|
+
usage += " [options]".light_cyan if command_class.option_definitions.any?
|
|
39
|
+
usage += " [subcommand]".light_black.underline if command_class.subcommands.any?
|
|
40
|
+
command_class.argument_definitions.each_with_index do |arg, i|
|
|
41
|
+
label = command_class.argument_required?(i) ? "<#{arg.name}>" : "[#{arg.name}]"
|
|
42
|
+
usage += " #{label}".light_yellow
|
|
43
|
+
end
|
|
44
|
+
"Usage:".light_cyan + " " + usage
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def subcommands_section
|
|
48
|
+
subs = command_class.subcommands
|
|
49
|
+
return if subs.empty?
|
|
50
|
+
|
|
51
|
+
max_width = subs.keys.map { |k| k.to_s.length }.max
|
|
52
|
+
colors = Util::Formatting::COLORS.cycle
|
|
53
|
+
title = "Subcommands"
|
|
54
|
+
|
|
55
|
+
lines = [(" \u250C" + ("\u2500" * (title.length - 1)) + "\u2518").light_black]
|
|
56
|
+
subs.each do |name, sub_class|
|
|
57
|
+
label = name.to_s.ljust(max_width + 2)
|
|
58
|
+
desc = sub_class.description.present? ? sub_class.description.light_black : ""
|
|
59
|
+
lines << " \u251C\u2500\u2510".light_black + " #{label.send(colors.next)} #{desc}"
|
|
60
|
+
end
|
|
61
|
+
lines << (" \u2514" + "\u2500" * 40).light_black
|
|
62
|
+
|
|
63
|
+
section(title, lines)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def options_section
|
|
67
|
+
opts = command_class.option_definitions
|
|
68
|
+
return if opts.empty?
|
|
69
|
+
|
|
70
|
+
max_width = opts.values.map { |o| o.signature.length }.max
|
|
71
|
+
|
|
72
|
+
lines = opts.each_value.map do |opt|
|
|
73
|
+
sig = opt.signature.ljust(max_width + 2)
|
|
74
|
+
desc = opt.description || ""
|
|
75
|
+
default_note = opt.default_value ? " (default: #{opt.default_value})".light_black : ""
|
|
76
|
+
" #{sig.light_green} #{desc}#{default_note}"
|
|
77
|
+
end
|
|
78
|
+
section("Options", lines)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def arguments_section
|
|
82
|
+
args = command_class.argument_definitions
|
|
83
|
+
return if args.empty?
|
|
84
|
+
|
|
85
|
+
max_width = args.map { |a| a.name.to_s.length }.max
|
|
86
|
+
|
|
87
|
+
lines = args.each_with_index.map do |arg, i|
|
|
88
|
+
label = arg.name.to_s.ljust(max_width + 2)
|
|
89
|
+
desc = arg.description || ""
|
|
90
|
+
req = command_class.argument_required?(i) ? " (required)".light_red : " (optional)".light_black
|
|
91
|
+
" #{label.light_yellow} #{desc}#{req}"
|
|
92
|
+
end
|
|
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")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class OptionDefinition
|
|
5
|
+
attr_reader :name, :type, :short, :description, :default, :required, :optional
|
|
6
|
+
|
|
7
|
+
def initialize(name, type = nil, short: nil, description: nil, default: nil, required: false, optional: false)
|
|
8
|
+
@name = name.to_sym
|
|
9
|
+
@type = type
|
|
10
|
+
@short = short&.to_s
|
|
11
|
+
@description = description
|
|
12
|
+
@default = default
|
|
13
|
+
@required = required
|
|
14
|
+
@optional = optional
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def boolean?
|
|
18
|
+
type.nil? || type == TrueClass || type == FalseClass
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def default_value
|
|
22
|
+
boolean? ? (default.nil? ? false : default) : default
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def long_flag
|
|
26
|
+
flag = "--#{name.to_s.tr('_', '-')}"
|
|
27
|
+
unless boolean?
|
|
28
|
+
flag += optional ? "=[VALUE]" : "=VALUE"
|
|
29
|
+
end
|
|
30
|
+
flag
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def short_flag
|
|
34
|
+
"-#{short}" if short
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def signature
|
|
38
|
+
[("#{short_flag}," if short), long_flag].compact.join(" ")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def attach(parser, store)
|
|
42
|
+
args = [short_flag, long_flag].compact
|
|
43
|
+
args << type if type && !boolean?
|
|
44
|
+
args << description if description
|
|
45
|
+
|
|
46
|
+
parser.on(*args) { |value| store[name] = value }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
# An ordered registry of path-prefix → label substitutions, used by
|
|
5
|
+
# Command#abbreviate_path. Ships with $HOME → "~"; consumers register
|
|
6
|
+
# their own, e.g.:
|
|
7
|
+
#
|
|
8
|
+
# Ergane.paths.register("~/Workspace", "@ws")
|
|
9
|
+
#
|
|
10
|
+
# When abbreviating, the longest matching prefix wins, and a prefix only
|
|
11
|
+
# matches at a path boundary so "/home/user" never clips "/home/username".
|
|
12
|
+
class PathRegistry
|
|
13
|
+
Substitution = Struct.new(:prefix, :label)
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@substitutions = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Register a +prefix+ to collapse to +label+. The prefix is expanded
|
|
20
|
+
# (so "~" and relative paths resolve), and re-registering a prefix
|
|
21
|
+
# replaces its previous label. Returns self for chaining.
|
|
22
|
+
def register(prefix, label)
|
|
23
|
+
expanded = File.expand_path(prefix.to_s)
|
|
24
|
+
@substitutions.reject! { |sub| sub.prefix == expanded }
|
|
25
|
+
@substitutions << Substitution.new(expanded, label.to_s)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Remove every registered substitution. Returns self for chaining.
|
|
30
|
+
def clear
|
|
31
|
+
@substitutions.clear
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
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.
|
|
39
|
+
def abbreviate(path)
|
|
40
|
+
original = path.to_s
|
|
41
|
+
expanded = File.expand_path(original)
|
|
42
|
+
best = @substitutions
|
|
43
|
+
.select { |sub| expanded == sub.prefix || expanded.start_with?("#{sub.prefix}/") }
|
|
44
|
+
.max_by { |sub| sub.prefix.length }
|
|
45
|
+
return original unless best
|
|
46
|
+
|
|
47
|
+
"#{best.label}#{expanded[best.prefix.length..]}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ergane
|
|
4
|
+
class Runner
|
|
5
|
+
attr_reader :root, :argv
|
|
6
|
+
|
|
7
|
+
def initialize(root, argv)
|
|
8
|
+
@root = root
|
|
9
|
+
@argv = argv.dup
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def execute
|
|
13
|
+
command_class, remaining, path = resolve(root, argv)
|
|
14
|
+
|
|
15
|
+
if help_requested?(remaining)
|
|
16
|
+
$stdout.puts HelpFormatter.new(command_class, command_path: path).format
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if version_requested?(remaining) && command_class.respond_to?(:version) && command_class.version
|
|
21
|
+
$stdout.puts "#{command_class.command_name} #{command_class.version}"
|
|
22
|
+
return
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
instance = command_class.new(remaining)
|
|
26
|
+
instance.run(*instance.args)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def help_requested?(args)
|
|
32
|
+
args.include?("--help") || args.include?("-h")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def version_requested?(args)
|
|
36
|
+
args.include?("--version") || args.include?("-V")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def resolve(command_class, args, path = [])
|
|
40
|
+
path << (command_class.command_name || command_class.name || "command").to_s
|
|
41
|
+
return [command_class, args, path] if args.empty?
|
|
42
|
+
|
|
43
|
+
token = args.first
|
|
44
|
+
return [command_class, args, path] if token.start_with?("-")
|
|
45
|
+
|
|
46
|
+
sub = find_subcommand(command_class, token.to_sym)
|
|
47
|
+
if sub
|
|
48
|
+
args.shift
|
|
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)
|
|
54
|
+
else
|
|
55
|
+
[command_class, args, path]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def find_subcommand(command_class, token)
|
|
60
|
+
command_class.subcommands[token] ||
|
|
61
|
+
command_class.subcommands.each_value.find { |cmd| cmd.terms.include?(token) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|