benry-cmdapp 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.
- checksums.yaml +7 -0
- data/CHANGES.md +8 -0
- data/MIT-LICENSE +21 -0
- data/README.md +2475 -0
- data/benry-cmdapp.gemspec +38 -0
- data/doc/benry-cmdapp.html +2314 -0
- data/doc/css/style.css +168 -0
- data/lib/benry/cmdapp.rb +1376 -0
- data/test/action_test.rb +1038 -0
- data/test/app_test.rb +1371 -0
- data/test/func_test.rb +137 -0
- data/test/help_test.rb +755 -0
- data/test/index_test.rb +185 -0
- data/test/run_all.rb +7 -0
- data/test/shared.rb +75 -0
- data/test/util_test.rb +189 -0
- metadata +98 -0
data/lib/benry/cmdapp.rb
ADDED
@@ -0,0 +1,1376 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
###
|
5
|
+
### $Release: 0.1.0 $
|
6
|
+
### $Copyright: copyright(c) 2023 kwatch@gmail.com $
|
7
|
+
### $License: MIT License $
|
8
|
+
###
|
9
|
+
|
10
|
+
|
11
|
+
require 'benry/cmdopt'
|
12
|
+
|
13
|
+
|
14
|
+
module Benry::CmdApp
|
15
|
+
|
16
|
+
|
17
|
+
SCHEMA_CLASS = Benry::CmdOpt::Schema
|
18
|
+
PARSER_CLASS = Benry::CmdOpt::Parser
|
19
|
+
|
20
|
+
|
21
|
+
class BaseError < StandardError; end
|
22
|
+
|
23
|
+
class DefinitionError < BaseError; end
|
24
|
+
class ActionDefError < DefinitionError; end
|
25
|
+
class OptionDefError < DefinitionError; end
|
26
|
+
class AliasDefError < DefinitionError; end
|
27
|
+
|
28
|
+
class ExecutionError < BaseError; end
|
29
|
+
class CommandError < ExecutionError; end
|
30
|
+
class InvalidOptionError < ExecutionError; end
|
31
|
+
class ActionNotFoundError < ExecutionError; end
|
32
|
+
class LoopedActionError < ExecutionError; end
|
33
|
+
|
34
|
+
|
35
|
+
module Util
|
36
|
+
module_function
|
37
|
+
|
38
|
+
def hidden_name?(name)
|
39
|
+
#; [!fcfic] returns true if name is '_foo'.
|
40
|
+
#; [!po5co] returns true if name is '_foo:bar'.
|
41
|
+
return true if name =~ /\A_/
|
42
|
+
#; [!9iqz3] returns true if name is 'foo:_bar'.
|
43
|
+
return true if name =~ /:_[-\w]*\z/
|
44
|
+
#; [!mjjbg] returns false if else.
|
45
|
+
return false
|
46
|
+
end
|
47
|
+
|
48
|
+
def schema_empty?(schema, all=false)
|
49
|
+
#; [!8t5ju] returns true if schema empty.
|
50
|
+
#; [!c4ljy] returns true if schema contains only private (hidden) options.
|
51
|
+
schema.each {|item| return false if all || ! item.hidden? }
|
52
|
+
return true
|
53
|
+
end
|
54
|
+
|
55
|
+
def method2action(name)
|
56
|
+
#; [!801f9] converts action name 'aa_bb_cc_' into 'aa_bb_cc'.
|
57
|
+
name = name.sub(/_+\z/, '') # ex: 'aa_bb_cc_' => 'aa_bb_cc'
|
58
|
+
#; [!9pahu] converts action name 'aa__bb__cc' into 'aa:bb:cc'.
|
59
|
+
name = name.gsub(/__/, ':') # ex: 'aa__bb__cc' => 'aa:bb:cc'
|
60
|
+
#; [!7a1s7] converts action name 'aa_bb:_cc_dd' into 'aa-bb:_cc-dd'.
|
61
|
+
name = name.gsub(/(?<=\w)_/, '-') # ex: 'aa_bb:_cc_dd' => 'aa-bb:_cc-dd'
|
62
|
+
return name
|
63
|
+
end
|
64
|
+
|
65
|
+
def colorize?()
|
66
|
+
#; [!801y1] returns $COLOR_MODE value if it is not nil.
|
67
|
+
return $COLOR_MODE if $COLOR_MODE != nil
|
68
|
+
#; [!0harg] returns true if stdout is a tty.
|
69
|
+
#; [!u1j1x] returns false if stdout is not a tty.
|
70
|
+
return $stdout.tty?
|
71
|
+
end
|
72
|
+
|
73
|
+
def del_escape_seq(str)
|
74
|
+
#; [!wgp2b] deletes escape sequence.
|
75
|
+
return str.gsub(/\e\[.*?m/, '')
|
76
|
+
end
|
77
|
+
|
78
|
+
class Doing # :nodoc:
|
79
|
+
def inspect(); "<DOING>"; end
|
80
|
+
alias to_s inspect
|
81
|
+
end
|
82
|
+
|
83
|
+
DOING = Doing.new # :nodoc:
|
84
|
+
|
85
|
+
## (obsolete)
|
86
|
+
def _important?(tag) # :nodoc:
|
87
|
+
#; [!0yz2h] returns nil if tag == nil.
|
88
|
+
#; [!h5pid] returns true if tag == :important.
|
89
|
+
#; [!7zval] returns false if tag == :unimportant.
|
90
|
+
#; [!z1ygi] supports nested tag.
|
91
|
+
case tag
|
92
|
+
when nil ; return nil
|
93
|
+
when :important, "important" ; return true
|
94
|
+
when :unimportant, "unimportant" ; return false
|
95
|
+
when Array
|
96
|
+
return true if tag.include?(:important)
|
97
|
+
return false if tag.include?(:unimportant)
|
98
|
+
return nil
|
99
|
+
else
|
100
|
+
return nil
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def str_strong(s)
|
105
|
+
return "\e[4m#{s}\e[0m"
|
106
|
+
end
|
107
|
+
|
108
|
+
def str_weak(s)
|
109
|
+
return "\e[2m#{s}\e[0m"
|
110
|
+
end
|
111
|
+
|
112
|
+
def format_help_line(format, name, desc, important)
|
113
|
+
#; [!xx1vj] if `important == nil` then format help line with no decoration.
|
114
|
+
#; [!oaxp1] if `important == true` then format help line with strong decoration.
|
115
|
+
#; [!bdhh6] if `important == false` then format help line with weak decoration.
|
116
|
+
if important != nil
|
117
|
+
name = fill_with_decoration(format, name) {|s|
|
118
|
+
important ? str_strong(s) : str_weak(s)
|
119
|
+
}
|
120
|
+
format = format.sub(/%-?(\d+)s/, '%s')
|
121
|
+
end
|
122
|
+
return format % [name, desc]
|
123
|
+
end
|
124
|
+
|
125
|
+
def fill_with_decoration(format, name, &block)
|
126
|
+
#; [!udrbj] returns decorated string with padding by white spaces.
|
127
|
+
if format =~ /%(-)?(\d+)s/
|
128
|
+
leftside = !! $1
|
129
|
+
width = $2.to_i
|
130
|
+
n = width - name.length
|
131
|
+
n = 0 if n < 0
|
132
|
+
s = " " * n
|
133
|
+
#; [!7bl2b] considers minus sign in format.
|
134
|
+
return leftside ? (yield name) + s : s + (yield name)
|
135
|
+
else
|
136
|
+
return yield name
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
class ActionIndex
|
144
|
+
|
145
|
+
def initialize()
|
146
|
+
@actions = {} # {action_name => ActionMetadata}
|
147
|
+
@aliases = {} # {alias_name => Alias}
|
148
|
+
@done = {} # {action_name => (Object|DOING)}
|
149
|
+
end
|
150
|
+
|
151
|
+
def lookup_action(action_name)
|
152
|
+
name = action_name.to_s
|
153
|
+
#; [!tnwq0] supports alias name.
|
154
|
+
alias_obj = nil
|
155
|
+
if @aliases[name]
|
156
|
+
alias_obj = @aliases[name]
|
157
|
+
name = alias_obj.action_name
|
158
|
+
end
|
159
|
+
#; [!vivoa] returns action metadata object.
|
160
|
+
#; [!z15vu] returns ActionWithArgs object if alias has args and/or kwargs.
|
161
|
+
metadata = @actions[name]
|
162
|
+
if alias_obj && alias_obj.args && ! alias_obj.args.empty?
|
163
|
+
args = alias_obj.args.dup()
|
164
|
+
opts = metadata.parse_options(args)
|
165
|
+
return ActionWithArgs.new(metadata, args, opts)
|
166
|
+
else
|
167
|
+
return metadata
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def each_action_name_and_desc(include_alias=true, all: false, &block)
|
172
|
+
#; [!5lahm] yields action name, description, and important flag.
|
173
|
+
#; [!27j8b] includes alias names when the first arg is true.
|
174
|
+
#; [!8xt8s] rejects hidden actions if 'all: false' kwarg specified.
|
175
|
+
#; [!5h7s5] includes hidden actions if 'all: true' kwarg specified.
|
176
|
+
#; [!arcia] action names are sorted.
|
177
|
+
metadatas = @actions.values()
|
178
|
+
metadatas = metadatas.reject {|ameta| ameta.hidden? } if ! all
|
179
|
+
pairs = metadatas.collect {|ameta|
|
180
|
+
[ameta.name, ameta.desc, ameta.important?]
|
181
|
+
}
|
182
|
+
pairs += @aliases.collect {|name, aliobj|
|
183
|
+
[name, aliobj.desc, aliobj.important?]
|
184
|
+
} if include_alias
|
185
|
+
pairs.sort_by {|name, _, _| name }.each(&block)
|
186
|
+
end
|
187
|
+
|
188
|
+
def get_action(action_name)
|
189
|
+
return @actions[action_name.to_s]
|
190
|
+
end
|
191
|
+
|
192
|
+
def register_action(action_name, action_metadata)
|
193
|
+
@actions[action_name.to_s] = action_metadata
|
194
|
+
action_metadata
|
195
|
+
end
|
196
|
+
|
197
|
+
def delete_action(action_name)
|
198
|
+
#; [!08e1s] unregisters action.
|
199
|
+
#; [!zjpq0] raises error if action not registered.
|
200
|
+
@actions.delete(action_name.to_s) or
|
201
|
+
raise ActionNotFoundError.new("delete_action(#{action_name.inspect}): Action not found.")
|
202
|
+
end
|
203
|
+
|
204
|
+
def action_exist?(action_name)
|
205
|
+
return @actions.key?(action_name.to_s)
|
206
|
+
end
|
207
|
+
|
208
|
+
def each_action(&block)
|
209
|
+
@actions.values().each(&block)
|
210
|
+
nil
|
211
|
+
end
|
212
|
+
|
213
|
+
def action_result(action_name)
|
214
|
+
return @done[action_name.to_s]
|
215
|
+
end
|
216
|
+
|
217
|
+
def action_done(action_name, val)
|
218
|
+
@done[action_name.to_s] = val
|
219
|
+
val
|
220
|
+
end
|
221
|
+
|
222
|
+
def action_done?(action_name)
|
223
|
+
return @done.key?(action_name.to_s) && ! action_doing?(action_name)
|
224
|
+
end
|
225
|
+
|
226
|
+
def action_doing(action_name)
|
227
|
+
@done[action_name.to_s] = Util::DOING
|
228
|
+
nil
|
229
|
+
end
|
230
|
+
|
231
|
+
def action_doing?(action_name)
|
232
|
+
return action_result(action_name) == Util::DOING
|
233
|
+
end
|
234
|
+
|
235
|
+
def register_alias(alias_name, alias_obj)
|
236
|
+
@aliases[alias_name.to_s] = alias_obj
|
237
|
+
alias_obj
|
238
|
+
end
|
239
|
+
|
240
|
+
def delete_alias(alias_name)
|
241
|
+
#; [!8ls45] unregisters alias.
|
242
|
+
#; [!fdfyq] raises error if alias not registered.
|
243
|
+
@aliases.delete(alias_name.to_s) or
|
244
|
+
raise ActionNotFoundError.new("delete_alias(#{alias_name.inspect}): Alias not found.")
|
245
|
+
end
|
246
|
+
|
247
|
+
def get_alias(alias_name)
|
248
|
+
return @aliases[alias_name.to_s]
|
249
|
+
end
|
250
|
+
|
251
|
+
def alias_exist?(alias_name)
|
252
|
+
return @aliases.key?(alias_name)
|
253
|
+
end
|
254
|
+
|
255
|
+
def each_alias(&block)
|
256
|
+
@aliases.values().each(&block)
|
257
|
+
end
|
258
|
+
|
259
|
+
end
|
260
|
+
|
261
|
+
|
262
|
+
INDEX = ActionIndex.new
|
263
|
+
|
264
|
+
|
265
|
+
def self.delete_action(action_name)
|
266
|
+
#; [!era7d] deletes action.
|
267
|
+
#; [!ifaj1] raises error if action not exist.
|
268
|
+
INDEX.delete_action(action_name)
|
269
|
+
end
|
270
|
+
|
271
|
+
def self.delete_alias(alias_name)
|
272
|
+
#; [!9g0x9] deletes alias.
|
273
|
+
#; [!r49vi] raises error if alias not exist.
|
274
|
+
INDEX.delete_alias(alias_name)
|
275
|
+
end
|
276
|
+
|
277
|
+
|
278
|
+
class ActionMetadata
|
279
|
+
|
280
|
+
def initialize(name, klass, method, desc, schema, detail: nil, postamble: nil, important: nil, tag: nil)
|
281
|
+
@name = name
|
282
|
+
@klass = klass
|
283
|
+
@method = method
|
284
|
+
@schema = schema
|
285
|
+
@desc = desc
|
286
|
+
@detail = detail if detail != nil
|
287
|
+
@postamble = postamble if postamble != nil
|
288
|
+
@important = important if important != nil
|
289
|
+
@tag = tag if tag != nil
|
290
|
+
end
|
291
|
+
|
292
|
+
attr_reader :name, :method, :klass, :schema, :desc, :detail, :postamble, :important, :tag
|
293
|
+
|
294
|
+
def hidden?()
|
295
|
+
#; [!kp10p] returns true when action method is private.
|
296
|
+
#; [!nw322] returns false when action method is not private.
|
297
|
+
return ! @klass.method_defined?(@method)
|
298
|
+
end
|
299
|
+
|
300
|
+
def important?()
|
301
|
+
#; [!52znh] returns true if `@important == true`.
|
302
|
+
#; [!rlfac] returns false if `@important == false`.
|
303
|
+
#; [!j3trl] returns false if `@important == nil`. and action is hidden.
|
304
|
+
#; [!hhef8] returns nil if `@important == nil`.
|
305
|
+
return @important if @important != nil
|
306
|
+
return false if hidden?()
|
307
|
+
return nil
|
308
|
+
end
|
309
|
+
|
310
|
+
def parse_options(argv, all=true)
|
311
|
+
#; [!ab3j8] parses argv and returns options.
|
312
|
+
return PARSER_CLASS.new(@schema).parse(argv, all: all)
|
313
|
+
#; [!56da8] raises InvalidOptionError if option value is invalid.
|
314
|
+
rescue Benry::CmdOpt::OptionError => exc
|
315
|
+
raise InvalidOptionError.new(exc.message)
|
316
|
+
end
|
317
|
+
|
318
|
+
def run_action(*args, **kwargs)
|
319
|
+
if ! $TRACE_MODE
|
320
|
+
__run_action(*args, **kwargs)
|
321
|
+
else
|
322
|
+
#; [!tubhv] if $TRACE_MODE is on, prints tracing info.
|
323
|
+
#; [!zgp14] tracing info is colored when stdout is a tty.
|
324
|
+
s = "## enter: #{@name}"
|
325
|
+
s = "\e[33m#{s}\e[0m" if Util.colorize?
|
326
|
+
puts s
|
327
|
+
__run_action(*args, **kwargs)
|
328
|
+
s = "## exit: #{@name}"
|
329
|
+
s = "\e[33m#{s}\e[0m" if Util.colorize?
|
330
|
+
puts s
|
331
|
+
end
|
332
|
+
nil
|
333
|
+
end
|
334
|
+
|
335
|
+
def __run_action(*args, **kwargs)
|
336
|
+
#; [!veass] runs action with args and kwargs.
|
337
|
+
action_obj = _new_action_object()
|
338
|
+
if kwargs.empty? # for Ruby < 2.7
|
339
|
+
action_obj.__send__(@method, *args) # for Ruby < 2.7
|
340
|
+
else
|
341
|
+
action_obj.__send__(@method, *args, **kwargs)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
private :__run_action
|
345
|
+
|
346
|
+
def _new_action_object()
|
347
|
+
return @klass.new
|
348
|
+
end
|
349
|
+
protected :_new_action_object
|
350
|
+
|
351
|
+
def method_arity()
|
352
|
+
#; [!7v4tp] returns min and max number of positional arguments.
|
353
|
+
n_req = 0
|
354
|
+
n_opt = 0
|
355
|
+
has_rest = false
|
356
|
+
@klass.instance_method(@method).parameters.each do |kind, _|
|
357
|
+
case kind
|
358
|
+
when :req ; n_req += 1
|
359
|
+
when :opt ; n_opt += 1
|
360
|
+
when :rest ; has_rest = true
|
361
|
+
when :key ; nil
|
362
|
+
when :keyrest ; nil
|
363
|
+
else ; nil
|
364
|
+
end
|
365
|
+
end
|
366
|
+
#; [!w3rer] max is nil if variable argument exists.
|
367
|
+
return has_rest ? [n_req, nil] : [n_req, n_req + n_opt]
|
368
|
+
end
|
369
|
+
|
370
|
+
def validate_method_params()
|
371
|
+
#; [!plkhs] returns error message if keyword parameter for option not exist.
|
372
|
+
#; [!1koi8] returns nil if all keyword parameters for option exist.
|
373
|
+
kw_params = []
|
374
|
+
method_obj = @klass.instance_method(@method)
|
375
|
+
method_obj.parameters.each {|kind, param| kw_params << param if kind == :key }
|
376
|
+
opt_keys = @schema.each.collect {|item| item.key }
|
377
|
+
key = (opt_keys - kw_params).first
|
378
|
+
return nil if key == nil
|
379
|
+
return "Should have keyword parameter '#{key}' for '@option.(#{key.inspect})', but not."
|
380
|
+
end
|
381
|
+
|
382
|
+
def help_message(command, all=false)
|
383
|
+
#; [!i7siu] returns help message of action.
|
384
|
+
builder = ACTION_HELP_BUILDER_CLASS.new(self)
|
385
|
+
return builder.build_help_message(command, all)
|
386
|
+
end
|
387
|
+
|
388
|
+
end
|
389
|
+
|
390
|
+
|
391
|
+
ACTION_METADATA_CLASS = ActionMetadata
|
392
|
+
|
393
|
+
|
394
|
+
class ActionWithArgs
|
395
|
+
|
396
|
+
def initialize(action_metadata, args, kwargs)
|
397
|
+
#; [!6jklb] keeps ActionMetadata, args, and kwargs.
|
398
|
+
@action_metadata = action_metadata
|
399
|
+
@args = args
|
400
|
+
@kwargs = kwargs
|
401
|
+
end
|
402
|
+
|
403
|
+
attr_reader :action_metadata, :args, :kwargs
|
404
|
+
|
405
|
+
def method_missing(meth, *args, **kwargs)
|
406
|
+
#; [!14li3] behaves as ActionMetadata.
|
407
|
+
if kwargs.empty? # Ruby < 2.7
|
408
|
+
return @action_metadata.__send__(meth, *args) # Ruby < 2.7
|
409
|
+
else
|
410
|
+
return @action_metadata.__send__(meth, *args, **kwargs)
|
411
|
+
end
|
412
|
+
end
|
413
|
+
|
414
|
+
def method()
|
415
|
+
return @action_metadata.method
|
416
|
+
end
|
417
|
+
|
418
|
+
def run_action(*args, **kwargs)
|
419
|
+
#; [!fl26i] invokes action with args and kwargs.
|
420
|
+
args = @args + args if @args
|
421
|
+
kwargs = @kwargs.merge(kwargs) if @kwargs
|
422
|
+
super(*args, **kwargs)
|
423
|
+
end
|
424
|
+
|
425
|
+
end
|
426
|
+
|
427
|
+
|
428
|
+
class HelpBuilder
|
429
|
+
|
430
|
+
def build_section(title, content, desc=nil)
|
431
|
+
#; [!cfijh] includes section title and content if specified by config.
|
432
|
+
#; [!09jzn] third argument can be nil.
|
433
|
+
sb = []
|
434
|
+
if desc
|
435
|
+
sb << heading(title) << " " << desc << "\n"
|
436
|
+
else
|
437
|
+
sb << heading(title) << "\n"
|
438
|
+
end
|
439
|
+
sb << content
|
440
|
+
sb << "\n" unless content.end_with?("\n")
|
441
|
+
return sb.join()
|
442
|
+
end
|
443
|
+
|
444
|
+
def config()
|
445
|
+
nil
|
446
|
+
end
|
447
|
+
|
448
|
+
def heading(title)
|
449
|
+
c = config()
|
450
|
+
format = c ? c.format_heading : Config::FORMAT_HEADING
|
451
|
+
return format % title
|
452
|
+
end
|
453
|
+
|
454
|
+
end
|
455
|
+
|
456
|
+
|
457
|
+
class ActionHelpBuilder < HelpBuilder
|
458
|
+
|
459
|
+
def initialize(action_metadata)
|
460
|
+
@am = action_metadata
|
461
|
+
end
|
462
|
+
|
463
|
+
def build_help_message(command, all=false)
|
464
|
+
sb = []
|
465
|
+
sb << build_preamble(command, all)
|
466
|
+
sb << build_usage(command, all)
|
467
|
+
sb << build_options(command, all)
|
468
|
+
sb << build_postamble(command, all)
|
469
|
+
return sb.reject {|x| x.nil? || x.empty? }.join("\n")
|
470
|
+
end
|
471
|
+
|
472
|
+
protected
|
473
|
+
|
474
|
+
def build_preamble(command, all=false)
|
475
|
+
#; [!pqoup] adds detail text into help if specified.
|
476
|
+
sb = []
|
477
|
+
sb << "#{command} #{@am.name} -- #{@am.desc}\n"
|
478
|
+
if @am.detail
|
479
|
+
sb << "\n"
|
480
|
+
sb << @am.detail
|
481
|
+
sb << "\n" unless @am.detail.end_with?("\n")
|
482
|
+
end
|
483
|
+
return sb.join()
|
484
|
+
end
|
485
|
+
|
486
|
+
def build_usage(command, all=false)
|
487
|
+
config = $cmdapp_config
|
488
|
+
format = config ? config.format_usage : Config::FORMAT_USAGE
|
489
|
+
#; [!zbc4y] adds '[<options>]' into 'Usage:' section only when any options exist.
|
490
|
+
#; [!8b02e] ignores '[<options>]' in 'Usage:' when only hidden options speicified.
|
491
|
+
#; [!ou3md] not add extra whiespace when no arguments of command.
|
492
|
+
s = build_argstr().strip()
|
493
|
+
s = "[<options>] " + s unless Util.schema_empty?(@am.schema, all)
|
494
|
+
s = s.rstrip()
|
495
|
+
sb = []
|
496
|
+
sb << (format % ["#{command} #{@am.name}", s]) << "\n"
|
497
|
+
return build_section("Usage", sb.join(), nil)
|
498
|
+
end
|
499
|
+
|
500
|
+
def build_options(command, all=false)
|
501
|
+
config = $cmdapp_config
|
502
|
+
format = config ? config.format_help : Config::FORMAT_HELP
|
503
|
+
format += "\n"
|
504
|
+
#; [!g2ju5] adds 'Options:' section.
|
505
|
+
sb = []; width = nil; indent = nil
|
506
|
+
@am.schema.each do |item|
|
507
|
+
#; [!hghuj] ignores 'Options:' section when only hidden options speicified.
|
508
|
+
next unless all || ! item.hidden?
|
509
|
+
#; [!vqqq1] hidden option should be shown in weak format.
|
510
|
+
important = item.hidden? ? false : nil
|
511
|
+
sb << Util.format_help_line(format, item.optdef, item.desc, important)
|
512
|
+
#; [!dukm7] includes detailed description of option.
|
513
|
+
if item.detail
|
514
|
+
width ||= (Util.del_escape_seq(format % ["", ""])).length
|
515
|
+
indent ||= " " * (width - 1) # `-1` means "\n"
|
516
|
+
sb << item.detail.gsub(/^/, indent)
|
517
|
+
sb << "\n" unless item.detail.end_with?("\n")
|
518
|
+
end
|
519
|
+
end
|
520
|
+
#; [!pvu56] ignores 'Options:' section when no options exist.
|
521
|
+
return nil if sb.empty?
|
522
|
+
return build_section("Options", sb.join(), nil)
|
523
|
+
end
|
524
|
+
|
525
|
+
def build_postamble(command, all=false)
|
526
|
+
#; [!0p2gt] adds postamble text if specified.
|
527
|
+
s = @am.postamble
|
528
|
+
if s
|
529
|
+
#; [!v5567] adds '\n' at end of preamble text if it doesn't end with '\n'.
|
530
|
+
s += "\n" unless s.end_with?("\n")
|
531
|
+
end
|
532
|
+
return s
|
533
|
+
end
|
534
|
+
|
535
|
+
def config()
|
536
|
+
return @cmdapp_config
|
537
|
+
end
|
538
|
+
|
539
|
+
private
|
540
|
+
|
541
|
+
def build_argstr()
|
542
|
+
#; [!x0z89] required arg is represented as '<arg>'.
|
543
|
+
#; [!md7ly] optional arg is represented as '[<arg>]'.
|
544
|
+
#; [!xugkz] variable args are represented as '[<arg>...]'.
|
545
|
+
method_obj = @am.klass.instance_method(@am.method)
|
546
|
+
sb = []; n = 0
|
547
|
+
method_obj.parameters.each do |kind, param|
|
548
|
+
arg = param2arg(param)
|
549
|
+
case kind
|
550
|
+
when :req ; sb << " <#{arg}>"
|
551
|
+
when :opt ; sb << " [<#{arg}>" ; n += 1
|
552
|
+
when :rest ; sb << " [<#{arg}>..." ; n += 1
|
553
|
+
when :key ; nil
|
554
|
+
when :keyrest ; nil
|
555
|
+
else ; nil
|
556
|
+
end
|
557
|
+
end
|
558
|
+
sb << ("]" * n)
|
559
|
+
return sb.join()
|
560
|
+
end
|
561
|
+
|
562
|
+
def param2arg(param)
|
563
|
+
#; [!eou4h] converts arg name 'xx_or_yy_or_zz' into 'xx|yy|zz'.
|
564
|
+
#; [!naoft] converts arg name '_xx_yy_zz' into '_xx-yy-zz'.
|
565
|
+
s = param.to_s
|
566
|
+
s = s.gsub(/_or_/, '|') # ex: 'file_or_dir' => 'file|dir'
|
567
|
+
s = s.gsub(/(?<=\w)_/, '-') # ex: 'aa_bb_cc' => 'aa-bb-cc'
|
568
|
+
return s
|
569
|
+
end
|
570
|
+
|
571
|
+
end
|
572
|
+
|
573
|
+
|
574
|
+
ACTION_HELP_BUILDER_CLASS = ActionHelpBuilder
|
575
|
+
|
576
|
+
|
577
|
+
class ActionScope
|
578
|
+
|
579
|
+
def run_action_once(action_name, *args, **kwargs)
|
580
|
+
#; [!oh8dc] don't invoke action if already invoked.
|
581
|
+
return __run_action(action_name, true, args, kwargs)
|
582
|
+
end
|
583
|
+
|
584
|
+
def run_action!(action_name, *args, **kwargs)
|
585
|
+
#; [!2yrc2] invokes action even if already invoked.
|
586
|
+
return __run_action(action_name, false, args, kwargs)
|
587
|
+
end
|
588
|
+
|
589
|
+
private
|
590
|
+
|
591
|
+
def __run_action(action_name, once, args, kwargs)
|
592
|
+
#; [!lbp9r] invokes action name with prefix if prefix defined.
|
593
|
+
#; [!7vszf] raises error if action specified not found.
|
594
|
+
prefix = self.class.instance_variable_get('@__prefix__')
|
595
|
+
metadata = INDEX.lookup_action("#{prefix}#{action_name}") || \
|
596
|
+
INDEX.lookup_action(action_name) or
|
597
|
+
raise ActionNotFoundError.new("#{action_name}: Action not found.")
|
598
|
+
name = metadata.name
|
599
|
+
#; [!u8mit] raises error if action flow is looped.
|
600
|
+
! INDEX.action_doing?(name) or
|
601
|
+
raise LoopedActionError.new("#{name}: Action loop detected.")
|
602
|
+
#; [!vhdo9] don't invoke action twice if 'once' arg is true.
|
603
|
+
if INDEX.action_done?(name)
|
604
|
+
return INDEX.action_result(name) if once
|
605
|
+
end
|
606
|
+
#; [!r8fbn] invokes action.
|
607
|
+
INDEX.action_doing(name)
|
608
|
+
ret = metadata.run_action(*args, **kwargs)
|
609
|
+
INDEX.action_done(name, ret)
|
610
|
+
return ret
|
611
|
+
end
|
612
|
+
|
613
|
+
def self.prefix(str, alias_of: nil, action: nil)
|
614
|
+
#; [!1gwyv] converts symbol into string.
|
615
|
+
str = str.to_s
|
616
|
+
#; [!pz46w] error if prefix contains extra '_'.
|
617
|
+
str =~ /\A\w[-a-zA-Z0-9]*(:\w[-a-zA-Z0-9]*)*\z/ or
|
618
|
+
raise ActionDefError.new("#{str}: Invalid prefix name (please use ':' or '-' instead of '_' as word separator).")
|
619
|
+
#; [!9pu01] adds ':' at end of prefix name if prefix not end with ':'.
|
620
|
+
str += ':' unless str.end_with?(':')
|
621
|
+
@__prefix__ = str
|
622
|
+
@__aliasof__ = alias_of # method name if symbol, or action name if string
|
623
|
+
@__default__ = action # method name if symbol, or action name if string
|
624
|
+
end
|
625
|
+
|
626
|
+
SUBCLASSES = []
|
627
|
+
|
628
|
+
def self.inherited(subclass)
|
629
|
+
#; [!f826w] registers all subclasses into 'ActionScope::SUBCLASSES'.
|
630
|
+
SUBCLASSES << subclass
|
631
|
+
#; [!2imrb] sets class instance variables in subclass.
|
632
|
+
subclass.instance_eval do
|
633
|
+
@__action__ = nil # ex: ["action desc", {detail: nil, postamble: nil}]
|
634
|
+
@__option__ = nil # Benry::CmdOpt::Schema object
|
635
|
+
@__prefix__ = nil # ex: "foo:bar:"
|
636
|
+
@__aliasof__ = nil # ex: :method_name or "action-name"
|
637
|
+
@__default__ = nil # ex: :method_name or "action-name"
|
638
|
+
#; [!1qv12] @action is a Proc object and saves args.
|
639
|
+
@action = proc do |desc, detail: nil, postamble: nil, important: nil, tag: nil|
|
640
|
+
@__action__ = [desc, {detail: detail, postamble: postamble, important: important, tag: tag}]
|
641
|
+
end
|
642
|
+
#; [!33ma7] @option is a Proc object and saves args.
|
643
|
+
@option = proc do |param, optdef, desc, *rest, type: nil, rexp: nil, enum: nil, range: nil, value: nil, detail: nil, tag: nil, &block|
|
644
|
+
#; [!gxybo] '@option.()' raises error when '@action.()' not called.
|
645
|
+
@__action__ != nil or
|
646
|
+
raise OptionDefError.new("@option.(#{param.inspect}): `@action.()` Required but not called.")
|
647
|
+
schema = (@__option__ ||= SCHEMA_CLASS.new)
|
648
|
+
#; [!ga6zh] '@option.()' raises error when invalid option info specified.
|
649
|
+
begin
|
650
|
+
schema.add(param, optdef, desc, *rest, type: type, rexp: rexp, enum: enum, range: range, value: value, detail: detail, tag: nil, &block)
|
651
|
+
rescue Benry::CmdOpt::SchemaError => exc
|
652
|
+
raise OptionDefError.new(exc.message)
|
653
|
+
end
|
654
|
+
end
|
655
|
+
#; [!yrkxn] @copy_options is a Proc object and copies options from other action.
|
656
|
+
@copy_options = proc do |action_name, except: nil|
|
657
|
+
#; [!mhhn2] '@copy_options.()' raises error when action not found.
|
658
|
+
metadata = INDEX.get_action(action_name) or
|
659
|
+
raise OptionDefError.new("@copy_options.(#{action_name.inspect}): Action not found.")
|
660
|
+
@__option__ ||= SCHEMA_CLASS.new
|
661
|
+
@__option__.copy_from(metadata.schema, except: except)
|
662
|
+
end
|
663
|
+
end
|
664
|
+
end
|
665
|
+
|
666
|
+
def self.method_added(method)
|
667
|
+
#; [!idh1j] do nothing if '@__action__' is nil.
|
668
|
+
return unless @__action__
|
669
|
+
#; [!ernnb] clears both '@__action__' and '@__option__'.
|
670
|
+
desc, kws = @__action__
|
671
|
+
schema = @__option__ || SCHEMA_CLASS.new
|
672
|
+
@__action__ = @__option__ = nil
|
673
|
+
#; [!n8tem] creates ActionMetadata object if '@__action__' is not nil.
|
674
|
+
name = __method2action(method)
|
675
|
+
metadata = ACTION_METADATA_CLASS.new(name, self, method, desc, schema, **kws)
|
676
|
+
#; [!4pbsc] raises error if keyword param for option not exist in method.
|
677
|
+
errmsg = metadata.validate_method_params()
|
678
|
+
errmsg == nil or
|
679
|
+
raise ActionDefError.new("def #{method}(): #{errmsg}")
|
680
|
+
#; [!t8vbf] raises error if action name duplicated.
|
681
|
+
! INDEX.action_exist?(name) or
|
682
|
+
raise ActionDefError.new("def #{method}(): Action '#{name}' already exist.")
|
683
|
+
INDEX.register_action(name, metadata)
|
684
|
+
#; [!jpzbi] defines same name alias of action as prefix.
|
685
|
+
#; [!997gs] not raise error when action not found.
|
686
|
+
self.__define_alias_of_action(method, name)
|
687
|
+
end
|
688
|
+
|
689
|
+
def self.__method2action(method) # :nodoc:
|
690
|
+
#; [!5e5o0] when method name is same as default action name...
|
691
|
+
if method == @__default__ # when Symbol
|
692
|
+
#; [!myj3p] uses prefix name (expect last char ':') as action name.
|
693
|
+
@__prefix__ != nil or raise "** assertion failed"
|
694
|
+
name = @__prefix__.chomp(":")
|
695
|
+
#; [!j5oto] clears '@__default__'.
|
696
|
+
@__default__ = nil
|
697
|
+
#; [!agpwh] else...
|
698
|
+
else
|
699
|
+
#; [!3icc4] uses method name as action name.
|
700
|
+
#; [!c643b] converts action name 'aa_bb_cc_' into 'aa_bb_cc'.
|
701
|
+
#; [!3fkb3] converts action name 'aa__bb__cc' into 'aa:bb:cc'.
|
702
|
+
#; [!o9s9h] converts action name 'aa_bb:_cc_dd' into 'aa-bb:_cc-dd'.
|
703
|
+
name = Util.method2action(method.to_s)
|
704
|
+
#; [!8hlni] when action name is same as default name, uses prefix as action name.
|
705
|
+
if name == @__default__ # when String
|
706
|
+
name = @__prefix__.chomp(":")
|
707
|
+
#; [!q8oxi] clears '@__default__' when default name matched to action name.
|
708
|
+
@__default__ = nil
|
709
|
+
#; [!xfent] when prefix is provided, adds it to action name.
|
710
|
+
elsif @__prefix__
|
711
|
+
name = "#{@__prefix__}#{name}"
|
712
|
+
end
|
713
|
+
end
|
714
|
+
return name
|
715
|
+
end
|
716
|
+
|
717
|
+
def self.__define_alias_of_action(method, action_name)
|
718
|
+
return if @__aliasof__ == nil
|
719
|
+
@__prefix__ != nil or raise "** internal error"
|
720
|
+
alias_of = @__aliasof__
|
721
|
+
if alias_of == method || alias_of == Util.method2action(method.to_s)
|
722
|
+
alias_name = @__prefix__.chomp(":")
|
723
|
+
#; [!349nr] raises error when same name action or alias with prefix already exists.
|
724
|
+
Benry::CmdApp.action_alias(alias_name, action_name)
|
725
|
+
#; [!tvjb0] clears '@__aliasof__' only when alias created.
|
726
|
+
@__aliasof__ = nil
|
727
|
+
end
|
728
|
+
end
|
729
|
+
|
730
|
+
end
|
731
|
+
|
732
|
+
|
733
|
+
Action = ActionScope
|
734
|
+
|
735
|
+
|
736
|
+
class BuiltInAction < ActionScope
|
737
|
+
|
738
|
+
@action.("print help message (of action)")
|
739
|
+
@option.(:all, "-a, --all", "show private (hidden) options, too")
|
740
|
+
def help(action=nil, all: false)
|
741
|
+
action_name = action
|
742
|
+
#; [!jfgsy] prints help message of action if action name specified.
|
743
|
+
if action_name
|
744
|
+
action_metadata = INDEX.get_action(action_name) or
|
745
|
+
raise ActionNotFoundError.new("#{action}: Action not found.")
|
746
|
+
help_builder = ACTION_HELP_BUILDER_CLASS.new(action_metadata)
|
747
|
+
config = $cmdapp_config
|
748
|
+
msg = help_builder.build_help_message(config.app_command, all)
|
749
|
+
#; [!fhpjg] prints help message of command if action name not specified.
|
750
|
+
else
|
751
|
+
app = $cmdapp_application
|
752
|
+
msg = app.help_message(all)
|
753
|
+
end
|
754
|
+
#; [!6g7jh] prints colorized help message when color mode is on.
|
755
|
+
#; [!ihr5u] prints non-colorized help message when color mode is off.
|
756
|
+
msg = Util.del_escape_seq(msg) unless Util.colorize?
|
757
|
+
print msg
|
758
|
+
end
|
759
|
+
|
760
|
+
end
|
761
|
+
|
762
|
+
|
763
|
+
def self.action_alias(alias_name, action_name, *args, important: nil, tag: nil)
|
764
|
+
invocation = "action_alias(#{alias_name.inspect}, #{action_name.inspect})"
|
765
|
+
#; [!5immb] convers both alias name and action name into string.
|
766
|
+
alias_ = alias_name.to_s
|
767
|
+
action_ = action_name.to_s
|
768
|
+
#; [!nrz3d] error if action not found.
|
769
|
+
INDEX.action_exist?(action_) or
|
770
|
+
raise AliasDefError.new("#{invocation}: Action not found.")
|
771
|
+
#; [!vvmwd] error when action with same name as alias exists.
|
772
|
+
! INDEX.action_exist?(alias_) or
|
773
|
+
raise AliasDefError.new("#{invocation}: Not allowed to define same name alias as existing action.")
|
774
|
+
#; [!i9726] error if alias already defined.
|
775
|
+
! INDEX.alias_exist?(alias_) or
|
776
|
+
raise AliasDefError.new("#{invocation}: Alias name duplicated.")
|
777
|
+
#; [!vzlrb] registers alias name with action name.
|
778
|
+
#; [!0cq6o] supports args.
|
779
|
+
#; [!4wtxj] supports 'tag:' keyword arg.
|
780
|
+
INDEX.register_alias(alias_, Alias.new(alias_, action_, *args, important: important, tag: tag))
|
781
|
+
end
|
782
|
+
|
783
|
+
|
784
|
+
class Alias
|
785
|
+
|
786
|
+
def initialize(alias_name, action_name, *args, important: nil, tag: nil)
|
787
|
+
@alias_name = alias_name
|
788
|
+
@action_name = action_name
|
789
|
+
@args = args.freeze if ! args.empty?
|
790
|
+
@important = important if important != nil
|
791
|
+
@tag = tag if tag != nil
|
792
|
+
end
|
793
|
+
|
794
|
+
attr_reader :alias_name, :action_name, :args, :important, :tag
|
795
|
+
|
796
|
+
def desc()
|
797
|
+
if @args && ! @args.empty?
|
798
|
+
return "alias of '#{@action_name} #{@args.join(' ')}'"
|
799
|
+
else
|
800
|
+
return "alias of '#{@action_name}' action"
|
801
|
+
end
|
802
|
+
end
|
803
|
+
|
804
|
+
def important?()
|
805
|
+
#; [!5juwq] returns true if `@important == true`.
|
806
|
+
#; [!1gnbc] returns false if `@important == false`.
|
807
|
+
return @important if @important != nil
|
808
|
+
#; [!h3nm3] returns true or false according to action object if `@important == nil`.
|
809
|
+
action_obj = INDEX.get_action(@action_name)
|
810
|
+
return action_obj.important?
|
811
|
+
end
|
812
|
+
|
813
|
+
end
|
814
|
+
|
815
|
+
|
816
|
+
class Config #< BasicObject
|
817
|
+
|
818
|
+
#FORMAT_HELP = " %-18s : %s"
|
819
|
+
FORMAT_HELP = " \e[1m%-18s\e[0m : %s" # bold
|
820
|
+
#FORMAT_HELP = " \e[34m%-18s\e[0m : %s" # blue
|
821
|
+
|
822
|
+
FORMAT_APPNAME = "\e[1m%s\e[0m"
|
823
|
+
|
824
|
+
#FORMAT_USAGE = " $ %s %s"
|
825
|
+
FORMAT_USAGE = " $ \e[1m%s\e[0m %s" # bold
|
826
|
+
#FORMAT_USAGE = " $ \e[34m%s\e[0m %s" # blue
|
827
|
+
|
828
|
+
#FORMAT_HEADING = "%s:"
|
829
|
+
#FORMAT_HEADING = "\e[1m%s:\e[0m" # bold
|
830
|
+
#FORMAT_HEADING = "\e[1;4m%s:\e[0m" # bold, underline
|
831
|
+
FORMAT_HEADING = "\e[34m%s:\e[0m" # blue
|
832
|
+
#FORMAT_HEADING = "\e[33;4m%s:\e[0m" # yellow, underline
|
833
|
+
|
834
|
+
def initialize(app_desc, app_version=nil,
|
835
|
+
app_name: nil, app_command: nil, app_detail: nil,
|
836
|
+
default_action: "help", default_help: false,
|
837
|
+
option_help: true, option_all: false,
|
838
|
+
option_verbose: false, option_quiet: false, option_color: false,
|
839
|
+
option_debug: false, option_trace: false,
|
840
|
+
help_action: true, help_aliases: false, help_sections: [], help_postamble: nil,
|
841
|
+
format_help: nil, format_appname: nil, format_usage: nil, format_heading: nil,
|
842
|
+
feat_candidate: true)
|
843
|
+
#; [!uve4e] sets command name automatically if not provided.
|
844
|
+
@app_desc = app_desc # ex: "sample application"
|
845
|
+
@app_version = app_version # ex: "1.0.0"
|
846
|
+
@app_name = app_name || ::File.basename($0) # ex: "MyApp"
|
847
|
+
@app_command = app_command || ::File.basename($0) # ex: "myapp"
|
848
|
+
@app_detail = app_detail # ex: "See https://.... for details.\n"
|
849
|
+
@default_action = default_action # default action name
|
850
|
+
@default_help = default_help # print help message if action not specified
|
851
|
+
@option_help = option_help # '-h' and '--help' are enabled when true
|
852
|
+
@option_all = option_all # '-a' and '--all' are enable when true
|
853
|
+
@option_verbose = option_verbose # '-v' and '--verbose' are enabled when true
|
854
|
+
@option_quiet = option_quiet # '-q' and '--quiet' are enabled when true
|
855
|
+
@option_color = option_color # '--color[=<on|off>]' enabled when true
|
856
|
+
@option_debug = option_debug # '-D' and '--debug' are enabled when true
|
857
|
+
@option_trace = option_trace # '-T' and '--trace' are enabled when true
|
858
|
+
@help_action = help_action # define built-in 'help' action when true
|
859
|
+
@help_aliases = help_aliases # 'Aliases:' section printed when true
|
860
|
+
@help_sections = help_sections # ex: [["Example", "..text.."], ...]
|
861
|
+
@help_postamble = help_postamble # ex: "(Tips: ....)\n"
|
862
|
+
@format_help = format_help || FORMAT_HELP
|
863
|
+
@format_appname = format_appname || FORMAT_APPNAME
|
864
|
+
@format_usage = format_usage || FORMAT_USAGE
|
865
|
+
@format_heading = format_heading || FORMAT_HEADING
|
866
|
+
@feat_candidate = feat_candidate # if arg is 'foo:', list actions starting with 'foo:'
|
867
|
+
end
|
868
|
+
|
869
|
+
attr_accessor :app_desc, :app_version, :app_name, :app_command, :app_detail
|
870
|
+
attr_accessor :default_action, :default_help
|
871
|
+
attr_accessor :option_help, :option_all
|
872
|
+
attr_accessor :option_verbose, :option_quiet, :option_color
|
873
|
+
attr_accessor :option_debug, :option_trace
|
874
|
+
attr_accessor :help_action, :help_aliases, :help_sections, :help_postamble
|
875
|
+
attr_accessor :format_help, :format_appname, :format_usage, :format_heading
|
876
|
+
attr_accessor :feat_candidate
|
877
|
+
|
878
|
+
end
|
879
|
+
|
880
|
+
|
881
|
+
class AppOptionSchema < Benry::CmdOpt::Schema
|
882
|
+
|
883
|
+
def initialize(config=nil)
|
884
|
+
super()
|
885
|
+
#; [!3ihzx] do nothing when config is nil.
|
886
|
+
c = config
|
887
|
+
return nil if c == nil
|
888
|
+
#; [!tq2ol] adds '-h, --help' option if 'config.option_help' is set.
|
889
|
+
add(:help , "-h, --help" , "print help message") if c.option_help
|
890
|
+
#; [!mbtw0] adds '-V, --version' option if 'config.app_version' is set.
|
891
|
+
add(:version, "-V, --version", "print version") if c.app_version
|
892
|
+
#; [!f5do6] adds '-a, --all' option if 'config.option_all' is set.
|
893
|
+
add(:all , "-a, --all" , "list all actions including private (hidden) ones") if c.option_all
|
894
|
+
#; [!cracf] adds '-v, --verbose' option if 'config.option_verbose' is set.
|
895
|
+
add(:verbose, "-v, --verbose", "verbose mode") if c.option_verbose
|
896
|
+
#; [!2vil6] adds '-q, --quiet' option if 'config.option_quiet' is set.
|
897
|
+
add(:quiet , "-q, --quiet" , "quiet mode") if c.option_quiet
|
898
|
+
#; [!6zw3j] adds '--color=<on|off>' option if 'config.option_color' is set.
|
899
|
+
add(:color , "--color[=<on|off>]", "enable/disable color", type: TrueClass) if c.option_color
|
900
|
+
#; [!29wfy] adds '-D, --debug' option if 'config.option_debug' is set.
|
901
|
+
add(:debug , "-D, --debug" , "debug mode (set $DEBUG_MODE to true)") if c.option_debug
|
902
|
+
#; [!s97go] adds '-T, --trace' option if 'config.option_trace' is set.
|
903
|
+
add(:trace , "-T, --trace" , "report enter into and exit from actions") if c.option_trace
|
904
|
+
end
|
905
|
+
|
906
|
+
def sort_options_in_this_order(*keys)
|
907
|
+
#; [!6udxr] sorts options in order of keys specified.
|
908
|
+
#; [!8hhuf] options which key doesn't appear in keys are moved at end of options.
|
909
|
+
len = @items.length
|
910
|
+
@items.sort_by! {|item| keys.index(item.key) || @items.index(item) + len }
|
911
|
+
nil
|
912
|
+
end
|
913
|
+
|
914
|
+
end
|
915
|
+
|
916
|
+
|
917
|
+
APP_OPTION_SCHEMA_CLASS = AppOptionSchema
|
918
|
+
|
919
|
+
|
920
|
+
class Application
|
921
|
+
|
922
|
+
def initialize(config, schema=nil, help_builder=nil, &callback)
|
923
|
+
@config = config
|
924
|
+
#; [!h786g] acceps callback block.
|
925
|
+
@callback = callback
|
926
|
+
#; [!jkprn] creates option schema object according to config.
|
927
|
+
@schema = schema || do_create_global_option_schema(config)
|
928
|
+
@help_builder = help_builder
|
929
|
+
@global_options = nil
|
930
|
+
end
|
931
|
+
|
932
|
+
attr_reader :config, :schema, :help_builder, :callback
|
933
|
+
|
934
|
+
def main(argv=ARGV, &block)
|
935
|
+
begin
|
936
|
+
#; [!y6q9z] runs action with options.
|
937
|
+
self.run(*argv)
|
938
|
+
rescue ExecutionError, DefinitionError => exc
|
939
|
+
#; [!6ro6n] not catch error when $DEBUG_MODE is on.
|
940
|
+
raise if $DEBUG_MODE
|
941
|
+
#; [!a7d4w] prints error message with '[ERROR]' prompt.
|
942
|
+
$stderr.puts "\033[0;31m[ERROR]\033[0m #{exc.message}"
|
943
|
+
#; [!r7opi] prints filename and line number on where error raised if DefinitionError.
|
944
|
+
if exc.is_a?(DefinitionError)
|
945
|
+
#; [!v0zrf] error location can be filtered by block.
|
946
|
+
if block_given?()
|
947
|
+
loc = exc.backtrace_locations.find(&block)
|
948
|
+
else
|
949
|
+
loc = exc.backtrace_locations.find {|x| x.path != __FILE__ }
|
950
|
+
end
|
951
|
+
raise unless loc
|
952
|
+
$stderr.puts "\t(file: #{loc.path}, line: #{loc.lineno})"
|
953
|
+
end
|
954
|
+
#; [!qk5q5] returns 1 as exit code when error occurred.
|
955
|
+
return 1
|
956
|
+
else
|
957
|
+
#; [!5oypr] returns 0 as exit code when no errors occurred.
|
958
|
+
return 0
|
959
|
+
end
|
960
|
+
end
|
961
|
+
|
962
|
+
def run(*args)
|
963
|
+
#; [!t4ypg] sets $cmdapp_config at beginning.
|
964
|
+
do_setup()
|
965
|
+
#; [!pyotc] sets global options to '@global_options'.
|
966
|
+
global_opts = do_parse_global_options(args)
|
967
|
+
@global_options = global_opts
|
968
|
+
#; [!go9kk] sets global variables according to global options.
|
969
|
+
do_toggle_global_switches(args, global_opts)
|
970
|
+
#; [!pbug7] skip actions if callback method throws `:SKIP`.
|
971
|
+
skip_action = true
|
972
|
+
catch :SKIP do
|
973
|
+
#; [!5iczl] skip actions if help option or version option specified.
|
974
|
+
do_handle_global_options(args, global_opts)
|
975
|
+
#; [!w584g] calls callback method.
|
976
|
+
do_callback(args, global_opts)
|
977
|
+
skip_action = false
|
978
|
+
end
|
979
|
+
return if skip_action
|
980
|
+
#; [!avxos] prints candidate actions if action name ends with ':'.
|
981
|
+
#; [!eeh0y] candidates are not printed if 'config.feat_candidate' is false.
|
982
|
+
if ! args.empty? && args[0].end_with?(':') && @config.feat_candidate
|
983
|
+
do_print_candidates(args, global_opts)
|
984
|
+
return
|
985
|
+
end
|
986
|
+
#; [!agfdi] reports error when action not found.
|
987
|
+
#; [!o5i3w] reports error when default action not found.
|
988
|
+
#; [!n60o0] reports error when action nor default action not specified.
|
989
|
+
#; [!7h0ku] prints help if no action but 'config.default_help' is true.
|
990
|
+
#; [!l0g1l] skip actions if no action specified and 'config.default_help' is set.
|
991
|
+
metadata = do_find_action(args, global_opts)
|
992
|
+
if metadata == nil
|
993
|
+
do_print_help_message([], global_opts)
|
994
|
+
do_validate_actions(args, global_opts)
|
995
|
+
return
|
996
|
+
end
|
997
|
+
#; [!x1xgc] run action with options and arguments.
|
998
|
+
#; [!v5k56] runs default action if action not specified.
|
999
|
+
do_run_action(metadata, args, global_opts)
|
1000
|
+
rescue => exc
|
1001
|
+
raise
|
1002
|
+
ensure
|
1003
|
+
#; [!hk6iu] unsets $cmdapp_config at end.
|
1004
|
+
#; [!wv22u] calls teardown method at end of running action.
|
1005
|
+
#; [!dhba4] calls teardown method even if exception raised.
|
1006
|
+
do_teardown(exc)
|
1007
|
+
end
|
1008
|
+
|
1009
|
+
protected
|
1010
|
+
|
1011
|
+
def do_create_global_option_schema(config)
|
1012
|
+
#; [!u3zdg] creates global option schema object according to config.
|
1013
|
+
return APP_OPTION_SCHEMA_CLASS.new(config)
|
1014
|
+
end
|
1015
|
+
|
1016
|
+
def do_create_help_message_builder(config, schema)
|
1017
|
+
#; [!pk5da] creates help message builder object.
|
1018
|
+
return APP_HELP_BUILDER_CLASS.new(config, schema)
|
1019
|
+
end
|
1020
|
+
|
1021
|
+
def do_parse_global_options(args)
|
1022
|
+
#; [!5br6t] parses only global options and not parse action options.
|
1023
|
+
parser = PARSER_CLASS.new(@schema)
|
1024
|
+
global_opts = parser.parse(args, all: false)
|
1025
|
+
return global_opts
|
1026
|
+
#; [!kklah] raises InvalidOptionError if global option value is invalid.
|
1027
|
+
rescue Benry::CmdOpt::OptionError => exc
|
1028
|
+
raise InvalidOptionError.new(exc.message)
|
1029
|
+
end
|
1030
|
+
|
1031
|
+
def do_toggle_global_switches(_args, global_opts)
|
1032
|
+
#; [!j6u5x] sets $QUIET_MODE to false if '-v' or '--verbose' specified.
|
1033
|
+
#; [!p1l1i] sets $QUIET_MODE to true if '-q' or '--quiet' specified.
|
1034
|
+
#; [!2zvf9] sets $COLOR_MODE to true/false according to '--color' option.
|
1035
|
+
#; [!ywl1a] sets $DEBUG_MODE to true if '-D' or '--debug' specified.
|
1036
|
+
#; [!8trmz] sets $TRACE_MODE to true if '-T' or '--trace' specified.
|
1037
|
+
global_opts.each do |key, val|
|
1038
|
+
case key
|
1039
|
+
when :verbose ; $QUIET_MODE = ! val
|
1040
|
+
when :quiet ; $QUIET_MODE = val
|
1041
|
+
when :color ; $COLOR_MODE = val
|
1042
|
+
when :debug ; $DEBUG_MODE = val
|
1043
|
+
when :trace ; $TRACE_MODE = val
|
1044
|
+
else ; # do nothing
|
1045
|
+
end
|
1046
|
+
end
|
1047
|
+
end
|
1048
|
+
|
1049
|
+
def do_handle_global_options(args, global_opts)
|
1050
|
+
#; [!xvj6s] prints help message if '-h' or '--help' specified.
|
1051
|
+
#; [!lpoz7] prints help message of action if action name specified with help option.
|
1052
|
+
if global_opts[:help]
|
1053
|
+
do_print_help_message(args, global_opts)
|
1054
|
+
do_validate_actions(args, global_opts)
|
1055
|
+
throw :SKIP # done
|
1056
|
+
end
|
1057
|
+
#; [!fslsy] prints version if '-V' or '--version' specified.
|
1058
|
+
if global_opts[:version]
|
1059
|
+
puts @config.app_version
|
1060
|
+
throw :SKIP # done
|
1061
|
+
end
|
1062
|
+
end
|
1063
|
+
|
1064
|
+
def do_callback(args, global_opts)
|
1065
|
+
#; [!xwo0v] calls callback if provided.
|
1066
|
+
#; [!lljs1] calls callback only once.
|
1067
|
+
if @callback && ! @__called
|
1068
|
+
@__called = true
|
1069
|
+
@callback.call(args, global_opts, @config)
|
1070
|
+
end
|
1071
|
+
end
|
1072
|
+
|
1073
|
+
def do_find_action(args, _global_opts)
|
1074
|
+
c = @config
|
1075
|
+
#; [!bm8np] returns action metadata.
|
1076
|
+
if ! args.empty?
|
1077
|
+
action_name = args.shift()
|
1078
|
+
#; [!vl0zr] error when action not found.
|
1079
|
+
metadata = INDEX.lookup_action(action_name) or
|
1080
|
+
raise CommandError.new("#{action_name}: Unknown action.")
|
1081
|
+
#; [!gucj7] if no action specified, finds default action instead.
|
1082
|
+
elsif c.default_action
|
1083
|
+
action_name = c.default_action
|
1084
|
+
#; [!388rs] error when default action not found.
|
1085
|
+
metadata = INDEX.lookup_action(action_name) or
|
1086
|
+
raise CommandError.new("#{action_name}: Unknown default action.")
|
1087
|
+
#; [!drmls] returns nil if no action specified but 'config.default_help' is set.
|
1088
|
+
elsif c.default_help
|
1089
|
+
#do_print_help_message([])
|
1090
|
+
return nil
|
1091
|
+
#; [!hs589] error when action nor default action not specified.
|
1092
|
+
else
|
1093
|
+
raise CommandError.new("#{c.app_command}: Action name required (run `#{c.app_command} -h` for details).")
|
1094
|
+
end
|
1095
|
+
return metadata
|
1096
|
+
end
|
1097
|
+
|
1098
|
+
def do_run_action(metadata, args, _global_opts)
|
1099
|
+
action_name = metadata.name
|
1100
|
+
#; [!62gv9] parses action options even if specified after args.
|
1101
|
+
options = metadata.parse_options(args, true)
|
1102
|
+
#; [!6mlol] error if action requries argument but nothing specified.
|
1103
|
+
#; [!72jla] error if action requires N args but specified less than N args.
|
1104
|
+
#; [!zawxe] error if action requires N args but specified over than N args.
|
1105
|
+
#; [!y97o3] action can take any much args if action has variable arg.
|
1106
|
+
min, max = metadata.method_arity()
|
1107
|
+
n = args.length
|
1108
|
+
if n < min
|
1109
|
+
raise CommandError.new("#{action_name}: Argument required.") if n == 0
|
1110
|
+
raise CommandError.new("#{action_name}: Too less arguments (at least #{min}).")
|
1111
|
+
elsif max && max < n
|
1112
|
+
raise CommandError.new("#{action_name}: Too much arguments (at most #{max}).")
|
1113
|
+
end
|
1114
|
+
#; [!cf45e] runs action with arguments and options.
|
1115
|
+
#; [!tsal4] detects looped action.
|
1116
|
+
INDEX.action_doing(action_name)
|
1117
|
+
ret = metadata.run_action(*args, **options)
|
1118
|
+
INDEX.action_done(action_name, ret)
|
1119
|
+
return ret
|
1120
|
+
end
|
1121
|
+
|
1122
|
+
def do_print_help_message(args, global_opts)
|
1123
|
+
#; [!4qs7y] shows private (hidden) actions/options if '--all' option specified.
|
1124
|
+
#; [!l4d6n] `all` flag should be true or false, not nil.
|
1125
|
+
all = !! global_opts[:all]
|
1126
|
+
#; [!eabis] prints help message of action if action name provided.
|
1127
|
+
action_name = args[0]
|
1128
|
+
if action_name
|
1129
|
+
#; [!cgxkb] error if action for help option not found.
|
1130
|
+
metadata = INDEX.lookup_action(action_name) or
|
1131
|
+
raise CommandError.new("#{action_name}: Action not found.")
|
1132
|
+
msg = metadata.help_message(@config.app_command, all)
|
1133
|
+
#; [!nv0x3] prints help message of command if action name not provided.
|
1134
|
+
else
|
1135
|
+
msg = help_message(all)
|
1136
|
+
end
|
1137
|
+
#; [!efaws] prints colorized help message when stdout is a tty.
|
1138
|
+
#; [!9vdy1] prints non-colorized help message when stdout is not a tty.
|
1139
|
+
#; [!gsdcu] prints colorized help message when '--color[=on]' specified.
|
1140
|
+
#; [!be8y2] prints non-colorized help message when '--color=off' specified.
|
1141
|
+
msg = Util.del_escape_seq(msg) unless Util.colorize?
|
1142
|
+
puts msg
|
1143
|
+
end
|
1144
|
+
|
1145
|
+
def do_validate_actions(_args, _global_opts)
|
1146
|
+
#; [!6xhvt] reports warning at end of help message.
|
1147
|
+
nl = "\n"
|
1148
|
+
ActionScope::SUBCLASSES.each do |klass|
|
1149
|
+
#; [!iy241] reports warning if `alias_of:` specified in action class but corresponding action not exist.
|
1150
|
+
alias_of = klass.instance_variable_get(:@__aliasof__)
|
1151
|
+
if alias_of
|
1152
|
+
warn "#{nl}** [warning] in '#{klass.name}' class, `alias_of: #{alias_of.inspect}` specified but corresponding action not exist."
|
1153
|
+
nl = ""
|
1154
|
+
end
|
1155
|
+
#; [!h7lon] reports warning if `action:` specified in action class but corresponding action not exist.
|
1156
|
+
default = klass.instance_variable_get(:@__default__)
|
1157
|
+
if default
|
1158
|
+
warn "#{nl}** [warning] in '#{klass.name}' class, `action: #{default.inspect}` specified but corresponding action not exist."
|
1159
|
+
nl = ""
|
1160
|
+
end
|
1161
|
+
end
|
1162
|
+
end
|
1163
|
+
|
1164
|
+
def do_print_candidates(args, _global_opts)
|
1165
|
+
#; [!0e8vt] prints candidate action names including prefix name without tailing ':'.
|
1166
|
+
prefix = args[0]
|
1167
|
+
prefix2 = prefix.chomp(':')
|
1168
|
+
pairs = []
|
1169
|
+
aname2aliases = {}
|
1170
|
+
INDEX.each_action do |ameta|
|
1171
|
+
aname = ameta.name
|
1172
|
+
next unless aname.start_with?(prefix) || aname == prefix2
|
1173
|
+
#; [!k3lw0] private (hidden) action should not be printed as candidates.
|
1174
|
+
next if ameta.hidden?
|
1175
|
+
#
|
1176
|
+
pairs << [aname, ameta.desc, ameta.important?]
|
1177
|
+
aname2aliases[aname] = []
|
1178
|
+
end
|
1179
|
+
#; [!85i5m] candidate actions should include alias names.
|
1180
|
+
INDEX.each_alias do |ali_obj|
|
1181
|
+
ali_name = ali_obj.alias_name
|
1182
|
+
next unless ali_name.start_with?(prefix) || ali_name == prefix2
|
1183
|
+
pairs << [ali_name, ali_obj.desc(), ali_obj.important?]
|
1184
|
+
end
|
1185
|
+
#; [!i2azi] raises error when no candidate actions found.
|
1186
|
+
! pairs.empty? or
|
1187
|
+
raise CommandError.new("No actions starting with '#{prefix}'.")
|
1188
|
+
INDEX.each_alias do |alias_obj|
|
1189
|
+
alias_ = alias_obj.alias_name
|
1190
|
+
action_ = alias_obj.action_name
|
1191
|
+
aname2aliases[action_] << alias_ if aname2aliases.key?(action_)
|
1192
|
+
end
|
1193
|
+
sb = []
|
1194
|
+
sb << @config.format_heading % "Actions" << "\n"
|
1195
|
+
format = @config.format_help
|
1196
|
+
indent = " " * (Util.del_escape_seq(format) % ['', '']).length
|
1197
|
+
pairs.sort_by {|aname, _, _| aname }.each do |aname, adesc, important|
|
1198
|
+
#; [!j4b54] shows candidates in strong format if important.
|
1199
|
+
#; [!q3819] shows candidates in weak format if not important.
|
1200
|
+
sb << Util.format_help_line(format, aname, adesc, important) << "\n"
|
1201
|
+
aliases = aname2aliases[aname]
|
1202
|
+
if aliases && ! aliases.empty?
|
1203
|
+
sb << indent << "(alias: " << aliases.join(", ") << ")\n"
|
1204
|
+
end
|
1205
|
+
end
|
1206
|
+
s = sb.join()
|
1207
|
+
s = Util.del_escape_seq(s) unless Util.colorize?
|
1208
|
+
puts s
|
1209
|
+
end
|
1210
|
+
|
1211
|
+
def do_setup()
|
1212
|
+
#; [!pkio4] sets config object to '$cmdapp_config'.
|
1213
|
+
$cmdapp_config = @config
|
1214
|
+
#; [!qwjjv] sets application object to '$cmdapp_application'.
|
1215
|
+
$cmdapp_application = self
|
1216
|
+
#; [!kqfn1] remove built-in 'help' action if `config.help_action == false`.
|
1217
|
+
if ! @config.help_action
|
1218
|
+
INDEX.delete_action("help") if INDEX.action_exist?("help")
|
1219
|
+
end
|
1220
|
+
end
|
1221
|
+
|
1222
|
+
def do_teardown(exc)
|
1223
|
+
#; [!zxeo7] clears '$cmdapp_config'.
|
1224
|
+
$cmdapp_config = nil
|
1225
|
+
#; [!ufm1d] clears '$cmdapp_application'.
|
1226
|
+
$cmdapp_application = nil
|
1227
|
+
end
|
1228
|
+
|
1229
|
+
public
|
1230
|
+
|
1231
|
+
def help_message(all=false, format=nil)
|
1232
|
+
#; [!owg9y] returns help message.
|
1233
|
+
@help_builder ||= do_create_help_message_builder(@config, @schema)
|
1234
|
+
return @help_builder.build_help_message(all, format)
|
1235
|
+
end
|
1236
|
+
|
1237
|
+
end
|
1238
|
+
|
1239
|
+
|
1240
|
+
class AppHelpBuilder < HelpBuilder
|
1241
|
+
|
1242
|
+
def initialize(config, schema)
|
1243
|
+
@config = config
|
1244
|
+
@schema = schema
|
1245
|
+
end
|
1246
|
+
|
1247
|
+
def build_help_message(all=false, format=nil)
|
1248
|
+
#; [!rvpdb] returns help message.
|
1249
|
+
format ||= @config.format_help
|
1250
|
+
sb = []
|
1251
|
+
sb << build_preamble(all)
|
1252
|
+
sb << build_usage(all)
|
1253
|
+
sb << build_options(all, format)
|
1254
|
+
sb << build_actions(all, format)
|
1255
|
+
#; [!oxpda] prints 'Aliases:' section only when 'config.help_aliases' is true.
|
1256
|
+
sb << build_aliases(all, format) if @config.help_aliases
|
1257
|
+
@config.help_sections.each do |title, content, desc|
|
1258
|
+
#; [!kqnxl] array of section may have two or three elements.
|
1259
|
+
sb << build_section(title, content, desc)
|
1260
|
+
end if @config.help_sections
|
1261
|
+
sb << build_postamble(all)
|
1262
|
+
return sb.reject {|s| s.nil? || s.empty? }.join("\n")
|
1263
|
+
end
|
1264
|
+
|
1265
|
+
protected
|
1266
|
+
|
1267
|
+
def build_preamble(all=false)
|
1268
|
+
#; [!34y8e] includes application name specified by config.
|
1269
|
+
#; [!744lx] includes application description specified by config.
|
1270
|
+
#; [!d1xz4] includes version number if specified by config.
|
1271
|
+
c = @config
|
1272
|
+
sb = []
|
1273
|
+
if c.app_desc
|
1274
|
+
app_name = c.format_appname % c.app_name
|
1275
|
+
ver = c.app_version ? " (#{c.app_version})" : ""
|
1276
|
+
sb << "#{app_name}#{ver} -- #{c.app_desc}\n"
|
1277
|
+
end
|
1278
|
+
#; [!775jb] includes detail text if specified by config.
|
1279
|
+
#; [!t3tbi] adds '\n' before detail text only when app desc specified.
|
1280
|
+
if c.app_detail
|
1281
|
+
sb << "\n" unless sb.empty?
|
1282
|
+
sb << c.app_detail
|
1283
|
+
sb << "\n" unless c.app_detail.end_with?("\n")
|
1284
|
+
end
|
1285
|
+
#; [!rvhzd] no preamble when neigher app desc nor detail specified.
|
1286
|
+
return nil if sb.empty?
|
1287
|
+
return sb.join()
|
1288
|
+
end
|
1289
|
+
|
1290
|
+
def build_usage(all=false)
|
1291
|
+
c = @config
|
1292
|
+
format = c.format_usage + "\n"
|
1293
|
+
#; [!o176w] includes command name specified by config.
|
1294
|
+
sb = []
|
1295
|
+
sb << (format % [c.app_command, "[<options>] [<action> [<arguments>...]]"])
|
1296
|
+
return build_section("Usage", sb.join(), nil)
|
1297
|
+
end
|
1298
|
+
|
1299
|
+
def build_options(all=false, format=nil)
|
1300
|
+
format ||= @config.format_help
|
1301
|
+
format += "\n"
|
1302
|
+
#; [!in3kf] ignores private (hidden) options.
|
1303
|
+
#; [!ywarr] not ignore private (hidden) options if 'all' flag is true.
|
1304
|
+
sb = []
|
1305
|
+
@schema.each do |item|
|
1306
|
+
if all || ! item.hidden?
|
1307
|
+
#; [!p1tu9] prints option in weak format if option is hidden.
|
1308
|
+
important = item.hidden? ? false : nil
|
1309
|
+
sb << Util.format_help_line(format, item.optdef, item.desc, important)
|
1310
|
+
end
|
1311
|
+
end
|
1312
|
+
#; [!bm71g] ignores 'Options:' section if no options exist.
|
1313
|
+
return nil if sb.empty?
|
1314
|
+
#; [!proa4] includes description of global options.
|
1315
|
+
return build_section("Options", sb.join(), nil)
|
1316
|
+
end
|
1317
|
+
|
1318
|
+
def build_actions(all=false, format=nil)
|
1319
|
+
c = @config
|
1320
|
+
format ||= c.format_help
|
1321
|
+
format += "\n"
|
1322
|
+
sb = []
|
1323
|
+
#; [!df13s] includes default action name if specified by config.
|
1324
|
+
desc = c.default_action ? "(default: #{c.default_action})" : nil
|
1325
|
+
#; [!jat15] includes action names ordered by name.
|
1326
|
+
include_alias = ! @config.help_aliases
|
1327
|
+
INDEX.each_action_name_and_desc(include_alias, all: all) do |name, desc, important|
|
1328
|
+
#; [!b3l3m] not show private (hidden) action names in default.
|
1329
|
+
#; [!yigf3] shows private (hidden) action names if 'all' flag is true.
|
1330
|
+
if all || ! Util.hidden_name?(name)
|
1331
|
+
#; [!5d9mc] shows hidden action in weak format.
|
1332
|
+
#; [!awk3l] shows important action in strong format.
|
1333
|
+
#; [!9k4dv] shows unimportant action in weak fomrat.
|
1334
|
+
sb << Util.format_help_line(format, name, desc, important)
|
1335
|
+
end
|
1336
|
+
end
|
1337
|
+
return build_section("Actions", sb.join(), desc)
|
1338
|
+
end
|
1339
|
+
|
1340
|
+
def build_aliases(all=false, format=nil)
|
1341
|
+
format ||= @config.format_help
|
1342
|
+
format += "\n"
|
1343
|
+
#; [!tri8x] includes alias names in order of registration.
|
1344
|
+
sb = []
|
1345
|
+
INDEX.each_alias do |alias_obj|
|
1346
|
+
alias_name = alias_obj.alias_name
|
1347
|
+
#; [!5g72a] not show hidden alias names in default.
|
1348
|
+
#; [!ekuqm] shows all alias names including private ones if 'all' flag is true.
|
1349
|
+
if all || ! Util.hidden_name?(alias_name)
|
1350
|
+
#; [!aey2k] shows alias in strong or weak format according to action.
|
1351
|
+
sb << Util.format_help_line(format, alias_name, alias_obj.desc(), alias_obj.important?)
|
1352
|
+
end
|
1353
|
+
end
|
1354
|
+
#; [!p3oh6] now show 'Aliases:' section if no aliases defined.
|
1355
|
+
return nil if sb.empty?
|
1356
|
+
#; [!we1l8] shows 'Aliases:' section if any aliases defined.
|
1357
|
+
return build_section("Aliases", sb.join(), nil)
|
1358
|
+
end
|
1359
|
+
|
1360
|
+
def build_postamble(all=false)
|
1361
|
+
#; [!i04hh] includes postamble text if specified by config.
|
1362
|
+
s = @config.help_postamble
|
1363
|
+
if s
|
1364
|
+
#; [!ckagw] adds '\n' at end of postamble text if it doesn't end with '\n'.
|
1365
|
+
s += "\n" unless s.end_with?("\n")
|
1366
|
+
end
|
1367
|
+
return s
|
1368
|
+
end
|
1369
|
+
|
1370
|
+
end
|
1371
|
+
|
1372
|
+
|
1373
|
+
APP_HELP_BUILDER_CLASS = AppHelpBuilder
|
1374
|
+
|
1375
|
+
|
1376
|
+
end
|