benry-cli 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.
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