clin 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.lint-ci.yml +2 -0
  3. data/.simplecov +5 -0
  4. data/.travis.yml +8 -0
  5. data/CHANGELOG.md +11 -0
  6. data/README.md +5 -4
  7. data/benchmarks/bench.rb +21 -0
  8. data/benchmarks/text_bench.rb +78 -0
  9. data/clin.gemspec +2 -1
  10. data/examples/reusable_options.rb +19 -0
  11. data/examples/simple.rb +8 -3
  12. data/examples/test.rb +5 -5
  13. data/examples/text_builder.rb +40 -0
  14. data/lib/clin/argument.rb +19 -2
  15. data/lib/clin/command_mixin/core.rb +13 -18
  16. data/lib/clin/command_mixin/options.rb +37 -26
  17. data/lib/clin/command_parser.rb +46 -57
  18. data/lib/clin/common/help_options.rb +1 -0
  19. data/lib/clin/errors.rb +50 -4
  20. data/lib/clin/line_reader/basic.rb +38 -0
  21. data/lib/clin/line_reader/readline.rb +53 -0
  22. data/lib/clin/line_reader.rb +16 -0
  23. data/lib/clin/option.rb +24 -11
  24. data/lib/clin/option_parser.rb +159 -0
  25. data/lib/clin/shell.rb +36 -15
  26. data/lib/clin/shell_interaction/choose.rb +19 -11
  27. data/lib/clin/shell_interaction/file_conflict.rb +4 -1
  28. data/lib/clin/shell_interaction/select.rb +44 -0
  29. data/lib/clin/shell_interaction.rb +1 -0
  30. data/lib/clin/text/table.rb +270 -0
  31. data/lib/clin/text.rb +152 -0
  32. data/lib/clin/version.rb +1 -1
  33. data/lib/clin.rb +10 -1
  34. data/spec/clin/command_dispacher_spec.rb +1 -1
  35. data/spec/clin/command_mixin/options_spec.rb +38 -15
  36. data/spec/clin/command_parser_spec.rb +27 -51
  37. data/spec/clin/line_reader/basic_spec.rb +54 -0
  38. data/spec/clin/line_reader/readline_spec.rb +64 -0
  39. data/spec/clin/line_reader_spec.rb +17 -0
  40. data/spec/clin/option_parser_spec.rb +217 -0
  41. data/spec/clin/option_spec.rb +5 -7
  42. data/spec/clin/shell_interaction/choose_spec.rb +30 -0
  43. data/spec/clin/shell_interaction/file_interaction_spec.rb +18 -0
  44. data/spec/clin/shell_interaction/select_spec.rb +96 -0
  45. data/spec/clin/shell_spec.rb +42 -0
  46. data/spec/clin/text/table_cell_spec.rb +72 -0
  47. data/spec/clin/text/table_row_spec.rb +74 -0
  48. data/spec/clin/text/table_separator_row_spec.rb +82 -0
  49. data/spec/clin/text/table_spec.rb +259 -0
  50. data/spec/clin/text_spec.rb +158 -0
  51. data/spec/examples/list_option_spec.rb +6 -2
  52. data/spec/examples/reusable_options_spec.rb +21 -0
  53. data/spec/examples/simple_spec.rb +9 -9
  54. data/spec/spec_helper.rb +3 -2
  55. metadata +54 -3
data/lib/clin/errors.rb CHANGED
@@ -4,16 +4,32 @@ module Clin
4
4
  Error = Class.new(RuntimeError)
5
5
 
6
6
  # Error cause by the user input(when parsing command)
7
- CommandLineError = Class.new(Error)
7
+ class CommandLineError < Error
8
+ def self.severity(value = @severity)
9
+ @severity = value
10
+ @severity ||= 1
11
+ end
12
+ end
8
13
 
9
14
  # Error when the help needs to be shown
10
- HelpError = Class.new(CommandLineError)
15
+ class HelpError < CommandLineError
16
+ def initialize(command)
17
+ if command.class == Class && command < Clin::Command
18
+ super(command.help)
19
+ @command = command
20
+ else
21
+ super(command)
22
+ end
23
+ end
24
+ end
11
25
 
