claide 0.5.0 → 0.6.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/README.markdown +4 -3
- data/lib/claide.rb +4 -3
- data/lib/claide/ansi.rb +126 -0
- data/lib/claide/ansi/cursor.rb +69 -0
- data/lib/claide/ansi/graphics.rb +72 -0
- data/lib/claide/ansi/string_escaper.rb +81 -0
- data/lib/claide/argv.rb +63 -108
- data/lib/claide/argv/parser.rb +83 -0
- data/lib/claide/command.rb +245 -300
- data/lib/claide/command/banner.rb +133 -117
- data/lib/claide/command/banner/prettifier.rb +59 -0
- data/lib/claide/command/options.rb +87 -0
- data/lib/claide/command/parser.rb +47 -0
- data/lib/claide/command/plugins_helper.rb +112 -0
- data/lib/claide/command/shell_completion_helper.rb +39 -0
- data/lib/claide/command/shell_completion_helper/zsh_completion_generator.rb +191 -0
- data/lib/claide/command/validation_helper.rb +82 -0
- data/lib/claide/help.rb +2 -18
- data/lib/claide/helper.rb +113 -0
- data/lib/claide/informative_error.rb +0 -2
- data/lib/claide/mixins.rb +25 -0
- metadata +16 -2
@@ -0,0 +1,47 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module CLAide
|
4
|
+
class Command
|
5
|
+
# Loads a command instances from arguments.
|
6
|
+
#
|
7
|
+
module Parser
|
8
|
+
# @param [Array, ARGV] argv
|
9
|
+
# A list of (remaining) parameters.
|
10
|
+
#
|
11
|
+
# @return [Command] An instance of the command class that was matched by
|
12
|
+
# going through the arguments in the parameters and drilling down
|
13
|
+
# command classes.
|
14
|
+
#
|
15
|
+
def self.parse(command, argv)
|
16
|
+
argv = ARGV.coherce(argv)
|
17
|
+
cmd = argv.arguments.first
|
18
|
+
if cmd && subcommand = command.find_subcommand(cmd)
|
19
|
+
argv.shift_argument
|
20
|
+
parse(subcommand, argv)
|
21
|
+
elsif command.abstract_command? && command.default_subcommand
|
22
|
+
load_default_subcommand(command, argv)
|
23
|
+
else
|
24
|
+
command.new(argv)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# @param [Array, ARGV] argv
|
29
|
+
# A list of (remaining) parameters.#
|
30
|
+
#
|
31
|
+
# @return [Command] Returns the default subcommand initialized with the
|
32
|
+
# given arguments.
|
33
|
+
#
|
34
|
+
def self.load_default_subcommand(command, argv)
|
35
|
+
default_subcommand = command.default_subcommand
|
36
|
+
subcommand = command.find_subcommand(default_subcommand)
|
37
|
+
unless subcommand
|
38
|
+
raise 'Unable to find the default subcommand ' \
|
39
|
+
"`#{default_subcommand}` for command `#{self}`."
|
40
|
+
end
|
41
|
+
result = parse(subcommand, argv)
|
42
|
+
result.invoked_as_default = true
|
43
|
+
result
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module CLAide
|
4
|
+
class Command
|
5
|
+
module PluginsHelper
|
6
|
+
# Loads additional plugins via rubygems looking for files named after the
|
7
|
+
# `PLUGIN_PREFIX_plugin`.
|
8
|
+
#
|
9
|
+
def self.load_plugins(plugin_prefix)
|
10
|
+
paths = PluginsHelper.plugin_load_paths(plugin_prefix)
|
11
|
+
loaded_paths = []
|
12
|
+
paths.each do |path|
|
13
|
+
if PluginsHelper.safe_require(path)
|
14
|
+
loaded_paths << path
|
15
|
+
end
|
16
|
+
end
|
17
|
+
loaded_paths
|
18
|
+
end
|
19
|
+
|
20
|
+
# Returns the name and the version of the plugin with the given path.
|
21
|
+
#
|
22
|
+
# @param [String] path
|
23
|
+
# The load path of the plugin.
|
24
|
+
#
|
25
|
+
# @return [String] A string including the name and the version or a
|
26
|
+
# failure message.
|
27
|
+
#
|
28
|
+
def self.plugin_info(path)
|
29
|
+
if gemspec = find_gemspec(path)
|
30
|
+
spec = Gem::Specification.load(gemspec)
|
31
|
+
end
|
32
|
+
|
33
|
+
if spec
|
34
|
+
"#{spec.name}: #{spec.version}"
|
35
|
+
else
|
36
|
+
"[!] Unable to load a specification for `#{path}`"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [String] Finds the path of the gemspec of a path. The path is
|
41
|
+
# iterated upwards until a dir with a single gemspec is found.
|
42
|
+
#
|
43
|
+
# @param [String] path
|
44
|
+
# The load path of a plugin.
|
45
|
+
#
|
46
|
+
def self.find_gemspec(path)
|
47
|
+
reverse_ascending_paths(path).find do |candidate_path|
|
48
|
+
glob = Dir.glob("#{candidate_path}/*.gemspec")
|
49
|
+
if glob.count == 1
|
50
|
+
return glob.first
|
51
|
+
end
|
52
|
+
end
|
53
|
+
nil
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [String] Returns the list of the parents paths of a path.
|
57
|
+
#
|
58
|
+
# @param [String] path
|
59
|
+
# The path for which the list is needed.
|
60
|
+
#
|
61
|
+
def self.reverse_ascending_paths(path)
|
62
|
+
components = path.split('/')[0...-1]
|
63
|
+
progress = nil
|
64
|
+
components.map do |component|
|
65
|
+
if progress
|
66
|
+
progress = progress + '/' + component
|
67
|
+
else
|
68
|
+
progress = component
|
69
|
+
end
|
70
|
+
end.reverse
|
71
|
+
end
|
72
|
+
|
73
|
+
# Returns the paths of the files to require to load the available
|
74
|
+
# plugins.
|
75
|
+
#
|
76
|
+
# @return [Array] The found plugins load paths.
|
77
|
+
#
|
78
|
+
def self.plugin_load_paths(plugin_prefix)
|
79
|
+
if plugin_prefix && !plugin_prefix.empty?
|
80
|
+
if Gem.respond_to? :find_latest_files
|
81
|
+
Gem.find_latest_files("#{plugin_prefix}_plugin")
|
82
|
+
else
|
83
|
+
Gem.find_files("#{plugin_prefix}_plugin")
|
84
|
+
end
|
85
|
+
else
|
86
|
+
[]
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Loads the given path. If any exception occurs it is catched and an
|
91
|
+
# informative message is printed.
|
92
|
+
#
|
93
|
+
# @param [String] path
|
94
|
+
# The path to load
|
95
|
+
#
|
96
|
+
# rubocop:disable RescueException
|
97
|
+
def self.safe_require(path)
|
98
|
+
require path
|
99
|
+
true
|
100
|
+
rescue Exception => exception
|
101
|
+
message = "\n---------------------------------------------"
|
102
|
+
message << "\nError loading the plugin with path `#{path}`.\n"
|
103
|
+
message << "\n#{exception.class} - #{exception.message}"
|
104
|
+
message << "\n#{exception.backtrace.join("\n")}"
|
105
|
+
message << "\n---------------------------------------------\n"
|
106
|
+
puts message.ansi.yellow
|
107
|
+
false
|
108
|
+
end
|
109
|
+
# rubocop:enable RescueException
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'claide/command/shell_completion_helper/zsh_completion_generator'
|
4
|
+
|
5
|
+
module CLAide
|
6
|
+
class Command
|
7
|
+
module ShellCompletionHelper
|
8
|
+
# Returns the completion template generated for the given command for the
|
9
|
+
# given shell. If the shell is not provided it will be inferred by the
|
10
|
+
# environment.
|
11
|
+
#
|
12
|
+
def self.completion_template(command, shell = nil)
|
13
|
+
shell ||= ENV['SHELL'].split('/').last
|
14
|
+
case shell
|
15
|
+
when 'zsh'
|
16
|
+
ZSHCompletionGenerator.generate(command)
|
17
|
+
else
|
18
|
+
raise Help, "Auto-completion generator for `#{shell}` shell not" \
|
19
|
+
' implemented.'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Indents the lines of the given string except the first one to the given
|
24
|
+
# level. Uses two spaces per each level.
|
25
|
+
#
|
26
|
+
# @param [String] string
|
27
|
+
# The string to indent.
|
28
|
+
#
|
29
|
+
# @param [Fixnum] indentation
|
30
|
+
# The indentation amount.
|
31
|
+
#
|
32
|
+
# @return [String] An indented string.
|
33
|
+
#
|
34
|
+
def self.indent(string, indentation)
|
35
|
+
string.gsub("\n", "\n#{' ' * indentation * 2}")
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
module CLAide
|
2
|
+
class Command
|
3
|
+
module ShellCompletionHelper
|
4
|
+
# Generates a completion script for the Z shell.
|
5
|
+
#
|
6
|
+
module ZSHCompletionGenerator
|
7
|
+
# @return [String] The completion script.
|
8
|
+
#
|
9
|
+
# @param [Class] command
|
10
|
+
# The command to generate the script for.
|
11
|
+
#
|
12
|
+
# rubocop:disable MethodLength
|
13
|
+
def self.generate(command)
|
14
|
+
result = <<-DOC.strip_margin('|')
|
15
|
+
|#compdef #{command.command}
|
16
|
+
|# setopt XTRACE VERBOSE
|
17
|
+
|# vim: ft=zsh sw=2 ts=2 et
|
18
|
+
|
|
19
|
+
|local -a _subcommands
|
20
|
+
|local -a _options
|
21
|
+
|
|
22
|
+
|#{case_statement_fragment(command)}
|
23
|
+
DOC
|
24
|
+
|
25
|
+
post_process(result)
|
26
|
+
end
|
27
|
+
# rubocop:enable MethodLength
|
28
|
+
|
29
|
+
# Returns a case statement for a given command with the given nesting
|
30
|
+
# level.
|
31
|
+
#
|
32
|
+
# @param [Class] command
|
33
|
+
# The command to generate the fragment for.
|
34
|
+
#
|
35
|
+
# @param [Fixnum] nesting_level
|
36
|
+
# The nesting level to detect the index of the words array.
|
37
|
+
#
|
38
|
+
# @return [String] the case statement fragment.
|
39
|
+
#
|
40
|
+
# @example
|
41
|
+
# case "$words[2]" in
|
42
|
+
# spec-file)
|
43
|
+
# [..snip..]
|
44
|
+
# ;;
|
45
|
+
# *) # bin
|
46
|
+
# _subcommands=(
|
47
|
+
# "spec-file:"
|
48
|
+
# )
|
49
|
+
# _describe -t commands "bin subcommands" _subcommands
|
50
|
+
# _options=(
|
51
|
+
# "--completion-script:Print the auto-completion script"
|
52
|
+
# "--help:Show help banner of specified command"
|
53
|
+
# "--verbose:Show more debugging information"
|
54
|
+
# "--version:Show the version of the tool"
|
55
|
+
# )
|
56
|
+
# _describe -t options "bin options" _options
|
57
|
+
# ;;
|
58
|
+
# esac
|
59
|
+
#
|
60
|
+
# rubocop:disable MethodLength
|
61
|
+
def self.case_statement_fragment(command, nest_level = 0)
|
62
|
+
entries = case_statement_entries_fragment(command, nest_level + 1)
|
63
|
+
subcommands = subcommands_fragment(command)
|
64
|
+
options = options_fragment(command)
|
65
|
+
|
66
|
+
result = <<-DOC.strip_margin('|')
|
67
|
+
|case "$words[#{nest_level + 2}]" in
|
68
|
+
| #{ShellCompletionHelper.indent(entries, 1)}
|
69
|
+
| *) # #{command.full_command}
|
70
|
+
| #{ShellCompletionHelper.indent(subcommands, 2)}
|
71
|
+
| #{ShellCompletionHelper.indent(options, 2)}
|
72
|
+
| ;;
|
73
|
+
|esac
|
74
|
+
DOC
|
75
|
+
result.gsub(/\n *\n/, "\n").chomp
|
76
|
+
end
|
77
|
+
# rubocop:enable MethodLength
|
78
|
+
|
79
|
+
# Returns a case statement for a given command with the given nesting
|
80
|
+
# level.
|
81
|
+
#
|
82
|
+
# @param [Class] command
|
83
|
+
# The command to generate the fragment for.
|
84
|
+
#
|
85
|
+
# @param [Fixnum] nesting_level
|
86
|
+
# The nesting level to detect the index of the words array.
|
87
|
+
#
|
88
|
+
# @return [String] the case statement fragment.
|
89
|
+
#
|
90
|
+
# @example
|
91
|
+
# repo)
|
92
|
+
# case "$words[5]" in
|
93
|
+
# *) # bin spec-file lint
|
94
|
+
# _options=(
|
95
|
+
# "--help:Show help banner of specified command"
|
96
|
+
# "--only-errors:Skip warnings"
|
97
|
+
# "--verbose:Show more debugging information"
|
98
|
+
# )
|
99
|
+
# _describe -t options "bin spec-file lint options" _options
|
100
|
+
# ;;
|
101
|
+
# esac
|
102
|
+
# ;;
|
103
|
+
#
|
104
|
+
def self.case_statement_entries_fragment(command, nest_level)
|
105
|
+
subcommands = command.subcommands_for_command_lookup
|
106
|
+
subcommands.sort_by(&:name).map do |subcommand|
|
107
|
+
subcase = case_statement_fragment(subcommand, nest_level)
|
108
|
+
<<-DOC.strip_margin('|')
|
109
|
+
|#{subcommand.command})
|
110
|
+
| #{ShellCompletionHelper.indent(subcase, 1)}
|
111
|
+
|;;
|
112
|
+
DOC
|
113
|
+
end.join("\n")
|
114
|
+
end
|
115
|
+
|
116
|
+
# Returns the fragment of the subcommands array.
|
117
|
+
#
|
118
|
+
# @param [Class] command
|
119
|
+
# The command to generate the fragment for.
|
120
|
+
#
|
121
|
+
# @return [String] The fragment.
|
122
|
+
#
|
123
|
+
def self.subcommands_fragment(command)
|
124
|
+
subcommands = command.subcommands_for_command_lookup
|
125
|
+
list = subcommands.sort_by(&:name).map do |subcommand|
|
126
|
+
"\"#{subcommand.command}:#{subcommand.summary}\""
|
127
|
+
end
|
128
|
+
describe_fragment(command, 'subcommands', 'commands', list)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Returns the fragment of the options array.
|
132
|
+
#
|
133
|
+
# @param [Class] command
|
134
|
+
# The command to generate the fragment for.
|
135
|
+
#
|
136
|
+
# @return [String] The fragment.
|
137
|
+
#
|
138
|
+
def self.options_fragment(command)
|
139
|
+
list = command.options.sort_by(&:first).map do |option|
|
140
|
+
"\"#{option[0]}:#{option[1]}\""
|
141
|
+
end
|
142
|
+
describe_fragment(command, 'options', 'options', list)
|
143
|
+
end
|
144
|
+
|
145
|
+
# Returns the fragment for a list of completions and the ZSH
|
146
|
+
# `_describe` function.
|
147
|
+
#
|
148
|
+
# @param [Class] command
|
149
|
+
# The command to generate the fragment for.
|
150
|
+
#
|
151
|
+
# @param [String] name
|
152
|
+
# The name of the list.
|
153
|
+
#
|
154
|
+
# @param [Class] tag
|
155
|
+
# The ZSH tag to use (e.g. command or option).
|
156
|
+
#
|
157
|
+
# @param [Array<String>] list
|
158
|
+
# The list of the entries.
|
159
|
+
#
|
160
|
+
# @return [String] The fragment.
|
161
|
+
#
|
162
|
+
def self.describe_fragment(command, name, tag, list)
|
163
|
+
if list && !list.empty?
|
164
|
+
<<-DOC.strip_margin('|')
|
165
|
+
|_#{name}=(
|
166
|
+
| #{ShellCompletionHelper.indent(list.join("\n"), 1)}
|
167
|
+
|)
|
168
|
+
|_describe -t #{tag} "#{command.full_command} #{name}" _#{name}
|
169
|
+
DOC
|
170
|
+
else
|
171
|
+
''
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Post processes a script to remove any artifact and escape any needed
|
176
|
+
# character.
|
177
|
+
#
|
178
|
+
# @param [String] string
|
179
|
+
# The string to post process.
|
180
|
+
#
|
181
|
+
# @return [String] The post processed script.
|
182
|
+
#
|
183
|
+
def self.post_process(string)
|
184
|
+
string.gsub!(/\n *\n/, "\n\n")
|
185
|
+
string.gsub!(/`/, '\\\`')
|
186
|
+
string
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
module CLAide
|
4
|
+
class Command
|
5
|
+
module ValidationHelper
|
6
|
+
# @return [String] Returns a message including a suggestion for the given
|
7
|
+
# unrecognized arguments.
|
8
|
+
#
|
9
|
+
# @param [Array<String>] arguments
|
10
|
+
# The unrecognized arguments.
|
11
|
+
#
|
12
|
+
# @param [Class] command_class
|
13
|
+
# The class of the command which encountered the unrecognized
|
14
|
+
# arguments.
|
15
|
+
#
|
16
|
+
def self.argument_suggestion(arguments, command_class)
|
17
|
+
string = arguments.first
|
18
|
+
type = ARGV::Parser.argument_type(string)
|
19
|
+
list = suggestion_list(command_class, type)
|
20
|
+
suggestion = ValidationHelper.suggestion(string, list)
|
21
|
+
pretty_suggestion = prettify_validation_suggestion(suggestion, type)
|
22
|
+
string_type = type == :arg ? 'command' : 'option'
|
23
|
+
"Unknown #{string_type}: `#{string}`\n" \
|
24
|
+
"Did you mean: #{pretty_suggestion}"
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Array<String>] The list of the valid arguments for a command
|
28
|
+
# according to the type of the argument.
|
29
|
+
#
|
30
|
+
# @param [Command] command_class
|
31
|
+
# The class of the command for which the list of arguments is
|
32
|
+
# needed.
|
33
|
+
#
|
34
|
+
# @param [Symbol] type
|
35
|
+
# The type of the argument.
|
36
|
+
#
|
37
|
+
def self.suggestion_list(command_class, type)
|
38
|
+
case type
|
39
|
+
when :option, :flag
|
40
|
+
command_class.options.map(&:first)
|
41
|
+
when :arg
|
42
|
+
command_class.subcommands_for_command_lookup.map(&:command)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Returns a suggestion for a string from a list of possible elements.
|
47
|
+
#
|
48
|
+
# @return [String] string
|
49
|
+
# The string for which the suggestion is needed.
|
50
|
+
#
|
51
|
+
# @param [Array<String>] list
|
52
|
+
# The list of the valid elements
|
53
|
+
#
|
54
|
+
def self.suggestion(string, list)
|
55
|
+
sorted = list.sort_by do |element|
|
56
|
+
Helper.levenshtein_distance(string, element)
|
57
|
+
end
|
58
|
+
sorted.first
|
59
|
+
end
|
60
|
+
|
61
|
+
# Prettifies the given validation suggestion according to the type.
|
62
|
+
#
|
63
|
+
# @param [String] suggestion
|
64
|
+
# The suggestion to prettify.
|
65
|
+
#
|
66
|
+
# @param [Type]
|
67
|
+
# The type of the suggestion: either `:command` or `:option`.
|
68
|
+
#
|
69
|
+
# @return [String] A handsome suggestion.
|
70
|
+
#
|
71
|
+
def self.prettify_validation_suggestion(suggestion, type)
|
72
|
+
case type
|
73
|
+
when :option, :flag
|
74
|
+
suggestion = "#{suggestion}"
|
75
|
+
suggestion.ansi.blue
|
76
|
+
when :arg
|
77
|
+
suggestion.ansi.green
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|