clin 0.3.0 → 0.4.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 (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'