claide 0.5.0 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|