benry-cli 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (7) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +428 -0
  3. data/Rakefile +108 -0
  4. data/benry-cli.gemspec +30 -0
  5. data/lib/benry/cli.rb +595 -0
  6. data/test/cli_test.rb +1025 -0
  7. metadata +79 -0
@@ -0,0 +1,30 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = 'benry-cli'
5
+ spec.version = '$Release: 0.1.0 $'.split()[1]
6
+ spec.author = 'kwatch'
7
+ spec.email = 'kwatch@gmail.com'
8
+ spec.platform = Gem::Platform::RUBY
9
+ spec.homepage = 'https://github.com/kwatch/benry/tree/ruby/benry-cli'
10
+ spec.summary = "MVC-like framework for command-line application"
11
+ spec.description = <<-'END'
12
+ MVC-like framework for command-line application such as Git or SVN.
13
+ END
14
+ spec.license = 'MIT'
15
+ spec.files = Dir[
16
+ 'README.md', 'CHANGES.md', 'MIT-LICENSE',
17
+ 'Rakefile', 'benry-cli.gemspec',
18
+ 'bin/*',
19
+ 'lib/**/*.rb',
20
+ 'test/**/*.rb',
21
+ ]
22
+ #spec.executables = ['benry-cli']
23
+ spec.bindir = 'bin'
24
+ spec.require_path = 'lib'
25
+ spec.test_files = Dir['test/**/*_test.rb']
26
+ #spec.extra_rdoc_files = ['README.rdoc', 'CHANGES.md']
27
+
28
+ spec.add_development_dependency 'minitest' , '~> 5.8'
29
+ spec.add_development_dependency 'minitest-ok' , '~> 0.2'
30
+ end
@@ -0,0 +1,595 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ ###
4
+ ### $Release: 0.1.0 $
5
+ ### $Copyright: copyright(c) 2016 kuwata-lab.com all rights reserved $
6
+ ### $License: MIT License $
7
+ ###
8
+
9
+
10
+ module Benry
11
+ end
12
+
13
+
14
+ module Benry::CLI
15
+
16
+
17
+ class OptionDefinitionError < StandardError
18
+ end
19
+
20
+
21
+ class OptionError < StandardError
22
+ end
23
+
24
+
25
+ class OptionSchema
26
+
27
+ def initialize(name, short, long, argname, argflag, desc, &callback)
28
+ @name = name
29
+ @short = short
30
+ @long = long
31
+ @argname = argname
32
+ @argflag = argflag # :required, :optional, or nil
33
+ @desc = desc
34
+ @callback = callback
35
+ end
36
+
37
+ attr_reader :name, :short, :long, :argname, :argflag, :desc, :callback
38
+
39
+ def ==(other)
40
+ return (
41
+ self.class == other.class \
42
+ && @short == other.short \
43
+ && @long == other.long \
44
+ && @argname == other.argname \
45
+ && @argflag == other.argflag \
46
+ && @desc == other.desc \
47
+ && @callback == other.callback
48
+ )
49
+ end
50
+
51
+ def arg_required?
52
+ return @argflag == :required
53
+ end
54
+
55
+ def arg_optional?
56
+ return @argflag == :optional
57
+ end
58
+
59
+ def arg_nothing?
60
+ return @argflag == nil
61
+ end
62
+
63
+ def self.parse(defstr, desc, name: nil, &callback)
64
+ #; [!cy1ux] regards canonical name of '-f NAME #file' as 'file'.
65
+ defstr = defstr.strip()
66
+ defstr = defstr.sub(/\s+\#(\w+)\z/, '')
67
+ name ||= $1
68
+ #; [!fdh36] can parse '-v, --version' (short + long).
69
+ #; [!jkmee] can parse '-v' (short)
70
+ #; [!uc2en] can parse '--version' (long).
71
+ #; [!sy157] can parse '-f, --file=FILE' (short + long + required-arg).
72
+ #; [!wrjqa] can parse '-f FILE' (short + required-arg).
73
+ #; [!ip99s] can parse '--file=FILE' (long + required-arg).
74
+ #; [!9pmv8] can parse '-i, --indent[=N]' (short + long + optional-arg).
75
+ #; [!ooo42] can parse '-i[N]' (short + optional-arg).
76
+ #; [!o93c7] can parse '--indent[=N]' (long + optional-arg).
77
+ #; [!gzuhx] can parse string with extra spaces.
78
+ case defstr
79
+ when /\A-(\w),\s*--(\w[-\w]*)(?:=(\S+)|\[=(\S+)\])?\z/ ; arr = [$1, $2, $3, $4]
80
+ when /\A-(\w)(?:\s+(\S+)|\[(\S+)\])?\z/ ; arr = [$1, nil, $2, $3]
81
+ when /\A--(\w[-\w]*)(?:=(\S+)|\[=(\S+)\])?\z/ ; arr = [nil, $1, $2, $3]
82
+ else
83
+ #; [!1769n] raises error when invalid format.
84
+ raise OptionDefinitionError.new("'#{defstr}': failed to parse option definition.")
85
+ end
86
+ short, long, arg_required, arg_optional = arr
87
+ #; [!j2wgf] raises error when '-i [N]' specified.
88
+ defstr !~ /\A-\w\s+\[/ or
89
+ raise OptionDefinitionError.new("'#{defstr}': failed to parse option definition"+\
90
+ " due to extra space before '['"+\
91
+ " (should be '#{defstr.sub(/\s+/, '')}').")
92
+ #; [!6f4xx] uses long name or short name as option name when option name is not specfied.
93
+ name = name || long || short
94
+ #
95
+ argname = arg_required || arg_optional
96
+ argflag = arg_required ? :required \
97
+ : arg_optional ? :optional : nil
98
+ return self.new(name.to_s, short, long, argname, argflag, desc, &callback)
99
+ end
100
+
101
+ def option_string
102
+ #; [!pdaz3] builds option definition string.
103
+ s = ""
104
+ case
105
+ when @short && @long ; s << "-#{@short}, --#{@long}"
106
+ when @short ; s << "-#{@short}"
107
+ when @long ; s << " --#{@long}"
108
+ else
109
+ raise "unreachable"
110
+ end
111
+ #
112
+ case
113
+ when arg_required? ; s << (@long ? "=#{@argname}" : " #{@argname}")
114
+ when arg_optional? ; s << (@long ? "[=#{@argname}]" : "[#{@argname}]")
115
+ else ; nil
116
+ end
117
+ #
118
+ return s
119
+ end
120
+
121
+ end
122
+
123
+
124
+ class OptionParser
125
+
126
+ def initialize(option_schemas=[])
127
+ #; [!bflls] takes array of option schema.
128
+ @option_schemas = option_schemas.collect {|x|
129
+ case x
130
+ when OptionSchema ; x
131
+ when Array ; OptionSchema.parse(*x)
132
+ else
133
+ raise OptionDefinitionError.new("#{x.inspect}: invalid option schema.")
134
+ end
135
+ }
136
+ end
137
+
138
+ def option(symbol, defstr=nil, desc=nil, &callback)
139
+ #; [!s59ly] accepts option definition string and description.
140
+ #; [!2gfnh] recognizes first argument as option name if it is a symbol.
141
+ unless symbol.is_a?(Symbol)
142
+ symbol, defstr, desc = nil, symbol, defstr
143
+ end
144
+ @option_schemas << OptionSchema.parse(defstr, desc, name: symbol, &callback)
145
+ #; [!fv5g4] return self in order to chain method call.
146
+ self
147
+ end
148
+
149
+ def each_option_string
150
+ #; [!luro4] yields each option string and description.
151
+ @option_schemas.each do |schema|
152
+ yield schema.option_string, schema.desc
153
+ end
154
+ end
155
+
156
+ def each_option_schema(&callback)
157
+ #; [!ycgdm] yields each option schema.
158
+ @option_schemas.each(&callback)
159
+ end
160
+
161
+ def err(msg)
162
+ OptionError.new(msg)
163
+ end
164
+
165
+ def parse(args)
166
+ #; [!5jfhv] returns command-line options as hash object.
167
+ #; [!06iq3] removes command-line options from args.
168
+ #; [!j2fda] stops command-line parsing when '-' found in args.
169
+ option_values = {}
170
+ while args[0] && args[0].start_with?('-') && args[0] != '-'
171
+ argstr = args.shift
172
+ #; [!31h46] stops parsing when '--' appears in args.
173
+ if argstr == '--'
174
+ break
175
+ #; [!w5dpy] can parse long options.
176
+ elsif argstr.start_with?('--')
177
+ parse_long_option(argstr, option_values)
178
+ #; [!mov8e] can parse short options.
179
+ else
180
+ parse_short_option(args, argstr, option_values)
181
+ end
182
+ end
183
+ return option_values
184
+ end
185
+
186
+ private
187
+
188
+ def parse_long_option(argstr, option_values)
189
+ argstr =~ /\A--(\w[-\w]*)(?:=(.*))?\z/
190
+ long, val = $1, $2
191
+ #; [!w67gl] raises error when long option is unknown.
192
+ opt = @option_schemas.find {|x| x.long == long } or
193
+ raise err("--#{long}: unknown option.")
194
+ #; [!kyd1j] raises error when required argument of long option is missing.
195
+ if opt.arg_required?
196
+ val or
197
+ raise err("#{argstr}: argument required.")
198
+ #; [!wuyrh] uses true as default value of optional argument of long option.
199
+ elsif opt.arg_optional?
200
+ val ||= true
201
+ #; [!91b2j] raises error when long option takes no argument but specified.
202
+ else
203
+ val.nil? or
204
+ raise err("#{argstr}: unexpected argument.")
205
+ val = true
206
+ end
207
+ #; [!9td8b] invokes callback with long option value if callback exists.
208
+ #; [!1hak2] invokes callback with long option values as 2nd argument.
209
+ begin
210
+ if (pr = opt.callback)
211
+ val = pr.arity == 2 ? pr.call(val, option_values) : pr.call(val)
212
+ end
213
+ rescue => ex
214
+ #; [!nkqln] regards RuntimeError callback raised as long option error.
215
+ raise unless ex.class == RuntimeError
216
+ raise err("#{argstr}: #{ex.message}")
217
+ end
218
+ #
219
+ option_values[opt.name] = val
220
+ end
221
+
222
+ def parse_short_option(args, argstr, option_values)
223
+ n = argstr.length
224
+ i = 0
225
+ while (i += 1) < n
226
+ char = argstr[i]
227
+ #; [!wr58v] raises error when unknown short option specified.
228
+ opt = @option_schemas.find {|x| x.short == char } or
229
+ raise err("-#{char}: unknown option.")
230
+ #; [!jzdcr] raises error when requried argument of short option is missing.
231
+ if opt.arg_required?
232
+ val = argstr[(i+1)..-1]
233
+ val = args.shift if val.empty?
234
+ val or
235
+ raise err("-#{char}: argument required.")
236
+ i = n
237
+ #; [!hnki9] uses true as default value of optional argument of short option.
238
+ elsif opt.arg_optional?
239
+ val = argstr[(i+1)..-1]
240
+ val = true if val.empty?
241
+ i = n
242
+ #; [!8gj65] uses true as value of short option which takes no argument.
243
+ else
244
+ val = true
245
+ end
246
+ #; [!l6gss] invokes callback with short option value if exists.
247
+ #; [!g4pld] invokes callback with short option values as 2nd argument.
248
+ begin
249
+ if (pr = opt.callback)
250
+ val = pr.arity == 2 ? pr.call(val, option_values) : pr.call(val)
251
+ end
252
+ rescue => ex
253
+ #; [!d4mgr] regards RuntimeError callback raised as short option error.
254
+ raise unless ex.class == RuntimeError
255
+ space = opt.arg_required? ? ' ' : ''
256
+ raise err("-#{char}#{space}#{val}: #{ex.message}")
257
+ end
258
+ #
259
+ option_values[opt.name] = val
260
+ end
261
+ end
262
+
263
+ end
264
+
265
+
266
+ class Action
267
+
268
+ SUBCLASSES = []
269
+
270
+ def self.inherited(subclass)
271
+ #; [!al5pr] provides @action and @option for subclass.
272
+ subclass.class_eval do
273
+ @__mappings = []
274
+ @__defining = nil
275
+ @action = proc do |action_name, desc|
276
+ option_schemas = []
277
+ option_schemas << OptionSchema.parse("-h, --help", "print help message")
278
+ method_name = nil
279
+ @__defining = [action_name, desc, option_schemas, method_name]
280
+ end
281
+ #; [!ymtsg] allows block argument to @option.
282
+ @option = proc do |symbol, defstr, desc, &callback|
283
+ #; [!v76cf] can take symbol as kwarg name.
284
+ if ! symbol.is_a?(Symbol)
285
+ defstr, desc = symbol, defstr
286
+ symbol = nil
287
+ end
288
+ #; [!di9na] raises error when @option.() called without @action.().
289
+ @__defining or
290
+ raise OptionDefinitionError.new("@option.(#{defstr.inspect}): @action.() should be called prior to @option.().")
291
+ option_schemas = @__defining[2]
292
+ option_schemas << OptionSchema.parse(defstr, desc, name: symbol, &callback)
293
+ end
294
+ end
295
+ #; [!4otr6] registers subclass.
296
+ SUBCLASSES << subclass
297
+ end
298
+
299
+ def self.method_added(method_name)
300
+ #; [!syzvc] registers action with method.
301
+ if @__defining
302
+ @__defining[-1] = method_name
303
+ @__mappings << @__defining
304
+ #; [!m7y8p] clears current action definition.
305
+ @__defining = nil
306
+ end
307
+ end
308
+
309
+ def self.prefix
310
+ return @prefix
311
+ end
312
+
313
+ def self.prefix=(prefix)
314
+ @prefix = prefix
315
+ end
316
+
317
+ end
318
+
319
+
320
+ class ActionInfo
321
+
322
+ def initialize(full_name, name, desc, option_schemas, action_class, action_method)
323
+ @full_name = full_name
324
+ @name = name
325
+ @desc = desc
326
+ @option_schemas = option_schemas
327
+ @action_class = action_class
328
+ @action_method = action_method
329
+ end
330
+
331
+ attr_reader :full_name, :name, :desc, :option_schemas, :action_class, :action_method
332
+
333
+ def ==(other)
334
+ return (
335
+ self.class == other.class \
336
+ && @full_name == other.full_name \
337
+ && @name == other.name \
338
+ && @desc == other.desc \
339
+ && @option_schemas == other.option_schemas \
340
+ && @action_class == other.action_class \
341
+ && @action_method == other.action_method
342
+ )
343
+ end
344
+
345
+ def help_message(command)
346
+ #; [!hjq5l] builds help message.
347
+ meth = @action_class.new.method(@action_method)
348
+ argstr = ""
349
+ meth.parameters.each do |kind, name|
350
+ #; [!7qmnz] replaces '_' in arg names with '-'.
351
+ #; [!s6p09] converts arg name 'file_or_dir' into 'file|dir'.
352
+ name_str = name.to_s.gsub('_or_', '|').gsub('_', '-')
353
+ case kind
354
+ when :req ; argstr << " <#{name_str}>"
355
+ when :opt ; argstr << " [<#{name_str}>]"
356
+ when :rest; argstr << " [<#{name_str}>...]"
357
+ end
358
+ end
359
+ #; [!6m50d] don't show non-described options.
360
+ pairs = @option_schemas.collect {|opt| [opt.option_string, opt.desc] }
361
+ pairs = pairs.select {|optstr, desc| desc }
362
+ #
363
+ width = pairs.collect {|pair| pair[0].length }.max || 0
364
+ width = [width, 20].max
365
+ width = [width, 35].min
366
+ #
367
+ msg = ""
368
+ #msg << "#{command} #{@full_name} -- #{@desc}\n"
369
+ msg << "#{@desc}\n"
370
+ msg << "\n"
371
+ msg << "Usage:\n"
372
+ msg << " #{command} #{@full_name} [<options>]#{argstr}\n"
373
+ msg << "\n" unless pairs.empty?
374
+ msg << "Options:\n" unless pairs.empty?
375
+ pairs.each do |option_string, desc|
376
+ msg << " %-#{width}s : %s\n" % [option_string, desc]
377
+ end
378
+ return msg
379
+ end
380
+
381
+ end
382
+
383
+
384
+ class Application
385
+
386
+ def self._setup_app_class(klass) # :nodoc:
387
+ klass.class_eval do
388
+ #; [!8swia] global option '-h' and '--help' are enabled by default.
389
+ #; [!vh08n] global option '--version' is enabled by defaut.
390
+ @_global_option_schemas = [
391
+ OptionSchema.parse("-h, --help", "print help message"),
392
+ OptionSchema.parse(" --version", "print version"),
393
+ ]
394
+ #; [!b09pv] provides @global_option in subclass.
395
+ @global_option = proc do |defstr, desc, &callback|
396
+ @_global_option_schemas << OptionSchema.parse(defstr, desc, &callback)
397
+ end
398
+ end
399
+ end
400
+
401
+ self._setup_app_class(self)
402
+
403
+ def self.inherited(subclass) # :nodoc:
404
+ self._setup_app_class(subclass)
405
+ end
406
+
407
+ def initialize(desc=nil, version: '0.0', script_name: nil, action_classes: nil)
408
+ @desc = desc
409
+ @version = version
410
+ @script_name = script_name || File.basename($0)
411
+ @action_dict = accept(action_classes || Action::SUBCLASSES)
412
+ end
413
+
414
+ attr_reader :desc, :version, :script_name
415
+
416
+ private
417
+
418
+ def accept(action_classes)
419
+ #; [!ue26k] builds action dictionary.
420
+ action_dict = {}
421
+ action_classes.each do |klass|
422
+ prefix = klass.instance_variable_get('@prefix')
423
+ (klass.instance_variable_get('@__mappings') || []).each do |tuple|
424
+ action_name, desc, option_schemas, method_name = tuple
425
+ action_name ||= method_name
426
+ full_name = prefix ? "#{prefix}:#{action_name}" : action_name.to_s
427
+ #; [!x6rh1] registers action name replacing '_' with '-'.
428
+ full_name = full_name.gsub('_', '-')
429
+ #
430
+ action_dict[full_name] = ActionInfo.new(full_name, action_name, desc,
431
+ option_schemas, klass, method_name)
432
+ end
433
+ end
434
+ return action_dict
435
+ end
436
+
437
+ public
438
+
439
+ def run(*args)
440
+ ## global options
441
+ gopt_values = parse_global_options(args)
442
+ output = handle_global_options(args, gopt_values)
443
+ return output if output
444
+ ## global help
445
+ #; [!p5pr6] returns global help message when action is 'help'.
446
+ #; [!3hyvi] returns help message of action when action is 'help' with action name.
447
+ action_full_name = args.shift || "help"
448
+ if action_full_name == "help"
449
+ return help_message(self.script_name, args[0])
450
+ end
451
+ ## action and options
452
+ #; [!mb92l] raises error when action name is unknown.
453
+ action_info = @action_dict[action_full_name] or
454
+ raise err("#{action_full_name}: unknown action.")
455
+ option_values = parse_options(args, action_info.option_schemas)
456
+ ## show help
457
+ #; [!13m3q] returns help message if '-h' or '--help' specified to action.
458
+ if option_values['help']
459
+ return action_info.help_message(self.script_name)
460
+ end
461
+ ## validation
462
+ obj = action_info.action_class.new()
463
+ method_name = action_info.action_method
464
+ validate_args(obj, method_name, args, action_full_name)
465
+ ## do action
466
+ ret = kick_action(obj, method_name, args, option_values)
467
+ return ret
468
+ end
469
+
470
+ def main(argv=ARGV)
471
+ begin
472
+ ret = run(*argv)
473
+ rescue OptionError => ex
474
+ $stderr.puts "ERROR: #{ex}"
475
+ exit 1
476
+ else
477
+ case ret
478
+ when String
479
+ output = ret
480
+ puts output
481
+ exit 0
482
+ when Integer
483
+ status = ret
484
+ exit status
485
+ else
486
+ exit 0
487
+ end
488
+ end
489
+ end
490
+
491
+ protected
492
+
493
+ def parse_global_options(args)
494
+ gopt_schemas = self.class.instance_variable_get('@_global_option_schemas')
495
+ gopt_values = parse_options(args, gopt_schemas)
496
+ return gopt_values
497
+ end
498
+
499
+ def handle_global_options(args, global_option_values)
500
+ g_opts = global_option_values
501
+ #; [!b8isy] returns help message when global option '-h' or '--help' is specified.
502
+ if g_opts['help']
503
+ return help_message(self.script_name, nil)
504
+ end
505
+ #; [!4irzw] returns version string when global option '--version' is specified.
506
+ if g_opts['version']
507
+ return @version || '0.0'
508
+ end
509
+ end
510
+
511
+ def validate_args(action_obj, method_name, args, action_full_name)
512
+ #; [!yhry7] raises error when required argument is missing.
513
+ meth = action_obj.method(method_name)
514
+ n_min = meth.parameters.count {|x| x[0] == :req }
515
+ args.length >= n_min or
516
+ raise err("too few arguments (at least #{n_min} args expected).\n" +\
517
+ "(run `#{@script_name} help #{action_full_name}' for details.)")
518
+ #; [!h5522] raises error when too much arguments specified.
519
+ #; [!hq8b0] not raise error when many argument specified but method has *args.
520
+ unless meth.parameters.find {|x| x[0] == :rest }
521
+ n_max = meth.parameters.count {|x| x[0] == :req || x[0] == :opt }
522
+ args.length <= n_max or
523
+ raise err("too many arguments (at most #{n_max} args expected).\n" +\
524
+ "(run `#{@script_name} help #{action_full_name}' for details.)")
525
+ end
526
+ end
527
+
528
+ def kick_action(action_obj, method_name, args, option_values)
529
+ #; [!rph9y] converts 'foo-bar' option name into :foo_bar keyword.
530
+ kwargs = Hash[option_values.map {|k, v| [k.gsub(/-/, '_').intern, v] }]
531
+ #; [!qwd9x] passes command arguments and options as method arguments and options.
532
+ method_obj = action_obj.method(method_name)
533
+ has_kwargs = method_obj.parameters.any? {|x| x[0] == :key }
534
+ if has_kwargs
535
+ return method_obj.call(*args, kwargs)
536
+ else
537
+ return method_obj.call(*args)
538
+ end
539
+ end
540
+
541
+ public
542
+
543
+ def help_message(command, action_name=nil)
544
+ if action_name
545
+ action_info = @action_dict[action_name] or
546
+ raise err("#{action_name}: no such action.")
547
+ return action_info.help_message(command)
548
+ end
549
+ #
550
+ msg = ""
551
+ #; [!1zpv4] adds command desc if it is specified at initializer.
552
+ if @desc
553
+ #msg << "#{command} -- #{@desc}\n"
554
+ #msg << "\n"
555
+ msg << @desc << "\n\n"
556
+ end
557
+ msg << "Usage:\n"
558
+ msg << " #{command} [<options>] <action> [<args>...]\n"
559
+ msg << "\n"
560
+ msg << "Options:\n"
561
+ self.class.instance_variable_get('@_global_option_schemas').each do |schema|
562
+ msg << " %-20s : %s\n" % [schema.option_string, schema.desc]
563
+ end
564
+ msg << "\n"
565
+ msg << "Actions:\n"
566
+ #msg << " %-20s : %s\n" % ["help", "show this help"]
567
+ @action_dict.keys.sort.each do |action_full_name|
568
+ action_info = @action_dict[action_full_name]
569
+ #; [!m3mry] skips action name when description is not provided.
570
+ msg << " %-20s : %s\n" % [action_full_name, action_info.desc] if action_info.desc
571
+ end
572
+ msg << "\n"
573
+ msg << "(Run `#{command} help <action>' to show help message of each action.)\n"
574
+ return msg
575
+ end
576
+
577
+ private
578
+
579
+ def parse_options(args, option_schemas)
580
+ return OptionParser.new(option_schemas).parse(args)
581
+ end
582
+
583
+ def err(msg)
584
+ return OptionError.new(msg)
585
+ end
586
+
587
+ end
588
+
589
+
590
+ def self.main(argv=nil)
591
+ Application.new.main(argv || ARGV)
592
+ end
593
+
594
+
595
+ end