tty-option 0.0.0 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +1653 -1
  4. data/lib/tty/option.rb +63 -4
  5. data/lib/tty/option/aggregate_errors.rb +95 -0
  6. data/lib/tty/option/conversions.rb +126 -0
  7. data/lib/tty/option/converter.rb +63 -0
  8. data/lib/tty/option/deep_dup.rb +48 -0
  9. data/lib/tty/option/dsl.rb +105 -0
  10. data/lib/tty/option/dsl/arity.rb +49 -0
  11. data/lib/tty/option/dsl/conversion.rb +17 -0
  12. data/lib/tty/option/error_aggregator.rb +35 -0
  13. data/lib/tty/option/errors.rb +144 -0
  14. data/lib/tty/option/formatter.rb +389 -0
  15. data/lib/tty/option/inflection.rb +50 -0
  16. data/lib/tty/option/param_conversion.rb +34 -0
  17. data/lib/tty/option/param_permitted.rb +30 -0
  18. data/lib/tty/option/param_validation.rb +48 -0
  19. data/lib/tty/option/parameter.rb +310 -0
  20. data/lib/tty/option/parameter/argument.rb +18 -0
  21. data/lib/tty/option/parameter/environment.rb +20 -0
  22. data/lib/tty/option/parameter/keyword.rb +15 -0
  23. data/lib/tty/option/parameter/option.rb +99 -0
  24. data/lib/tty/option/parameters.rb +157 -0
  25. data/lib/tty/option/params.rb +122 -0
  26. data/lib/tty/option/parser.rb +57 -3
  27. data/lib/tty/option/parser/arguments.rb +166 -0
  28. data/lib/tty/option/parser/arity_check.rb +34 -0
  29. data/lib/tty/option/parser/environments.rb +169 -0
  30. data/lib/tty/option/parser/keywords.rb +158 -0
  31. data/lib/tty/option/parser/options.rb +273 -0
  32. data/lib/tty/option/parser/param_types.rb +51 -0
  33. data/lib/tty/option/parser/required_check.rb +36 -0
  34. data/lib/tty/option/pipeline.rb +38 -0
  35. data/lib/tty/option/result.rb +46 -0
  36. data/lib/tty/option/section.rb +26 -0
  37. data/lib/tty/option/sections.rb +56 -0
  38. data/lib/tty/option/usage.rb +166 -0
  39. data/lib/tty/option/usage_wrapper.rb +58 -0
  40. data/lib/tty/option/version.rb +3 -3
  41. metadata +37 -3
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ module Option
5
+ module DSL
6
+ module Arity
7
+ # @api public
8
+ def one
9
+ 1
10
+ end
11
+
12
+ # @api public
13
+ def two
14
+ 2
15
+ end
16
+
17
+ # Zero or more arity
18
+ #
19
+ # @api public
20
+ def zero_or_more
21
+ -1
22
+ end
23
+ alias any zero_or_more
24
+ alias any_args zero_or_more
25
+
26
+ # One or more arity
27
+ #
28
+ # @api public
29
+ def one_or_more
30
+ -2
31
+ end
32
+
33
+ # Two or more arity
34
+ #
35
+ # @api public
36
+ def two_or_more
37
+ -3
38
+ end
39
+
40
+ # At last number values for arity
41
+ #
42
+ # @api public
43
+ def at_least(number)
44
+ -number.to_i - 1
45
+ end
46
+ end # Arity
47
+ end # DSL
48
+ end # Option
49
+ end # TTY
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ module Option
5
+ module DSL
6
+ module Conversion
7
+ def map_of(type)
8
+ :"#{type}_map"
9
+ end
10
+
11
+ def list_of(type)
12
+ :"#{type}_list"
13
+ end
14
+ end # Convert
15
+ end # DSL
16
+ end # Option
17
+ end # TTY
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inflection"
4
+
5
+ module TTY
6
+ module Option
7
+ class ErrorAggregator
8
+ include Inflection
9
+
10
+ # Collected errors
11
+ attr_reader :errors
12
+
13
+ def initialize(errors = [], raise_on_parse_error: false)
14
+ @errors = errors
15
+ @raise_on_parse_error = raise_on_parse_error
16
+ end
17
+
18
+ # Record or raise an error
19
+ #
20
+ # @param [TTY::Option::Error] error
21
+ # @param [String] message
22
+ #
23
+ # @api public
24
+ def call(error, message = nil)
25
+ if error.is_a?(Class)
26
+ error = message.nil? ? error.new : error.new(message)
27
+ end
28
+
29
+ raise(error) if @raise_on_parse_error
30
+
31
+ @errors << error
32
+ end
33
+ end # ErrorAggregator
34
+ end # Option
35
+ end # TTY
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TTY
4
+ module Option
5
+ Error = Class.new(StandardError)
6
+
7
+ # Raised when a parameter invariant is invalid
8
+ ConfigurationError = Class.new(Error)
9
+
10
+ # Raised when attempting to register already registered parameter
11
+ ParameterConflict = Class.new(Error)
12
+
13
+ # Raised when overriding already defined conversion
14
+ ConversionAlreadyDefined = Class.new(Error)
15
+
16
+ # Raised when conversion cannot be performed
17
+ ConversionError = Class.new(Error)
18
+
19
+ # Raised when conversion type isn't registered
20
+ UnsupportedConversion = Class.new(Error)
21
+
22
+ # Raised during command line input parsing
23
+ class ParseError < Error
24
+ attr_accessor :param
25
+ end
26
+
27
+ # Raised when found unrecognized parameter
28
+ InvalidParameter = Class.new(ParseError)
29
+
30
+ # Raised when an option matches more than one parameter option
31
+ AmbiguousOption = Class.new(ParseError)
32
+
33
+ # Raised when parameter argument doesn't match expected value
34
+ class InvalidArgument < ParseError
35
+ MESSAGE = "value of `%<value>s` fails validation for '%<name>s' %<type>s"
36
+
37
+ def initialize(param_or_message, value = nil)
38
+ if param_or_message.is_a?(Parameter)
39
+ @param = param_or_message
40
+
41
+ message = format(MESSAGE,
42
+ value: value,
43
+ name: param.name,
44
+ type: param.to_sym)
45
+ else
46
+ message = param_or_message
47
+ end
48
+
49
+ super(message)
50
+ end
51
+ end
52
+
53
+ # Raised when number of parameter arguments doesn't match
54
+ class InvalidArity < ParseError
55
+ MESSAGE = "%<type>s '%<name>s' should appear %<expect>s but appeared %<actual>s"
56
+
57
+ def initialize(param_or_message, arity = nil)
58
+ if param_or_message.is_a?(Parameter)
59
+ @param = param_or_message
60
+ prefix = param.arity < 0 ? "at least " : ""
61
+ expected_arity = param.arity < 0 ? param.arity.abs - 1 : param.arity
62
+
63
+ message = format(MESSAGE,
64
+ type: param.to_sym,
65
+ name: param.name,
66
+ expect: prefix + pluralize("time", expected_arity),
67
+ actual: pluralize("time", arity))
68
+ else
69
+ message = param_or_message
70
+ end
71
+
72
+ super(message)
73
+ end
74
+
75
+ # Pluralize a noun
76
+ #
77
+ # @api private
78
+ def pluralize(noun, count = 1)
79
+ "#{count} #{noun}#{'s' unless count == 1}"
80
+ end
81
+ end
82
+
83
+ # Raised when conversion provided with unexpected argument
84
+ class InvalidConversionArgument < ParseError
85
+ MESSAGE = "cannot convert value of `%<value>s` into '%<cast>s' type " \
86
+ "for '%<name>s' %<type>s"
87
+
88
+ def initialize(param, value)
89
+ @param = param
90
+ message = format(MESSAGE, value: value, cast: param.convert,
91
+ name: param.name, type: param.to_sym)
92
+ super(message)
93
+ end
94
+ end
95
+
96
+ # Raised when option requires an argument
97
+ class MissingArgument < ParseError
98
+ MESSAGE = "%<type>s %<name>s requires an argument"
99
+
100
+ def initialize(param)
101
+ @param = param
102
+ message = format(MESSAGE, type: param.to_sym, name: param.name)
103
+ super(message)
104
+ end
105
+ end
106
+
107
+ # Raised when a parameter is required but not present
108
+ class MissingParameter < ParseError
109
+ MESSAGE = "%<type>s '%<name>s' must be provided"
110
+
111
+ def initialize(param_or_message)
112
+ if param_or_message.is_a?(Parameter)
113
+ @param = param_or_message
114
+ message = format(MESSAGE, name: param.name, type: param.to_sym)
115
+ else
116
+ message = param_or_message
117
+ end
118
+
119
+ super(message)
120
+ end
121
+ end
122
+
123
+ # Raised when argument value isn't permitted
124
+ class UnpermittedArgument < ParseError
125
+ MESSAGE = "unpermitted value `%<value>s` for '%<name>s' %<type>s: " \
126
+ "choose from %<choices>s"
127
+
128
+ def initialize(param_or_message, value = nil)
129
+ if param_or_message.is_a?(Parameter)
130
+ @param = param_or_message
131
+ message = format(MESSAGE,
132
+ value: value,
133
+ name: param.name,
134
+ type: param.to_sym,
135
+ choices: param.permit.join(", "))
136
+ else
137
+ message = param_or_message
138
+ end
139
+
140
+ super(message)
141
+ end
142
+ end
143
+ end # Option
144
+ end # TTY
@@ -0,0 +1,389 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sections"
4
+ require_relative "usage_wrapper"
5
+
6
+ module TTY
7
+ module Option
8
+ class Formatter
9
+ include UsageWrapper
10
+
11
+ SHORT_OPT_LENGTH = 4
12
+ DEFAULT_WIDTH = 80
13
+ NEWLINE = "\n"
14
+ ELLIPSIS = "..."
15
+ SPACE = " "
16
+
17
+ DEFAULT_PARAM_DISPLAY = ->(str) { str.to_s.upcase }
18
+ DEFAULT_ORDER = ->(params) { params.sort }
19
+ NOOP_PROC = ->(param) { param }
20
+ DEFAULT_NAME_SELECTOR = ->(param) { param.name }
21
+
22
+ # @api public
23
+ def self.help(parameters, usage, **config, &block)
24
+ new(parameters, usage, **config).help(&block)
25
+ end
26
+
27
+ attr_reader :width
28
+
29
+ # Create a help formatter
30
+ #
31
+ # @param [Parameters]
32
+ #
33
+ # @api public
34
+ def initialize(parameters, usage, param_display: DEFAULT_PARAM_DISPLAY,
35
+ width: DEFAULT_WIDTH, order: DEFAULT_ORDER, indent: 0)
36
+ @parameters = parameters
37
+ @usage = usage
38
+ @param_display = param_display
39
+ @order = order
40
+ @width = width
41
+ @indent = indent
42
+ @space_indent = SPACE * indent
43
+ @param_indent = indent + 2
44
+ @section_names = {
45
+ usage: "Usage:",
46
+ arguments: "Arguments:",
47
+ keywords: "Keywords:",
48
+ options: "Options:",
49
+ env: "Environment:",
50
+ examples: "Examples:"
51
+ }
52
+ end
53
+
54
+ # A formatted help usage information
55
+ #
56
+ # @return [String]
57
+ #
58
+ # @api public
59
+ def help(&block)
60
+ sections = Sections.new
61
+
62
+ sections.add(:header, help_header) if @usage.header?
63
+ sections.add(:banner, help_banner)
64
+ sections.add(:description, help_description) if @usage.desc?
65
+
66
+ if @parameters.arguments.any?(&:display?)
67
+ sections.add(:arguments, help_arguments)
68
+ end
69
+
70
+ if @parameters.keywords.any?(&:display?)
71
+ sections.add(:keywords, help_keywords)
72
+ end
73
+
74
+ if @parameters.options?
75
+ sections.add(:options, help_options)
76
+ end
77
+
78
+ if @parameters.environments.any?(&:display?)
79
+ sections.add(:environments, help_environments)
80
+ end
81
+
82
+ sections.add(:examples, help_examples) if @usage.example?
83
+ sections.add(:footer, help_footer) if @usage.footer?
84
+
85
+ if block_given?
86
+ yield(sections)
87
+ end
88
+
89
+ formatted = sections.reject(&:empty?).join(NEWLINE)
90
+ formatted.end_with?(NEWLINE) ? formatted : formatted + NEWLINE
91
+ end
92
+
93
+ def help_header
94
+ "#{format_multiline(@usage.header, @indent)}#{NEWLINE}"
95
+ end
96
+
97
+ def help_banner
98
+ (@usage.banner? ? @usage.banner : format_usage)
99
+ end
100
+
101
+ def help_description
102
+ "#{NEWLINE}#{format_description}"
103
+ end
104
+
105
+ def help_arguments
106
+ "#{NEWLINE}#{@space_indent}#{@section_names[:arguments]}#{NEWLINE}" +
107
+ format_section(@parameters.arguments, ->(param) do
108
+ @param_display.(param.name)
109
+ end)
110
+ end
111
+
112
+ def help_keywords
113
+ "#{NEWLINE}#{@space_indent}#{@section_names[:keywords]}#{NEWLINE}" +
114
+ format_section(@parameters.keywords, ->(param) do
115
+ kwarg_param_display(param).split("=").map(&@param_display).join("=")
116
+ end)
117
+ end
118
+
119
+ def help_options
120
+ "#{NEWLINE}#{@space_indent}#{@section_names[:options]}#{NEWLINE}" +
121
+ format_options
122
+ end
123
+
124
+ def help_environments
125
+ "#{NEWLINE}#{@space_indent}#{@section_names[:env]}#{NEWLINE}" +
126
+ format_section(@order.(@parameters.environments))
127
+ end
128
+
129
+ def help_examples
130
+ "#{NEWLINE}#{@space_indent}#{@section_names[:examples]}#{NEWLINE}" +
131
+ format_examples
132
+ end
133
+
134
+ def help_footer
135
+ "#{NEWLINE}#{format_multiline(@usage.footer, @indent)}"
136
+ end
137
+
138
+ private
139
+
140
+ # Provide a default usage banner
141
+ #
142
+ # @api private
143
+ def format_usage
144
+ usage = @space_indent + @section_names[:usage] + SPACE
145
+ output = []
146
+ output << @usage.program
147
+ output << " #{@usage.commands.join(" ")}" if @usage.command?
148
+ output << " [#{@param_display.("options")}]" if @parameters.options?
149
+ output << " [#{@param_display.("environment")}]" if @parameters.environments?
150
+ output << " #{format_arguments_usage}" if @parameters.arguments?
151
+ output << " #{format_keywords_usage}" if @parameters.keywords?
152
+ usage + wrap(output.join, indent: usage.length, width: width)
153
+ end
154
+
155
+ # Format arguments
156
+ #
157
+ # @api private
158
+ def format_arguments_usage
159
+ return "" unless @parameters.arguments?
160
+
161
+ @parameters.arguments.reduce([]) do |acc, arg|
162
+ next acc if arg.hidden?
163
+
164
+ acc << format_argument_usage(arg)
165
+ end.join(SPACE)
166
+ end
167
+
168
+ # Provide an argument summary
169
+ #
170
+ # @api private
171
+ def format_argument_usage(arg)
172
+ arg_name = @param_display.(arg.name)
173
+ format_parameter_usage(arg, arg_name)
174
+ end
175
+
176
+ # Format parameter usage
177
+ #
178
+ # @api private
179
+ def format_parameter_usage(param, param_name)
180
+ args = []
181
+ if 0 < param.arity
182
+ args << "[" if param.optional?
183
+ args << param_name
184
+ (param.arity - 1).times { args << " #{param_name}" }
185
+ args. << "]" if param.optional?
186
+ args.join
187
+ else
188
+ (param.arity.abs - 1).times { args << param_name }
189
+ args << "[#{param_name}#{ELLIPSIS}]"
190
+ args.join(SPACE)
191
+ end
192
+ end
193
+
194
+ # Format keywords usage
195
+ #
196
+ # @api private
197
+ def format_keywords_usage
198
+ return "" unless @parameters.keywords?
199
+
200
+ @parameters.keywords.reduce([]) do |acc, kwarg|
201
+ next acc if kwarg.hidden?
202
+
203
+ acc << format_keyword_usage(kwarg)
204
+ end.join(SPACE)
205
+ end
206
+
207
+ # Provide a keyword summary
208
+ #
209
+ # @api private
210
+ def format_keyword_usage(kwarg)
211
+ param_name = kwarg_param_display(kwarg, @param_display)
212
+ format_parameter_usage(kwarg, param_name)
213
+ end
214
+
215
+ # Provide a keyword argument display format
216
+ #
217
+ # @api private
218
+ def kwarg_param_display(kwarg, param_display = NOOP_PROC)
219
+ kwarg_name = param_display.(kwarg.name)
220
+ conv_name = case kwarg.convert
221
+ when Proc, NilClass
222
+ kwarg_name
223
+ else
224
+ param_display.(kwarg.convert)
225
+ end
226
+
227
+ "#{kwarg_name}=#{conv_name}"
228
+ end
229
+
230
+ # Format a parameter section in the help display
231
+ #
232
+ # @param [String] parameters_name
233
+ # the name of parameter type
234
+ #
235
+ # @param [Proc] name_selector
236
+ # selects a name from the parameter, by defeault the name
237
+ #
238
+ # @return [String]
239
+ #
240
+ # @api private
241
+ def format_section(params, name_selector = DEFAULT_NAME_SELECTOR)
242
+ longest_param = params.map(&name_selector).compact.max_by(&:length).length
243
+
244
+ params.reduce([]) do |acc, param|
245
+ next acc if param.hidden?
246
+
247
+ acc << format_section_parameter(param, longest_param, name_selector)
248
+ end.join(NEWLINE)
249
+ end
250
+
251
+ # Format a section parameter line
252
+ #
253
+ # @return [String]
254
+ #
255
+ # @api private
256
+ def format_section_parameter(param, longest_param, name_selector)
257
+ line = []
258
+ desc = []
259
+ indent = @param_indent + longest_param + 2
260
+ param_name = name_selector.(param)
261
+
262
+ if param.desc?
263
+ line << format("%s%-#{longest_param}s", SPACE * @param_indent, param_name)
264
+ desc << " #{param.desc}"
265
+ else
266
+ line << format("%s%s", SPACE * @param_indent, param_name)
267
+ end
268
+
269
+ if param.permit?
270
+ desc << format(" (permitted: %s)", param.permit.join(", "))
271
+ end
272
+
273
+ if (default = format_default(param))
274
+ desc << default
275
+ end
276
+
277
+ line << wrap(desc.join, indent: indent, width: width)
278
+ line.join
279
+ end
280
+
281
+ # Format multiline description
282
+ #
283
+ # @api private
284
+ def format_description
285
+ format_multiline(@usage.desc, @indent)
286
+ end
287
+
288
+ # Returns all the options formatted to fit 80 columns
289
+ #
290
+ # @return [String]
291
+ #
292
+ # @api private
293
+ def format_options
294
+ return "" if @parameters.options.empty?
295
+
296
+ longest_option = @parameters.options.map(&:long)
297
+ .compact.max_by(&:length).length
298
+ any_short = @parameters.options.map(&:short).compact.any?
299
+ ordered_options = @order.(@parameters.options)
300
+
301
+ ordered_options.reduce([]) do |acc, option|
302
+ next acc if option.hidden?
303
+ acc << format_option(option, longest_option, any_short)
304
+ end.join(NEWLINE)
305
+ end
306
+
307
+ # Format an option
308
+ #
309
+ # @api private
310
+ def format_option(option, longest_length, any_short)
311
+ line = [@space_indent]
312
+ desc = []
313
+ indent = @indent
314
+
315
+ if any_short
316
+ short_option = option.short? ? option.short_name : SPACE
317
+ line << format("%#{SHORT_OPT_LENGTH}s", short_option)
318
+ indent += SHORT_OPT_LENGTH
319
+ end
320
+
321
+ # short & long option separator
322
+ line << ((option.short? && option.long?) ? ", " : " ")
323
+ indent += 2
324
+
325
+ if option.long?
326
+ if option.desc?
327
+ line << format("%-#{longest_length}s", option.long)
328
+ else
329
+ line << option.long
330
+ end
331
+ else
332
+ line << format("%-#{longest_length}s", SPACE)
333
+ end
334
+ indent += longest_length
335
+
336
+ if option.desc?
337
+ desc << " #{option.desc}"
338
+ end
339
+ indent += 2
340
+
341
+ if option.permit?
342
+ desc << format(" (permitted: %s)", option.permit.join(","))
343
+ end
344
+
345
+ if (default = format_default(option))
346
+ desc << default
347
+ end
348
+
349
+ line << wrap(desc.join, indent: indent, width: width)
350
+
351
+ line.join
352
+ end
353
+
354
+ # Format default value
355
+ #
356
+ # @api private
357
+ def format_default(param)
358
+ return if !param.default? || [true, false].include?(param.default)
359
+
360
+ if param.default.is_a?(String)
361
+ format(" (default %p)", param.default)
362
+ else
363
+ format(" (default %s)", param.default)
364
+ end
365
+ end
366
+
367
+ # Format examples section
368
+ #
369
+ # @api private
370
+ def format_examples
371
+ format_multiline(@usage.example, @param_indent)
372
+ end
373
+
374
+ # Format multiline content
375
+ #
376
+ # @api private
377
+ def format_multiline(lines, indent)
378
+ last_index = lines.size - 1
379
+ lines.map.with_index do |line, i|
380
+ line.map do |part|
381
+ part.split(NEWLINE).map do |p|
382
+ wrap(p, indent: indent, width: width, indent_first: true)
383
+ end.join(NEWLINE)
384
+ end.join(NEWLINE) + (last_index != i ? NEWLINE : "")
385
+ end.join(NEWLINE)
386
+ end
387
+ end # Formatter
388
+ end # Option
389
+ end # TTY