12
26
  # Error when an positional argument is wrong
13
27
  ArgumentError = Class.new(CommandLineError)
14
28
 
15
29
  # Error when a fixed argument is not matched
16
- class FixedArgumentError < ArgumentError
30
+ class RequiredArgumentError < ArgumentError
31
+ severity 100
32
+
17
33
  # Create a new FixedArgumentError
18
34
  # @param argument [String] Name of the fixed argument
19
35
  # @param got [String] What argument was in place of the fixed argument
@@ -32,5 +48,35 @@ module Clin
32
48
  end
33
49
 
34
50
  # Error when a option is wrong
35
- OptionError = Class.new(CommandLineError)
51
+ class OptionError < CommandLineError
52
+ def initialize(message, option)
53
+ super(message)
54
+ @option = option
55
+ end
56
+ end
57
+
58
+ # Error when undefined options are found in argv
59
+ class UnknownOptionError < OptionError
60
+ def initialize(option)
61
+ message = "Unknown option #{option}"
62
+ super(message, option)
63
+ end
64
+ end
65
+
66
+ # Error when a flag option has an unexpected argument
67
+ class OptionUnexpectedArgumentError < OptionError
68
+ def initialize(option, value)
69
+ @value = value
70
+ message = "Unexpected argument '#{value}' for option #{option}"
71
+ super message, option
72
+ end
73
+ end
74
+
75
+ # When a option is missing it's required argument
76
+ class MissingOptionArgumentError < OptionError
77
+ def initialize(option)
78
+ message = "Missing argument for option #{option}"
79
+ super(message, option)
80
+ end
81
+ end
36
82
  end
