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.
@@ -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