claide 0.3.0 → 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/claide.rb +1 -1
- data/lib/claide/argv.rb +250 -0
- data/lib/claide/command.rb +370 -0
- data/lib/claide/command/banner.rb +110 -0
- data/lib/claide/help.rb +57 -0
- data/lib/claide/informative_error.rb +21 -0
- metadata +6 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8b17373043336a80f9e26e9c516bae69912b455c
|
4
|
+
data.tar.gz: 5ebac7336f852fae13e18cd2401aed67032a68f7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 40bf038269203c041b11b0b64a830103fc194b051472aec39cef958ca1de08a4dadab8dae285650a117a02cd58b1e709eb3397b22dedb7bff37b95d02b96b6df
|
7
|
+
data.tar.gz: e137fa4d2b3d6aa178ba9feb6a2b1197364d1603e579746be60033266872af863b076a2c1c53cfbddffc6a206ad5106fb563e2dcc0a73b5c4cc23d8da7c5e339
|
data/lib/claide.rb
CHANGED
data/lib/claide/argv.rb
ADDED
@@ -0,0 +1,250 @@
|
|
1
|
+
module CLAide
|
2
|
+
|
3
|
+
# This class is responsible for parsing the parameters specified by the user,
|
4
|
+
# accessing individual parameters, and keep state by removing handled
|
5
|
+
# parameters.
|
6
|
+
#
|
7
|
+
class ARGV
|
8
|
+
|
9
|
+
# @param [Array<String>] argv
|
10
|
+
#
|
11
|
+
# A list of parameters. Each entry is ensured to be a string by calling
|
12
|
+
# `#to_s` on it.
|
13
|
+
#
|
14
|
+
def initialize(argv)
|
15
|
+
@entries = self.class.parse(argv)
|
16
|
+
end
|
17
|
+
|
18
|
+
# @return [Boolean]
|
19
|
+
#
|
20
|
+
# Returns whether or not there are any remaining unhandled parameters.
|
21
|
+
#
|
22
|
+
def empty?
|
23
|
+
@entries.empty?
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [Array<String>]
|
27
|
+
#
|
28
|
+
# A list of the remaining unhandled parameters, in the same format a user
|
29
|
+
# specifies it in.
|
30
|
+
#
|
31
|
+
# @example
|
32
|
+
#
|
33
|
+
# argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey'])
|
34
|
+
# argv.shift_argument # => 'tea'
|
35
|
+
# argv.remainder # => ['--no-milk', '--sweetner=honey']
|
36
|
+
#
|
37
|
+
def remainder
|
38
|
+
@entries.map do |type, (key, value)|
|
39
|
+
case type
|
40
|
+
when :arg
|
41
|
+
key
|
42
|
+
when :flag
|
43
|
+
"--#{'no-' if value == false}#{key}"
|
44
|
+
when :option
|
45
|
+
"--#{key}=#{value}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [Hash]
|
51
|
+
#
|
52
|
+
# A hash that consists of the remaining flags and options and their
|
53
|
+
# values.
|
54
|
+
#
|
55
|
+
# @example
|
56
|
+
#
|
57
|
+
# argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey'])
|
58
|
+
# argv.options # => { 'milk' => false, 'sweetner' => 'honey' }
|
59
|
+
#
|
60
|
+
def options
|
61
|
+
options = {}
|
62
|
+
@entries.each do |type, (key, value)|
|
63
|
+
options[key] = value unless type == :arg
|
64
|
+
end
|
65
|
+
options
|
66
|
+
end
|
67
|
+
|
68
|
+
# @return [Array<String>]
|
69
|
+
#
|
70
|
+
# A list of the remaining arguments.
|
71
|
+
#
|
72
|
+
# @example
|
73
|
+
#
|
74
|
+
# argv = CLAide::ARGV.new(['tea', 'white', '--no-milk', 'biscuit'])
|
75
|
+
# argv.shift_argument # => 'tea'
|
76
|
+
# argv.arguments # => ['white', 'biscuit']
|
77
|
+
#
|
78
|
+
def arguments
|
79
|
+
@entries.map { |type, value| value if type == :arg }.compact
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [Array<String>]
|
83
|
+
#
|
84
|
+
# A list of the remaining arguments.
|
85
|
+
#
|
86
|
+
# @note
|
87
|
+
#
|
88
|
+
# This version also removes the arguments from the remaining parameters.
|
89
|
+
#
|
90
|
+
# @example
|
91
|
+
#
|
92
|
+
# argv = CLAide::ARGV.new(['tea', 'white', '--no-milk', 'biscuit'])
|
93
|
+
# argv.arguments # => ['tea', 'white', 'biscuit']
|
94
|
+
# argv.arguments! # => ['tea', 'white', 'biscuit']
|
95
|
+
# argv.arguments # => []
|
96
|
+
#
|
97
|
+
def arguments!
|
98
|
+
arguments = []
|
99
|
+
while arg = shift_argument
|
100
|
+
arguments << arg
|
101
|
+
end
|
102
|
+
arguments
|
103
|
+
end
|
104
|
+
|
105
|
+
# @return [String]
|
106
|
+
#
|
107
|
+
# The first argument in the remaining parameters.
|
108
|
+
#
|
109
|
+
# @note
|
110
|
+
#
|
111
|
+
# This will remove the argument from the remaining parameters.
|
112
|
+
#
|
113
|
+
# @example
|
114
|
+
#
|
115
|
+
# argv = CLAide::ARGV.new(['tea', 'white'])
|
116
|
+
# argv.shift_argument # => 'tea'
|
117
|
+
# argv.arguments # => ['white']
|
118
|
+
#
|
119
|
+
def shift_argument
|
120
|
+
if index = @entries.find_index { |type, _| type == :arg }
|
121
|
+
entry = @entries[index]
|
122
|
+
@entries.delete_at(index)
|
123
|
+
entry.last
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# @return [Boolean, nil]
|
128
|
+
#
|
129
|
+
# Returns `true` if the flag by the specified `name` is among the
|
130
|
+
# remaining parameters and is not negated.
|
131
|
+
#
|
132
|
+
# @param [String] name
|
133
|
+
#
|
134
|
+
# The name of the flag to look for among the remaining parameters.
|
135
|
+
#
|
136
|
+
# @param [Boolean] default
|
137
|
+
#
|
138
|
+
# The value that is returned in case the flag is not among the remaining
|
139
|
+
# parameters.
|
140
|
+
#
|
141
|
+
# @note
|
142
|
+
#
|
143
|
+
# This will remove the flag from the remaining parameters.
|
144
|
+
#
|
145
|
+
# @example
|
146
|
+
#
|
147
|
+
# argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey'])
|
148
|
+
# argv.flag?('milk') # => false
|
149
|
+
# argv.flag?('milk') # => nil
|
150
|
+
# argv.flag?('milk', true) # => true
|
151
|
+
# argv.remainder # => ['tea', '--sweetner=honey']
|
152
|
+
#
|
153
|
+
def flag?(name, default = nil)
|
154
|
+
delete_entry(:flag, name, default)
|
155
|
+
end
|
156
|
+
|
157
|
+
# @return [String, nil]
|
158
|
+
#
|
159
|
+
# Returns the value of the option by the specified `name` is among the
|
160
|
+
# remaining parameters.
|
161
|
+
#
|
162
|
+
# @param [String] name
|
163
|
+
#
|
164
|
+
# The name of the option to look for among the remaining parameters.
|
165
|
+
#
|
166
|
+
# @param [String] default
|
167
|
+
#
|
168
|
+
# The value that is returned in case the option is not among the
|
169
|
+
# remaining parameters.
|
170
|
+
#
|
171
|
+
# @note
|
172
|
+
#
|
173
|
+
# This will remove the option from the remaining parameters.
|
174
|
+
#
|
175
|
+
# @example
|
176
|
+
#
|
177
|
+
# argv = CLAide::ARGV.new(['tea', '--no-milk', '--sweetner=honey'])
|
178
|
+
# argv.option('sweetner') # => 'honey'
|
179
|
+
# argv.option('sweetner') # => nil
|
180
|
+
# argv.option('sweetner', 'sugar') # => 'sugar'
|
181
|
+
# argv.remainder # => ['tea', '--no-milk']
|
182
|
+
#
|
183
|
+
def option(name, default = nil)
|
184
|
+
delete_entry(:option, name, default)
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
attr_reader :entries
|
190
|
+
|
191
|
+
def delete_entry(requested_type, requested_key, default)
|
192
|
+
result = nil
|
193
|
+
@entries.delete_if do |type, (key, value)|
|
194
|
+
if requested_key == key && requested_type == type
|
195
|
+
result = value
|
196
|
+
true
|
197
|
+
end
|
198
|
+
end
|
199
|
+
result.nil? ? default : result
|
200
|
+
end
|
201
|
+
|
202
|
+
# @return [Array<Array>]
|
203
|
+
#
|
204
|
+
# A list of tuples for each parameter, where the first entry is the
|
205
|
+
# `type` and the second entry the actual parsed parameter.
|
206
|
+
#
|
207
|
+
# @example
|
208
|
+
#
|
209
|
+
# list = parse(['tea', '--no-milk', '--sweetner=honey'])
|
210
|
+
# list # => [[:arg, "tea"],
|
211
|
+
# [:flag, ["milk", false]],
|
212
|
+
# [:option, ["sweetner", "honey"]]]
|
213
|
+
#
|
214
|
+
def self.parse(argv)
|
215
|
+
entries = []
|
216
|
+
copy = argv.map(&:to_s)
|
217
|
+
while x = copy.shift
|
218
|
+
type = key = value = nil
|
219
|
+
if is_arg?(x)
|
220
|
+
# A regular argument (e.g. a command)
|
221
|
+
type, value = :arg, x
|
222
|
+
else
|
223
|
+
key = x[2..-1]
|
224
|
+
if key.include?('=')
|
225
|
+
# An option with a value
|
226
|
+
type = :option
|
227
|
+
key, value = key.split('=', 2)
|
228
|
+
else
|
229
|
+
# A boolean flag
|
230
|
+
type = :flag
|
231
|
+
value = true
|
232
|
+
if key[0,3] == 'no-'
|
233
|
+
# A negated boolean flag
|
234
|
+
key = key[3..-1]
|
235
|
+
value = false
|
236
|
+
end
|
237
|
+
end
|
238
|
+
value = [key, value]
|
239
|
+
end
|
240
|
+
entries << [type, value]
|
241
|
+
end
|
242
|
+
entries
|
243
|
+
end
|
244
|
+
|
245
|
+
def self.is_arg?(x)
|
246
|
+
x[0,2] != '--'
|
247
|
+
end
|
248
|
+
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,370 @@
|
|
1
|
+
require 'claide/command/banner'
|
2
|
+
|
3
|
+
module CLAide
|
4
|
+
|
5
|
+
# This class is used to build a command-line interface
|
6
|
+
#
|
7
|
+
# Each command is represented by a subclass of this class, which may be
|
8
|
+
# nested to create more granular commands.
|
9
|
+
#
|
10
|
+
# Following is an overview of the types of commands and what they should do.
|
11
|
+
#
|
12
|
+
# ### Any command type
|
13
|
+
#
|
14
|
+
# * Inherit from the command class under which the command should be nested.
|
15
|
+
# * Set {Command.summary} to a brief description of the command.
|
16
|
+
# * Override {Command.options} to return the options it handles and their
|
17
|
+
# descriptions and prepending them to the results of calling `super`.
|
18
|
+
# * Override {Command#initialize} if it handles any parameters.
|
19
|
+
# * Override {Command#validate!} to check if the required parameters the
|
20
|
+
# command handles are valid, or call {Command#help!} in case they’re not.
|
21
|
+
#
|
22
|
+
# ### Abstract command
|
23
|
+
#
|
24
|
+
# The following is needed for an abstract command:
|
25
|
+
#
|
26
|
+
# * Set {Command.abstract_command} to `true`.
|
27
|
+
# * Subclass the command.
|
28
|
+
#
|
29
|
+
# When the optional {Command.description} is specified, it will be shown at
|
30
|
+
# the top of the command’s help banner.
|
31
|
+
#
|
32
|
+
# ### Normal command
|
33
|
+
#
|
34
|
+
# The following is needed for a normal command:
|
35
|
+
#
|
36
|
+
# * Set {Command.arguments} to the description of the arguments this command
|
37
|
+
# handles.
|
38
|
+
# * Override {Command#run} to perform the actual work.
|
39
|
+
#
|
40
|
+
# When the optional {Command.description} is specified, it will be shown
|
41
|
+
# underneath the usage section of the command’s help banner. Otherwise this
|
42
|
+
# defaults to {Command.summary}.
|
43
|
+
#
|
44
|
+
class Command
|
45
|
+
|
46
|
+
#-------------------------------------------------------------------------#
|
47
|
+
|
48
|
+
class << self
|
49
|
+
|
50
|
+
# @return [Boolean] Indicates whether or not this command can actually
|
51
|
+
# perform work of itself, or that it only contains subcommands.
|
52
|
+
#
|
53
|
+
attr_accessor :abstract_command
|
54
|
+
alias_method :abstract_command?, :abstract_command
|
55
|
+
|
56
|
+
# @return [String] The subcommand which an abstract command should invoke
|
57
|
+
# by default.
|
58
|
+
#
|
59
|
+
attr_accessor :default_subcommand
|
60
|
+
|
61
|
+
# @return [String] A brief description of the command, which is shown
|
62
|
+
# next to the command in the help banner of a parent command.
|
63
|
+
#
|
64
|
+
attr_accessor :summary
|
65
|
+
|
66
|
+
# @return [String] A longer description of the command, which is shown
|
67
|
+
# underneath the usage section of the command’s help banner. Any
|
68
|
+
# indentation in this value will be ignored.
|
69
|
+
#
|
70
|
+
attr_accessor :description
|
71
|
+
|
72
|
+
# @return [String] A list of arguments the command handles. This is shown
|
73
|
+
# in the usage section of the command’s help banner.
|
74
|
+
#
|
75
|
+
attr_accessor :arguments
|
76
|
+
|
77
|
+
# @return [Boolean] The default value for {Command#colorize_output}. This
|
78
|
+
# defaults to `true` if `String` has the instance methods
|
79
|
+
# `#green` and `#red`. Which are defined by, for instance, the
|
80
|
+
# [colored](https://github.com/defunkt/colored) gem.
|
81
|
+
#
|
82
|
+
def colorize_output
|
83
|
+
if @colorize_output.nil?
|
84
|
+
@colorize_output = String.method_defined?(:red) &&
|
85
|
+
String.method_defined?(:green)
|
86
|
+
end
|
87
|
+
@colorize_output
|
88
|
+
end
|
89
|
+
attr_writer :colorize_output
|
90
|
+
alias_method :colorize_output?, :colorize_output
|
91
|
+
|
92
|
+
# @return [String] The name of the command. Defaults to a snake-cased
|
93
|
+
# version of the class’ name.
|
94
|
+
#
|
95
|
+
def command
|
96
|
+
@command ||= name.split('::').last.gsub(/[A-Z]+[a-z]*/) do |part|
|
97
|
+
part.downcase << '-'
|
98
|
+
end[0..-2]
|
99
|
+
end
|
100
|
+
attr_writer :command
|
101
|
+
|
102
|
+
# @return [String] The full command up-to this command.
|
103
|
+
#
|
104
|
+
# @example
|
105
|
+
#
|
106
|
+
# BevarageMaker::Tea.full_command # => "beverage-maker tea"
|
107
|
+
#
|
108
|
+
def full_command
|
109
|
+
if superclass == Command
|
110
|
+
"#{command}"
|
111
|
+
else
|
112
|
+
"#{superclass.full_command} #{command}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# @return [Array<Class>] A list of command classes that are nested under
|
117
|
+
# this command.
|
118
|
+
#
|
119
|
+
def subcommands
|
120
|
+
@subcommands ||= []
|
121
|
+
end
|
122
|
+
|
123
|
+
# @visibility private
|
124
|
+
#
|
125
|
+
# Automatically registers a subclass as a subcommand.
|
126
|
+
#
|
127
|
+
def inherited(subcommand)
|
128
|
+
subcommands << subcommand
|
129
|
+
end
|
130
|
+
|
131
|
+
# Should be overridden by a subclass if it handles any options.
|
132
|
+
#
|
133
|
+
# The subclass has to combine the result of calling `super` and its own
|
134
|
+
# list of options. The recommended way of doing this is by concatenating
|
135
|
+
# concatenating to this classes’ own options.
|
136
|
+
#
|
137
|
+
# @return [Array<Array>]
|
138
|
+
#
|
139
|
+
# A list of option name and description tuples.
|
140
|
+
#
|
141
|
+
# @example
|
142
|
+
#
|
143
|
+
# def self.options
|
144
|
+
# [
|
145
|
+
# ['--verbose', 'Print more info'],
|
146
|
+
# ['--help', 'Print help banner'],
|
147
|
+
# ].concat(super)
|
148
|
+
# end
|
149
|
+
#
|
150
|
+
def options
|
151
|
+
options = [
|
152
|
+
['--verbose', 'Show more debugging information'],
|
153
|
+
['--help', 'Show help banner of specified command'],
|
154
|
+
]
|
155
|
+
if Command.colorize_output?
|
156
|
+
options.unshift(['--no-color', 'Show output without color'])
|
157
|
+
end
|
158
|
+
options
|
159
|
+
end
|
160
|
+
|
161
|
+
# @param [Array, ARGV] argv
|
162
|
+
# A list of (remaining) parameters.
|
163
|
+
#
|
164
|
+
# @return [Command] An instance of the command class that was matched by
|
165
|
+
# going through the arguments in the parameters and drilling down
|
166
|
+
# command classes.
|
167
|
+
#
|
168
|
+
def parse(argv)
|
169
|
+
argv = ARGV.new(argv) unless argv.is_a?(ARGV)
|
170
|
+
cmd = argv.arguments.first
|
171
|
+
if cmd && subcommand = subcommands.find { |sc| sc.command == cmd }
|
172
|
+
argv.shift_argument
|
173
|
+
subcommand.parse(argv)
|
174
|
+
elsif abstract_command? && default_subcommand
|
175
|
+
subcommand = subcommands.find { |sc| sc.command == default_subcommand }
|
176
|
+
unless subcommand
|
177
|
+
raise "Unable to find the default subcommand `#{default_subcommand}` " \
|
178
|
+
"for command `#{self}`."
|
179
|
+
end
|
180
|
+
result = subcommand.parse(argv)
|
181
|
+
result.invoked_as_default = true
|
182
|
+
result
|
183
|
+
else
|
184
|
+
new(argv)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Instantiates the command class matching the parameters through
|
189
|
+
# {Command.parse}, validates it through {Command#validate!}, and runs it
|
190
|
+
# through {Command#run}.
|
191
|
+
#
|
192
|
+
# @note
|
193
|
+
#
|
194
|
+
# You should normally call this on
|
195
|
+
#
|
196
|
+
# @param [Array, ARGV] argv
|
197
|
+
#
|
198
|
+
# A list of parameters. For instance, the standard `ARGV` constant,
|
199
|
+
# which contains the parameters passed to the program.
|
200
|
+
#
|
201
|
+
# @return [void]
|
202
|
+
#
|
203
|
+
def run(argv)
|
204
|
+
command = parse(argv)
|
205
|
+
command.validate!
|
206
|
+
command.run
|
207
|
+
rescue Exception => exception
|
208
|
+
if exception.is_a?(InformativeError)
|
209
|
+
puts exception.message
|
210
|
+
if command.verbose?
|
211
|
+
puts
|
212
|
+
puts(*exception.backtrace)
|
213
|
+
end
|
214
|
+
exit exception.exit_status
|
215
|
+
else
|
216
|
+
report_error(exception)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Allows the application to perform custom error reporting, by overriding
|
221
|
+
# this method.
|
222
|
+
#
|
223
|
+
# @param [Exception] exception
|
224
|
+
#
|
225
|
+
# An exception that occurred while running a command through
|
226
|
+
# {Command.run}.
|
227
|
+
#
|
228
|
+
# @raise
|
229
|
+
#
|
230
|
+
# By default re-raises the specified exception.
|
231
|
+
#
|
232
|
+
# @return [void]
|
233
|
+
#
|
234
|
+
def report_error(exception)
|
235
|
+
raise exception
|
236
|
+
end
|
237
|
+
|
238
|
+
# @visibility private
|
239
|
+
#
|
240
|
+
# @raise [Help]
|
241
|
+
#
|
242
|
+
# Signals CLAide that a help banner for this command should be shown,
|
243
|
+
# with an optional error message.
|
244
|
+
#
|
245
|
+
# @return [void]
|
246
|
+
#
|
247
|
+
def help!(error_message = nil, colorize = false)
|
248
|
+
raise Help.new(banner(colorize), error_message, colorize)
|
249
|
+
end
|
250
|
+
|
251
|
+
# @visibility private
|
252
|
+
#
|
253
|
+
# Returns the banner for the command.
|
254
|
+
#
|
255
|
+
# @param [Bool] colorize
|
256
|
+
# Whether the banner should be returned colorized.
|
257
|
+
#
|
258
|
+
# @return [String] The banner for the command.
|
259
|
+
#
|
260
|
+
def banner(colorize = false)
|
261
|
+
Banner.new(self, colorize).formatted_banner
|
262
|
+
end
|
263
|
+
|
264
|
+
end
|
265
|
+
|
266
|
+
#-------------------------------------------------------------------------#
|
267
|
+
|
268
|
+
# Set to `true` if the user specifies the `--verbose` option.
|
269
|
+
#
|
270
|
+
# @note
|
271
|
+
#
|
272
|
+
# If you want to make use of this value for your own configuration, you
|
273
|
+
# should check the value _after_ calling the `super` {Command#initialize}
|
274
|
+
# implementation.
|
275
|
+
#
|
276
|
+
# @return [Boolean]
|
277
|
+
#
|
278
|
+
# Wether or not backtraces should be included when presenting the user an
|
279
|
+
# exception that includes the {InformativeError} module.
|
280
|
+
#
|
281
|
+
attr_accessor :verbose
|
282
|
+
alias_method :verbose?, :verbose
|
283
|
+
|
284
|
+
# Set to `true` if {Command.colorize_output} returns `true` and the user
|
285
|
+
# did **not** specify the `--no-color` option.
|
286
|
+
#
|
287
|
+
# @note (see #verbose)
|
288
|
+
#
|
289
|
+
# @return [Boolean]
|
290
|
+
#
|
291
|
+
# Wether or not to color {InformativeError} exception messages red and
|
292
|
+
# subcommands in help banners green.
|
293
|
+
#
|
294
|
+
attr_accessor :colorize_output
|
295
|
+
alias_method :colorize_output?, :colorize_output
|
296
|
+
|
297
|
+
# @return [Bool] Whether the command was invoked by an abstract command by
|
298
|
+
# default.
|
299
|
+
#
|
300
|
+
attr_accessor :invoked_as_default
|
301
|
+
alias_method :invoked_as_default?, :invoked_as_default
|
302
|
+
|
303
|
+
# Subclasses should override this method to remove the arguments/options
|
304
|
+
# they support from `argv` _before_ calling `super`.
|
305
|
+
#
|
306
|
+
# The `super` implementation sets the {#verbose} attribute based on whether
|
307
|
+
# or not the `--verbose` option is specified; and the {#colorize_output}
|
308
|
+
# attribute to `false` if {Command.colorize_output} returns `true`, but the
|
309
|
+
# user specified the `--no-color` option.
|
310
|
+
#
|
311
|
+
# @param [ARGV, Array] argv
|
312
|
+
#
|
313
|
+
# A list of (user-supplied) params that should be handled.
|
314
|
+
#
|
315
|
+
def initialize(argv)
|
316
|
+
argv = ARGV.new(argv) unless argv.is_a?(ARGV)
|
317
|
+
@verbose = argv.flag?('verbose')
|
318
|
+
@colorize_output = argv.flag?('color', Command.colorize_output?)
|
319
|
+
@argv = argv
|
320
|
+
end
|
321
|
+
|
322
|
+
# Raises a Help exception if the `--help` option is specified, if `argv`
|
323
|
+
# still contains remaining arguments/options by the time it reaches this
|
324
|
+
# implementation, or when called on an ‘abstract command’.
|
325
|
+
#
|
326
|
+
# Subclasses should call `super` _before_ doing their own validation. This
|
327
|
+
# way when the user specifies the `--help` flag a help banner is shown,
|
328
|
+
# instead of possible actual validation errors.
|
329
|
+
#
|
330
|
+
# @raise [Help]
|
331
|
+
#
|
332
|
+
# @return [void]
|
333
|
+
#
|
334
|
+
def validate!
|
335
|
+
help! if @argv.flag?('help')
|
336
|
+
help! "Unknown arguments: #{@argv.remainder.join(' ')}" if !@argv.empty?
|
337
|
+
help! if self.class.abstract_command?
|
338
|
+
end
|
339
|
+
|
340
|
+
# This method should be overridden by the command class to perform its work.
|
341
|
+
#
|
342
|
+
# @return [void
|
343
|
+
#
|
344
|
+
def run
|
345
|
+
raise "A subclass should override the Command#run method to actually " \
|
346
|
+
"perform some work."
|
347
|
+
end
|
348
|
+
|
349
|
+
protected
|
350
|
+
|
351
|
+
# @raise [Help]
|
352
|
+
#
|
353
|
+
# Signals CLAide that a help banner for this command should be shown,
|
354
|
+
# with an optional error message.
|
355
|
+
#
|
356
|
+
# @return [void]
|
357
|
+
#
|
358
|
+
def help!(error_message = nil)
|
359
|
+
if invoked_as_default?
|
360
|
+
command = self.class.superclass
|
361
|
+
else
|
362
|
+
command = self.class
|
363
|
+
end
|
364
|
+
command = command.help!(error_message, colorize_output?)
|
365
|
+
end
|
366
|
+
|
367
|
+
#-------------------------------------------------------------------------#
|
368
|
+
|
369
|
+
end
|
370
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
module CLAide
|
2
|
+
class Command
|
3
|
+
|
4
|
+
# Creates the formatted banner to present as help of the provided command
|
5
|
+
# class.
|
6
|
+
#
|
7
|
+
class Banner
|
8
|
+
|
9
|
+
# @return [Class]
|
10
|
+
#
|
11
|
+
attr_accessor :command
|
12
|
+
|
13
|
+
# @return [Bool]
|
14
|
+
#
|
15
|
+
attr_accessor :colorize_output
|
16
|
+
alias_method :colorize_output?, :colorize_output
|
17
|
+
|
18
|
+
# @param [Class] command @see command
|
19
|
+
# @param [Class] colorize_output@see colorize_output
|
20
|
+
#
|
21
|
+
def initialize(command, colorize_output = false)
|
22
|
+
@command = command
|
23
|
+
@colorize_output = colorize_output
|
24
|
+
end
|
25
|
+
|
26
|
+
# @return [String]
|
27
|
+
#
|
28
|
+
def formatted_banner
|
29
|
+
banner = []
|
30
|
+
if command.abstract_command?
|
31
|
+
banner << command.description if command.description
|
32
|
+
elsif usage = formatted_usage_description
|
33
|
+
banner << 'Usage:'
|
34
|
+
banner << usage
|
35
|
+
end
|
36
|
+
if commands = formatted_subcommand_summaries
|
37
|
+
banner << 'Commands:'
|
38
|
+
banner << commands
|
39
|
+
end
|
40
|
+
banner << 'Options:'
|
41
|
+
banner << formatted_options_description
|
42
|
+
banner.join("\n\n")
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
# @!group Banner sections
|
48
|
+
|
49
|
+
#-----------------------------------------------------------------------#
|
50
|
+
|
51
|
+
# @return [String]
|
52
|
+
#
|
53
|
+
def formatted_options_description
|
54
|
+
opts = command.options
|
55
|
+
size = opts.map { |opt| opt.first.size }.max
|
56
|
+
opts.map { |key, desc| " #{key.ljust(size)} #{desc}" }.join("\n")
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [String]
|
60
|
+
#
|
61
|
+
def formatted_usage_description
|
62
|
+
if message = command.description || command.summary
|
63
|
+
message = strip_heredoc(message)
|
64
|
+
message = message.split("\n").map { |line| " #{line}" }.join("\n")
|
65
|
+
args = " #{command.arguments}" if command.arguments
|
66
|
+
" $ #{command.full_command}#{args}\n\n#{message}"
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# @return [String]
|
71
|
+
#
|
72
|
+
def formatted_subcommand_summaries
|
73
|
+
subcommands = command.subcommands.reject do |subcommand|
|
74
|
+
subcommand.summary.nil?
|
75
|
+
end.sort_by(&:command)
|
76
|
+
unless subcommands.empty?
|
77
|
+
command_size = subcommands.map { |cmd| cmd.command.size }.max
|
78
|
+
subcommands.map do |subcommand|
|
79
|
+
subcommand_string = subcommand.command.ljust(command_size)
|
80
|
+
subcommand_string = subcommand_string.green if colorize_output?
|
81
|
+
is_default = subcommand.command == command.default_subcommand
|
82
|
+
if is_default
|
83
|
+
bullet_point = '-'
|
84
|
+
else
|
85
|
+
bullet_point = '*'
|
86
|
+
end
|
87
|
+
" #{bullet_point} #{subcommand_string} #{subcommand.summary}"
|
88
|
+
end.join("\n")
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
private
|
93
|
+
|
94
|
+
# @!group Private helpers
|
95
|
+
|
96
|
+
#-----------------------------------------------------------------------#
|
97
|
+
|
98
|
+
# @return [String] Lifted straight from ActiveSupport. Thanks guys!
|
99
|
+
#
|
100
|
+
def strip_heredoc(string)
|
101
|
+
if min = string.scan(/^[ \t]*(?=\S)/).min
|
102
|
+
string.gsub(/^[ \t]{#{min.size}}/, '')
|
103
|
+
else
|
104
|
+
string
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
data/lib/claide/help.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
module CLAide
|
2
|
+
|
3
|
+
require 'claide/informative_error.rb'
|
4
|
+
|
5
|
+
# The exception class that is raised to indicate a help banner should be
|
6
|
+
# shown while running {Command.run}.
|
7
|
+
#
|
8
|
+
class Help < StandardError
|
9
|
+
include InformativeError
|
10
|
+
|
11
|
+
# @return [String] The banner containing the usage instructions of the
|
12
|
+
# command to show in the help.
|
13
|
+
#
|
14
|
+
attr_reader :banner
|
15
|
+
|
16
|
+
# @return [String] An optional error message that will be shown before the
|
17
|
+
# help banner.
|
18
|
+
#
|
19
|
+
attr_reader :error_message
|
20
|
+
|
21
|
+
# @return [Bool] Whether the error message should be colorized.
|
22
|
+
#
|
23
|
+
attr_reader :colorize
|
24
|
+
alias_method :colorize?, :colorize
|
25
|
+
|
26
|
+
# @param [String] banner @see banner
|
27
|
+
# @param [String] error_message @see error_message
|
28
|
+
#
|
29
|
+
# @note If an error message is provided, the exit status, used to
|
30
|
+
# terminate the program with, will be set to `1`, otherwise a {Help}
|
31
|
+
# exception is treated as not being a real error and exits with `0`.
|
32
|
+
#
|
33
|
+
def initialize(banner, error_message = nil, colorize = false)
|
34
|
+
@banner = banner
|
35
|
+
@error_message = error_message
|
36
|
+
@colorize = colorize
|
37
|
+
@exit_status = @error_message.nil? ? 0 : 1
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [String] The optional error message, colored in red if
|
41
|
+
# {Command.colorize_output} is set to `true`.
|
42
|
+
#
|
43
|
+
def formatted_error_message
|
44
|
+
if error_message
|
45
|
+
message = "[!] #{error_message}"
|
46
|
+
colorize? ? message.red : message
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# @return [String] The optional error message, combined with the help
|
51
|
+
# banner of the command.
|
52
|
+
#
|
53
|
+
def message
|
54
|
+
[formatted_error_message, banner].compact.join("\n\n")
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module CLAide
|
2
|
+
|
3
|
+
# Including this module into an exception class will ensure that when raised,
|
4
|
+
# while running {Command.run}, only the message of the exception will be
|
5
|
+
# shown to the user. Unless disabled with the `--verbose` flag.
|
6
|
+
#
|
7
|
+
# In addition, the message will be colored red, if {Command.colorize_output}
|
8
|
+
# is set to `true`.
|
9
|
+
#
|
10
|
+
module InformativeError
|
11
|
+
|
12
|
+
# @return [Numeric] The exist status code that should be used to terminate
|
13
|
+
# the program with. Defaults to `1`.
|
14
|
+
#
|
15
|
+
attr_accessor :exit_status
|
16
|
+
|
17
|
+
def exit_status
|
18
|
+
@exit_status ||= 1
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: claide
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.3.
|
4
|
+
version: 0.3.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Eloy Duran
|
@@ -19,6 +19,11 @@ executables: []
|
|
19
19
|
extensions: []
|
20
20
|
extra_rdoc_files: []
|
21
21
|
files:
|
22
|
+
- lib/claide/argv.rb
|
23
|
+
- lib/claide/command/banner.rb
|
24
|
+
- lib/claide/command.rb
|
25
|
+
- lib/claide/help.rb
|
26
|
+
- lib/claide/informative_error.rb
|
22
27
|
- lib/claide.rb
|
23
28
|
- README.markdown
|
24
29
|
- LICENSE
|