@@ -0,0 +1,38 @@
1
+ require 'clin'
2
+ require 'clin/line_reader'
3
+ require 'io/console'
4
+
5
+ # Basic line scanner.
6
+ # Use stdin#gets
7
+ class Clin::LineReader::Basic
8
+ attr_reader :statement
9
+ attr_reader :options
10
+
11
+ def self.available?
12
+ true
13
+ end
14
+
15
+ def initialize(shell, statement, options = {})
16
+ @shell = shell
17
+ @statement = statement
18
+ @options = options
19
+ end
20
+
21
+ def readline
22
+ @shell.out.print(@statement)
23
+ scan
24
+ end
25
+
26
+ protected def scan
27
+ return @shell.in.gets if echo?
28
+ begin
29
+ @shell.in.noecho(&:gets)
30
+ rescue Errno::EBADF # If console doesn't support noecho
31
+ @shell.in.gets
32
+ end
33
+ end
34
+
35
+ protected def echo?
36
+ @options.fetch(:echo, true)
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ require 'readline'
2
+
3
+ # Readline line scanner.
4
+ # Allow autocomplete and history.
5
+ # Use Readline.readline
6
+ # Valid options:
7
+ # echo: [Boolean] Set to false not to show on screen what you type(e.g. password)
8
+ # autocomplete: List of values to autocomplete or proc that return the values
9
+ # add_to_history: [Boolean] Add the reply to the history, default: true
10
+ class Clin::LineReader::Readline < Clin::LineReader::Basic
11
+ def self.available?
12
+ Clin.use_readline?
13
+ end
14
+
15
+ def readline
16
+ if echo?
17
+ Readline.completion_append_character = nil
18
+ set_completion_proc
19
+ Readline.readline(statement, add_to_history?)
20
+ else # Use basic method to fetch
21
+ super
22
+ end
23
+ end
24
+
25
+ # Set the auto-completion process if applicable
26
+ protected def set_completion_proc
27
+ proc = completion_proc
28
+ Readline.completion_proc = proc unless proc.nil?
29
+ end
30
+
31
+ # Return nil if no completion given as option
32
+ # @return [Proc] Auto-completion process
33
+ protected def completion_proc
34
+ return nil unless autocomplete?
35
+ if autocomplete.is_a? Proc
36
+ autocomplete
37
+ else
38
+ proc { |s| autocomplete.grep(/^#{Regexp.escape(s)}/) }
39
+ end
40
+ end
41
+
42
+ protected def autocomplete
43
+ options[:autocomplete]
44
+ end
45
+
46
+ protected def autocomplete?
47
+ options.fetch(:autocomplete, false)
48
+ end
49
+
50
+ protected def add_to_history?
51
+ options.fetch(:add_to_history, true)
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ require 'clin'
2
+
3
+ # Handle to delegate the scan method to the right module.
4
+ # It will use Readline unless the disabled using Clin.use_readline = false
5
+ module Clin::LineReader
6
+ def self.scan(shell, statement, options = {})
7
+ readers.detect(&:available?).new(shell, statement, options).readline
8
+ end
9
+
10
+ def self.readers
11
+ @readers ||= [Clin::LineReader::Readline, Clin::LineReader::Basic]
12
+ end
13
+ end
14
+
15
+ require 'clin/line_reader/basic'
16
+ require 'clin/line_reader/readline'
data/lib/clin/option.rb CHANGED
@@ -53,19 +53,12 @@ class Clin::Option
53
53
  @default = default
54
54
  end
55
55
 
56
- # Register the option to the Option Parser
57
- # @param opts [OptionParser]
58
- # @param out [Hash] Out options mapping
59
- def register(opts, out)
60
- load_default(out)
56
+ def trigger(opts, out, value)
57
+ value = cast(value)
61
58
  if @block.nil?
62
- opts.on(*option_parser_arguments) do |value|
63
- on(value, out)
64
- end
59
+ on(value, out)
65
60
  else
66
- opts.on(*option_parser_arguments) do |value|
67
- block.call(opts, out, value)
68
- end
61
+ block.call(opts, out, value)
69
62
  end
70
63
  end
71
64
 
@@ -153,6 +146,10 @@ class Clin::Option
153
146
  @argument.eql? false
154
147
  end
155
148
 
149
+ def argument_optional?
150
+ @optional_argument
151
+ end
152
+
156
153
  # Init the output Hash with the default values. Must be called before parsing.
157
154
  # @param out [Hash]
158
155
  def load_default(out)
@@ -187,4 +184,20 @@ class Clin::Option
187
184
  end
188
185
  out
189
186
  end
187
+
188
+ def banner
189
+ args = [short, long_argument, description]
190
+ args.compact.join(' ')
191
+ end
192
+
193
+ def cast(str)
194
+ return str if type.nil?
195
+ if type == Integer
196
+ Integer(str)
197
+ elsif type == Float
198
+ Float(str)
199
+ else
200
+ str
201
+ end
202
+ end
190
203
  end
@@ -0,0 +1,159 @@
1
+ require 'clin'
2
+
3
+ # Class that handler the option parsing part of command parsing.
4
+ # It separate the options from the arguments
5
+ class Clin::OptionParser
6
+ LONG_OPTION_REGEX = /\A(?<name>--[^=]*)(?:=(?<value>.*))?/m
7
+ SHORT_OPTION_REGEX = /\A(?<name>-.)(?<value>(=).*|.+)?/m
8
+
9
+ # List of arguments(i.e. Argv segments that are not options)
10
+ attr_reader :arguments
11
+
12
+ # List of errors encountered
13
+ attr_reader :errors
14
+
15
+ # Parsed options are store here
16
+ attr_reader :options
17
+
18
+ # Any option skipped(if the command allow it) will be listed in here
19
+ attr_reader :skipped_options
20
+
21
+ def initialize(command, argv)
22
+ @errors = []
23
+ @command = command
24
+ @options = {}
25
+ @original_argv = argv
26
+ @argv = argv.clone
27
+ @arguments = []
28
+ @skipped_options = []
29
+ end
30
+
31
+ # Parse the argument for the command.
32
+ # @return [Hash] return the options parsed
33
+ # Options can also be accessed with #options
34
+ # ```
35
+ # # Suppose verbose and opt are defined option for the command.
36
+ # parser = OptionParser.new(command, %w(arg1 arg2 -v --opt val))
37
+ # parser.parse #=> {verbose: true, opt: 'val'}
38
+ # Get the arguments
39
+ # parser.argv # => ['arg1', 'arg2']
40
+ # ```
41
+ def parse
42
+ while parse_next
43
+ end
44
+ @options
45
+ end
46
+
47
+ # Fetch the next next argument and parse the option or store it as an argument
48
+ # @return [Boolean] true if it parsed anything, false if there are no more argument to parse
49
+ def parse_next
50
+ return false if @argv.empty?
51
+ case (arg = @argv.shift)
52
+ when LONG_OPTION_REGEX
53
+ name = Regexp.last_match[:name]
54
+ value = Regexp.last_match[:value]
55
+ parse_long(name, value)
56
+ when SHORT_OPTION_REGEX
57
+ name = Regexp.last_match[:name]
58
+ value = Regexp.last_match[:value]
59
+ parse_short(name, value)
60
+ else
61
+ @arguments << arg
62
+ end
63
+ true
64
+ end
65
+
66
+ # Parse a long option
67
+ # @param name [String] name of the option(--verbose)
68
+ # @param value [String] value of the option
69
+ # If the value is nil and the option allow argument it will try to use the next argument
70
+ def parse_long(name, value)
71
+ option = @command.find_option_by(long: name)
72
+ parse_option(option, name, value, false)
73
+ end
74
+
75
+ # Parse a long option
76
+ # @param name [String] name of the option(-v)
77
+ # @param value [String] value of the option
78
+ # If the value is nil and the option allow argument it will try to use the next argument
79
+ def parse_short(name, value)
80
+ option = @command.find_option_by(short: name)
81
+ parse_option(option, name, value, true)
82
+ end
83
+
84
+ # Parse the given option.
85
+ # @param option [Clin::Option]
86
+ # @param name [String] name it was given in the command
87
+ # @param value [String] value of the option
88
+ # If the value is nil and the option allow argument it will try to use the next argument
89
+ def parse_option(option, name, value, short)
90
+ return handle_unknown_option(name, value) if option.nil?
91
+ return parse_flag_option(option, value, short) if option.flag?
92
+
93
+ value = complete(value)
94
+ if value.nil? && !option.argument_optional?
95
+ return add_error Clin::MissingOptionArgumentError.new(option)
96
+ end
97
+ value ||= true
98
+ option.trigger(self, @options, value)
99
+ end
100
+
101
+ # Get the next possible argument in the list if the value is nil.
102
+ # @param value [String] current option value.
103
+ # Only get the next argument in the list if:
104
+ # - value is nil
105
+ # - the next argument is not an option(start with '-')
106
+ def complete(value)
107
+ if value.nil? && @argv.any? && !@argv.first.start_with?('-')
108
+ @argv.shift
109
+ else
110
+ value
111
+ end
112
+ end
113
+
114
+ # Parse a flag option(No argument)
115
+ # Add [OptionUnexpectedArgumentError] If value is defined and the long version was used.
116
+ # Short flag option can be merged together(i.e these are equivalent: -abc, -a -b -c)
117
+ # In that case the value will be 'bc'. It will then try to parse b and c as flag options.
118
+ def parse_flag_option(option, value, short)
119
+ return option.trigger(self, @options, true) if value.nil?
120
+ unless short # Short can also have the format -abc
121
+ return add_error Clin::OptionUnexpectedArgumentError.new(option, value)
122
+ end
123
+
124
+ option.trigger(self, @options, true)
125
+ # The value is expected to be other flag options
126
+ parse_compact_flag_options(value)
127
+ end
128
+
129
+ # Parse compact flag_options(e.g. For -abc it will be called with 'bc')
130
+ # @param options [String] List of options where each char should correspond to a short option
131
+ def parse_compact_flag_options(options)
132
+ options.each_char do |s|
133
+ option = @command.find_option_by(short: "-#{s}")
134
+ if option && !option.flag?
135
+ message = "Cannot combine short options that expect argument: #{option}"
136
+ add_error Clin::OptionError.new(message, option)
137
+ break
138
+ end
139
+ parse_flag_option(option, nil, true)
140
+ end
141
+ end
142
+
143
+ # Handle the case where the option was not defined in the command.
144
+ # @param name [String] name used in the command.
145
+ # @param value [String] Value of the option if applicable.
146
+ # Add [UnknownOptionError] if the command doesn't allow unknown options.
147
+ def handle_unknown_option(name, value)
148
+ unless @command.skip_options?
149
+ add_error Clin::UnknownOptionError.new(name)
150
+ return
151
+ end
152
+ value = complete(value)
153
+ @skipped_options += [name, value]
154
+ end
155
+
156
+ def add_error(err)
157
+ @errors << err
158
+ end
159
+ end
data/lib/clin/shell.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'clin'
2
+ require 'clin/line_reader'
2
3
 
3
4
  # Class the offer helper method to interact with the user using the command line
4
5
  class Clin::Shell
@@ -8,19 +9,34 @@ class Clin::Shell
8
9
  # Output stream, default: STDOUT
9
10
  attr_accessor :out
10
11
 
12
+ # Text builder instance that is used to stream
13
+ attr_accessor :text
14
+
11
15
  def initialize(input: STDIN, output: STDOUT)
12
16
  @in = input
13
17
  @out = output
14
18
  @yes_or_no_persist = false
15
19
  @override_persist = false
20
+ @text = Clin::Text.new
21
+ end
22
+
23
+ def say(line, indent: '')
24
+ @out.puts text.line(line, indent: indent)
25
+ end
26
+
27
+ # Indent the current output
28
+ def indent(indent, &block)
29
+ text.indent(indent, &block)
16
30
  end
17
31
 
18
32
  # Ask a question
19
33
  # @param statement [String]
20
34
  # @param default [String]
21
- # @param autocomplete [Array|Proc] Filter for autocomplete
22
- def ask(statement, default: nil, autocomplete: nil)
23
- answer = scan(statement, autocomplete: autocomplete)
35
+ # @param autocomplete [Array|Proc] Filter for autocomplete (Need Readline)
36
+ # @param echo [Boolean] If false no character will be displayed during input
37
+ # @param add_to_history [Boolean] If the answer should be added to history. (Need Readline)
38
+ def ask(statement, default: nil, autocomplete: nil, echo: true, add_to_history: true)
39
+ answer = scan(statement, autocomplete: autocomplete, echo: echo, add_to_history: add_to_history)
24
40
  if answer.blank?
25
41
  default
26
42
  else
@@ -28,6 +44,10 @@ class Clin::Shell
28
44
  end
29
45
  end
30
46
 
47
+ def password(statement, default: nil)
48
+ ask(statement, default: default, echo: false, add_to_history: false)
49
+ end
50
+
31
51
  # Ask a question and expect the result to be in the list of choices
32
52
  # Will continue asking until the input is correct
33
53
  # or if a default value is supplied then empty will return.
@@ -43,6 +63,17 @@ class Clin::Shell
43
63
  default: default, allow_initials: allow_initials)
44
64
  end
45
65
 
66
+ # Ask a question with a list of possible answer.
67
+ # Answer can either be selected using their name or their index
68
+ # e.g.
69
+ # Select answer:
70
+ # 1. Choice A
71
+ # 2. Choice B
72
+ # 3. Choice C
73
+ def select(statement, choices, default: nil)
74
+ Clin::ShellInteraction::Select.new(self).run(statement, choices, default: default)
75
+ end
76
+
46
77
  # Expect the user the return yes or no(y/n also works)
47
78
  # @param statement [String] Question to ask
48
79
  # @param default [String] Default value(yes/no)
@@ -93,18 +124,8 @@ class Clin::Shell
93
124
  end
94
125
 
95
126
  # Prompt the statement to the user and return his reply.
96
- # @param statement [String]
97
- # @param autocomplete [Array|Block]
98
- protected def scan(statement, autocomplete: nil)
99
- unless autocomplete.nil?
100
- Readline.completion_proc = if autocomplete.is_a? Proc
101
- autocomplete
102
- else
103
- proc { |s| autocomplete.grep(/^#{Regexp.escape(s)}/) }
104
- end
105
- end
106
- Readline.completion_append_character = nil
107
- Readline.readline(statement + ' ', true)
127
+ protected def scan(statement, options = {})
128
+ Clin::LineReader.scan(self, statement + ' ', options)
108
129
  end
109
130
  end
110
131
 
@@ -1,4 +1,5 @@
1
1
  require 'clin'
2
+ require 'clin/text'
2
3
 
3
4
  # Handle a choose question
4
5
  class Clin::ShellInteraction::Choose < Clin::ShellInteraction
@@ -41,19 +42,26 @@ class Clin::ShellInteraction::Choose < Clin::ShellInteraction
41
42
  end
42
43
  end
43
44
 
45
+ # Print help
44
46
  protected def print_choices_help(choices, allow_initials: false)
45
- puts 'Choose from:'
47
+ shell.say choice_help(choices, allow_initals: allow_initials)
48
+ end
49
+
50
+ def choice_help(choices, allow_initials: false)
46
51
  used_initials = Set.new
47
- choices.each do |choice, description|
48
- suf = choice.to_s
49
- suf += ", #{description}" unless description.blank?
50
- line = if allow_initials && !used_initials.include?(choice[0])
51
- used_initials << choice[0]
52
- " #{choice[0]} - #{suf}"
53
- else
54
- " #{suf}"
55
- end
56
- puts line
52
+ Clin::Text.new do |t|
53
+ t.line 'Choose from:'
54
+ t.table(indent: 2, border: false, separate_blank: false) do |m|
55
+ m.column_delimiter(allow_initials ? [' - ', ' '] : [' '])
56
+ choices.each do |choice, description|
57
+ if allow_initials
58
+ inital = used_initials.add?(choice[0]) ? choice[0] : nil
59
+ m.row inital, choice.to_s, description
60
+ else
61
+ m.row choice.to_s, description
62
+ end
63
+ end
64
+ end
57
65
  end
58
66
  end
59
67
  end
@@ -15,6 +15,9 @@ class Clin::ShellInteraction::FileConflict < Clin::ShellInteraction
15
15
  result
16
16
  end
17
17
 
18
+ # Handle the use choice
19
+ # @return [Boolean] true/false if the user made a choice or
20
+ # nil if the question needs to be asked again
18
21
  protected def handle_choice(choice, filename, &block)
19
22
  case choice
20
23
  when :yes
@@ -24,7 +27,7 @@ class Clin::ShellInteraction::FileConflict < Clin::ShellInteraction
24
27
  when :always
25
28
  return persist!
26
29
  when :quit
27
- puts 'Aborting...'
30
+ shell.say 'Aborting...'
28
31
  fail SystemExit
29
32
  when :diff
30
33
  show_diff(filename, block.call)
@@ -0,0 +1,44 @@
1
+ require 'clin'
2
+ require 'clin/text'
3
+
4
+ # Handle a choose question. Where you select the choices with a number
5
+ # $ Choose:
6
+ # 1. Choice A
7
+ # 2. Choice B
8
+ # 3. Choice B
9
+ #
10
+ class Clin::ShellInteraction::Select < Clin::ShellInteraction::Choose
11
+ def run(statement, choices, default: nil, start_index: 1)
12
+ choices = convert_choices(choices)
13
+ loop do
14
+ shell.say statement
15
+ shell.say choice_help(choices, start_index)
16
+ answer = @shell.ask('>', default: default, autocomplete: choices.keys)
17
+ next if answer.nil?
18
+ choice = get_choice(choices, answer, start_index)
19
+ return choice unless choice.nil?
20
+ end
21
+ end
22
+
23
+ def choice_help(choices, start_index)
24
+ Clin::Text::Table.new(border: false, col_delim: ' ') do |t|
25
+ i = start_index
26
+ choices.each do |key, description|
27
+ key = "#{key}," unless description.blank?
28
+ row = ["#{i}.", key]
29
+ row << description unless description.blank?
30
+ t.row row
31
+ i += 1
32
+ end
33
+ end
34
+ end
35
+
36
+ def get_choice(choices, answer, start_index)
37
+ i = start_index
38
+ choices.each do |choice, _|
39
+ return choice if choice.casecmp(answer) == 0 || i.to_s == answer
40
+ i += 1
41
+ end
42
+ nil
43
+ end
44
+ end
@@ -32,3 +32,4 @@ end
32
32
  require 'clin/shell_interaction/file_conflict'
33
33
  require 'clin/shell_interaction/yes_or_no'
34
34
  require 'clin/shell_interaction/choose'
35
+ require 'clin/shell_interaction/select'