ergane 0.0.1 → 0.1.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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ module DSL
5
+ module CommandDSL
6
+ def description(text = nil)
7
+ text ? (@description = text) : (@description || "")
8
+ end
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: true, 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,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,101 @@
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 do |arg|
41
+ label = arg.required ? "<#{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
+ 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
58
+
59
+ subs.each do |name, sub_class|
60
+ label = name.to_s.ljust(max_width + 2)
61
+ desc = sub_class.description.present? ? sub_class.description.light_black : ""
62
+ lines << " \u251C\u2500\u2510".light_black + " #{label.send(Util::Formatting.next_color)} #{desc}"
63
+ end
64
+
65
+ lines << (" \u2514" + "\u2500" * 40).light_black
66
+ lines.join("\n")
67
+ end
68
+
69
+ def options_section
70
+ opts = command_class.option_definitions
71
+ return if opts.empty?
72
+
73
+ max_width = opts.values.map { |o| o.signature.length }.max
74
+
75
+ lines = ["Options:".light_cyan]
76
+ opts.each_value do |opt|
77
+ sig = opt.signature.ljust(max_width + 2)
78
+ desc = opt.description || ""
79
+ default_note = opt.default_value ? " (default: #{opt.default_value})".light_black : ""
80
+ lines << " #{sig.light_green} #{desc}#{default_note}"
81
+ end
82
+ lines.join("\n")
83
+ end
84
+
85
+ def arguments_section
86
+ args = command_class.argument_definitions
87
+ return if args.empty?
88
+
89
+ max_width = args.map { |a| a.name.to_s.length }.max
90
+
91
+ lines = ["Arguments:".light_cyan]
92
+ args.each do |arg|
93
+ label = arg.name.to_s.ljust(max_width + 2)
94
+ desc = arg.description || ""
95
+ req = arg.required ? " (required)".light_red : " (optional)".light_black
96
+ lines << " #{label.light_yellow} #{desc}#{req}"
97
+ end
98
+ lines.join("\n")
99
+ end
100
+ end
101
+ 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,47 @@
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,
36
+ # returning the path unchanged when nothing matches.
37
+ def abbreviate(path)
38
+ str = path.to_s
39
+ best = @substitutions
40
+ .select { |sub| str == sub.prefix || str.start_with?("#{sub.prefix}/") }
41
+ .max_by { |sub| sub.prefix.length }
42
+ return str unless best
43
+
44
+ "#{best.label}#{str[best.prefix.length..]}"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,60 @@
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
+ else
51
+ [command_class, args, path]
52
+ end
53
+ end
54
+
55
+ def find_subcommand(command_class, token)
56
+ command_class.subcommands[token] ||
57
+ command_class.subcommands.each_value.find { |cmd| cmd.terms.include?(token) }
58
+ end
59
+ end
60
+ end
data/lib/ergane/tool.rb CHANGED
@@ -1,116 +1,85 @@
1
- module Ergane
2
- class Tool < CommandDefinition
3
- attr_reader :title #capitalized :label
4
- attr_reader :version
1
+ # frozen_string_literal: true
5
2
 
6
- def initialize(label, *paths)
7
- super(label)
3
+ module Ergane
4
+ class Tool < Command
5
+ self.abstract_class = true
6
+
7
+ class << self
8
+ def command_class(klass = nil)
9
+ if klass
10
+ @command_class = klass
11
+ wire_command_class(klass)
12
+ else
13
+ @command_class
14
+ end
15
+ end
8
16
 
9
- @title = label.capitalize
10
- @version = VERSION
17
+ def tool_name(name = nil)
18
+ name ? (self.command_name = name) : command_name
19
+ end
11
20
 
12
- # Dogfood-ing
13
- define do
14
- description "Basic CLI Tool"
15
- switches do
16
- switch :help, default: false, kind: TrueClass, description: "Display this help block" do
17
- raise Help
18
- end
19
- switch :version, default: false, kind: TrueClass, description: "Display the version" do
20
- # TODO: Push ARGV into a variable at the command level that can be manipulated by flags/switches
21
- # NOTE: This would allow a --version to morph into a version command and we could push all this logic to that.
22
- # Additional logic for --version would be -1 or --short etc.
23
- puts "#{@title} Version: #{Ergane::VERSION}"
24
- exit
25
- end
26
- # switch verbose: FalseClass, short: :v, "Turn on verbose logging"
27
- end
21
+ def version(ver = nil)
22
+ ver ? (@version = ver) : @version
23
+ end
28
24
 
29
- run do
30
- begin
31
- command, args = self, []
32
- Process.setproctitle(label.to_s)
33
- command, args = self.parse_args(ARGV.dup)
25
+ def start(argv = ARGV)
26
+ Runner.new(self, argv.dup).execute
27
+ rescue Interrupt
28
+ $stderr.puts "\nAborted."
29
+ exit 130
30
+ rescue Ergane::Error => e
31
+ $stderr.puts e.message
32
+ exit 1
33
+ end
34
34
 
35
- command.run(*args)
36
- puts "Finished running #{label}"
37
- rescue Interrupt
38
- puts "\nOkay. Aborting."
39
- rescue RuntimeError
40
- puts "RuntimeError"
41
- binding.pry
42
- rescue Help
43
- puts help(command, args)
44
- ensure
45
- system "printf '\033]0;\007'"
46
- end
35
+ def load_commands(*patterns)
36
+ patterns.flatten.each do |pattern|
37
+ Dir[pattern].sort.each { |file| require file }
47
38
  end
48
39
  end
49
40
 
50
- Pry.config.prompt_name = "#{title} ".light_blue
41
+ def inherited(subclass)
42
+ super
43
+ create_command_base(subclass) if self == Tool
44
+ end
51
45
 
52
- load_commands(paths)
53
- end
46
+ private
54
47
 
55
- def help(command, args=[])
56
- missing_args = command.arguments.product([false]).to_h.merge(args.product([true]).to_h).map do |arg, is_missing|
57
- a = arg.to_s.light_black.underline.tap do |b|
58
- b.blink if is_missing
59
- end
60
- end.join(' ')
61
-
62
- command.switch_parser.banner = [].tap do |text|
63
- text << "About: #{description}"
64
- text << "Version: #{version.to_s.light_blue}"
65
- text << "Usage: #{([label] + chain).join(' ').light_red} #{'[options]'.light_cyan} "
66
- if commands.any?
67
- text.last << "[subcommand]".light_black.underline
68
- text << (" ┌" + ("─" * (text.last.uncolorize.length - 12)) + "┘").light_black
69
- commands.each do |key, command|
70
- # text << " ├─┐".light_black + " #{(klass.terms.join(', ')).ljust(24, ' ')} ".send(Athena::Util.next_color) + klass.description.light_black
71
- text << " ├─┐".light_black + " #{key.to_s.ljust(24, ' ')} " + command.description.light_black
72
- end
73
- text << (" └" + "─" * 64).light_black
74
- else
75
- # text.last << command.arguments(missing_args.keys)
76
- end
77
- # text << list_examples if examples.any?
78
- text << "Options:".light_cyan
79
- end.join("\n")
80
- switch_parser
81
- end
48
+ def command_base_for(_name)
49
+ command_class || self
50
+ end
82
51
 
83
- def self.define(label, chain: [], &block)
84
- c = CommandDefinition.define(label, chain: chain, &block)
52
+ def create_command_base(tool_subclass)
53
+ return if tool_subclass.command_class
85
54
 
86
- parent_command = if chain.any?
87
- Ergane.active_tool.dig(*chain)
88
- else
89
- Ergane.active_tool
55
+ base = Class.new(Ergane::Command)
56
+ base.abstract_class = true
57
+ tool_subclass.const_set(:Command, base)
58
+ tool_subclass.command_class(base)
90
59
  end
91
60
 
92
- parent_command[label] = c
93
- end
61
+ def wire_command_class(klass)
62
+ tool = self
63
+ klass.define_singleton_method(:tool) { tool }
94
64
 
95
- def load_commands(paths)
96
- activate_tool do
97
- Ergane.logger.debug "Loading paths:"
98
- Array.wrap(paths).each do |path|
99
- Ergane.logger.debug " - #{path}"
100
- Dir[path].each do |path|
101
- file = path.split('/').last
102
- Ergane.logger.debug " - loading #{path.split('/').last(4).join('/')}"
103
- instance_eval(File.read(path), file)
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
104
71
  end
105
72
  end
106
- end
107
- end
108
73
 
109
- private
110
-
111
- def activate_tool(&block)
112
- Ergane.activate_tool(self, &block)
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
+ end
113
83
  end
114
-
115
84
  end
116
85
  end
@@ -0,0 +1,24 @@
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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ergane
4
+ module Util
5
+ module Formatting
6
+ extend self
7
+
8
+ COLORS = %i[light_red light_yellow light_green light_blue light_cyan light_magenta].freeze
9
+
10
+ def color_cycle
11
+ @color_cycle ||= COLORS.cycle
12
+ end
13
+
14
+ def next_color
15
+ color_cycle.next
16
+ end
17
+
18
+ def reset_colors!
19
+ @color_cycle = COLORS.cycle
20
+ end
21
+
22
+ def rainbow(string, delimiter = " ")
23
+ string.split(delimiter).map { |word| word.to_s.send(next_color) }.join(delimiter)
24
+ end
25
+
26
+ def colorize_list(items)
27
+ items.map { |item| item.to_s.send(next_color) }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Ergane
2
- VERSION = '0.0.1'
4
+ VERSION = "0.1.0"
3
5
  end