options_by_example 3.4.0 → 4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: beeaa2ae22a04cfe49102108f616bad5d3fb63b00156bb34647774ba07c8def0
4
- data.tar.gz: e2af434e1812a6244c276bea7b00b52b97411927ccc207e843f4866e64356d08
3
+ metadata.gz: 3f146461611879c6934270f692e97391830d93f634b9300f7fa2883195bc0a22
4
+ data.tar.gz: 19ad57569defbb334404085af3248675cfa84000511bbf49b2803b5c6cb38dee
5
5
  SHA512:
6
- metadata.gz: fca827e7190634ca8382ff90d074a276fd4367a609fad77b3d58a8488f85b97e3e8493fe2a717fd9a9aff3fefdde4315058c1512addaf4b1d7663f1a2dc201db
7
- data.tar.gz: a2ce56d793153540e2c9c4959dc7af5aff0e73a4c32037a738a368f06128d4f35730411f8cce8875e5e1a3c153e6e4a4344de006171ca1b70d1b24737eeaf819
6
+ metadata.gz: 01146df8d0c06df84d2c093d0493219d6bd9d78ab5174f089b6b13b79b5fbb355ebd16f8bd5e3b65358670b44f6479e032e8967e57df52935328e5f151bfb4a1
7
+ data.tar.gz: fbfe47bf367329b63e3661c28b964118c9dd47e013dabcfabdf6ceae9bdad3a2af9cd0998a852d5e3ede7a22b07146d64ae85fb61727aac54e1ced139075f918
data/README.md CHANGED
@@ -1,61 +1,48 @@
1
1
  # Options by Example
2
2
 
3
- No-code options parser that automatically detects command-line options from the usage text of your application. This intuitive parser identifies optional and required argument names as well as option names without requiring any additional code, making it easy to manage user input for your command-line applications.
3
+ No-code options parser that automatically detects command-line options from the usage text.
4
4
 
5
5
  Features
6
6
 
7
- - Automatically detects optional and required argument names from usage text
8
- - Automatically detects option names and associated arguments (if any) from usage text
7
+ - Automatically infers options and argument names from usage text
9
8
  - Parses those arguments and options from the command line (ARGV)
10
9
  - Raises errors for unknown options or missing required arguments
10
+ - Supports typed arguments, eg `--lines NUM` or `--since DATE`
11
11
 
12
- Installation
12
+ Example
13
13
 
14
- To use options_by_example, first install the gem by running:
14
+ ```ruby
15
+ require %(options_by_example)
15
16
 
16
- ```
17
- gem install options_by_example
18
- ```
17
+ flags = OptionsByExample.read(DATA).parse(ARGV)
19
18
 
20
- Alternatively, add this line to your Gemfile and run bundle install:
19
+ puts 'Feeling verbose today' if flags.include?(:verbose)
20
+ puts flags.get(:words).sample(flags.get(:num))
21
21
 
22
- ```
23
- gem 'options_by_example'
24
- ```
25
-
26
- Example
22
+ __END__
23
+ Choose at random from a list of provided words.
27
24
 
28
- ```ruby
29
- require 'options_by_example'
25
+ Usage: random.rb [options] words ...
30
26
 
31
- Options = OptionsByExample.read(DATA).parse(ARGV)
27
+ Options:
28
+ -n, --num NUM Number of choices (default 1)
29
+ --verbose Enable verbose mode
30
+ ```
32
31
 
33
- puts Options.include? :secure
34
- puts Options.include? :verbose
35
- puts Options.include? :retries
36
- puts Options.include? :timeout
37
- puts Options.get :retries
38
- puts Options.get :timeout
39
- puts Options.get :mode
40
- puts Options.get :host
41
- puts Options.get :port
32
+ And then call the program with eg
42
33
 
34
+ ruby random.rb -n 2 foo bar qux
43
35
 
44
- __END__
45
- Establishes a network connection to a designated host and port, enabling
46
- users to assess network connectivity and diagnose potential problems.
36
+ ### Installation
47
37
 
48
- Usage: connect [options] [mode] host port
38
+ To use options_by_example, first install the gem by running:
49
39
 
50
- Options:
51
- -s, --secure Establish a secure connection (SSL/TSL)
52
- -v, --verbose Enable verbose output for detailed information
53
- -r, --retries NUM Number of connection retries (default 3)
54
- -t, --timeout NUM Set connection timeout in seconds
55
-
56
- Arguments:
57
- [mode] Optional connection mode (active or passive)
58
- host The target host to connect to (e.g., example.com)
59
- port The target port to connect to (e.g., 80)
60
40
  ```
41
+ gem install options_by_example
42
+ ```
43
+
44
+ Alternatively, add this line to your Gemfile and run bundle install:
61
45
 
46
+ ```
47
+ gem 'options_by_example'
48
+ ```
@@ -16,46 +16,47 @@ class OptionsByExample
16
16
 
17
17
  def initialize(usage)
18
18
  @argument_names = usage.argument_names
19
- @default_values = usage.default_values
20
19
  @option_names = usage.option_names
21
20
 
22
- @argument_values = @default_values.dup
21
+ @argument_values = {}
23
22
  @option_values = {}
24
23
  end
25
24
 
26
- def parse(array)
25
+ def parse(argv)
26
+ @slices = argv.slice_before(/^-/).entries
27
+ treat_everything_after_double_dash_as_positionals
27
28
 
28
- # Separate command-line options and their respective arguments into
29
- # chunks, plus tracking leading excess arguments. This organization
30
- # facilitates further processing and validation of the input.
31
-
32
- @slices = []
33
- @remainder = current = []
34
- array.each do |each|
35
- @slices << current = [] if each.start_with?(?-)
36
- current << each
37
- end
38
-
39
- raise_if_help_option
29
+ exit_if_help_option
40
30
  unpack_combined_shorthand_options
41
31
  expand_dash_number_to_dash_n_option
42
32
  raise_if_unknown_options
43
- parse_options
33
+
34
+ @remainder = parse_options_and_return_remainder
44
35
  coerce_num_date_time_etc
45
36
 
46
37
  validate_number_of_arguments
47
- parse_required_arguments
48
- parse_optional_arguments
38
+ parse_positional_arguments
49
39
 
50
- raise "Internal error: unreachable state" unless @remainder.empty?
40
+ # :nocov:
41
+ raise %{unreachable given we check number of arguments} unless @remainder.empty?
42
+ # :nocov:
51
43
  end
52
44
 
53
45
  private
54
46
 
55
- def raise_if_help_option
47
+ def treat_everything_after_double_dash_as_positionals
48
+ index = @slices.index { |head,| head == '--' }
49
+ @slices[index..] = [@slices.drop(index).flatten] if index
50
+ end
51
+
52
+ def exit_if_help_option
56
53
  @slices.each do |option, *args|
57
54
  case option
58
55
  when '-h', '--help'
56
+ if args.first == 'debug!'
57
+ puts "@argument_names = #{@argument_names.inspect}"
58
+ puts "@option_names = #{@option_names.inspect}"
59
+ end
59
60
  raise PrintUsageMessage
60
61
  end
61
62
  end
@@ -99,104 +100,135 @@ class OptionsByExample
99
100
  end
100
101
 
101
102
  def raise_if_unknown_options
102
- @slices.each do |option, *args|
103
- raise "Found unknown option '#{option}'" unless @option_names.include?(option)
103
+ @slices.each do |option,|
104
+ if option =~ /^--?\w/ and not @option_names.include?(option)
105
+ raise "Found unknown option '#{option}'"
106
+ end
104
107
  end
105
108
  end
106
109
 
107
- def parse_options
108
- @slices.each do |option, *args|
109
- if @remainder.any?
110
- raise "Unexpected arguments found before option '#{option}', please provide all options before arguments"
110
+ def parse_options_and_return_remainder
111
+ pending = @slices.dup
112
+
113
+ until pending.empty?
114
+ current = pending.first
115
+
116
+ unless current.first =~ /^--?\w/
117
+ current.shift if current.first == '--' # consume double-dash
118
+ return current if pending.length == 1
119
+ raise "Unexpected arguments found before option '#{pending[1].first}', please provide all options before arguments"
111
120
  end
112
121
 
113
- option_name, argument_name = @option_names[option]
122
+ option = current.shift # consume the option/flag
123
+ option_name, has_argument, _ = @option_names[option]
114
124
  @option_values[option_name] = true
115
125
 
116
- if argument_name
117
- raise "Expected argument for option '#{option}', got none" if args.empty?
118
- @argument_values[option_name] = args.shift
126
+ if has_argument
127
+ raise "Expected argument for option '#{option}', got none" unless current.first
128
+ @argument_values[option_name] = current.shift # consume the argument
119
129
  @option_took_argument = option
120
130
  else
121
131
  @option_took_argument = nil
122
132
  end
123
133
 
124
- @remainder = args
134
+ pending.shift if current.empty?
125
135
  end
136
+
137
+ return []
126
138
  end
127
139
 
128
140
  def coerce_num_date_time_etc
129
- @option_names.each do |option, (each, argument_name)|
130
- next unless value = @argument_values[each]
141
+ @option_names.each do |option, (each, argument_name, default_value)|
142
+ value = @argument_values.fetch(each, default_value)
143
+ next unless value
144
+
131
145
  begin
132
146
  case argument_name
133
147
  when 'NUM'
134
148
  expected_type = 'an integer value'
135
- @argument_values[each] = Integer value
149
+ value = Integer value
150
+ when 'FLOAT'
151
+ expected_type = 'a floating-point value'
152
+ value = Float value
136
153
  when 'DATE'
137
154
  expected_type = 'a date (e.g. YYYY-MM-DD)'
138
- @argument_values[each] = Date.parse value
155
+ value = Date.parse value
139
156
  when 'TIME'
140
157
  expected_type = 'a timestamp (e.g. HH:MM:SS)'
141
- @argument_values[each] = Time.parse value
158
+ value = Time.parse value
142
159
  end
143
160
  rescue ArgumentError
144
161
  raise "Invalid argument \"#{value}\" for option '#{option}', please provide #{expected_type}"
145
162
  end
163
+
164
+ @argument_values[each] = value
146
165
  end
147
166
  end
148
167
 
149
168
  def validate_number_of_arguments
150
- count_optional_arguments = @argument_names.values.count(:optional)
151
- count_required_arguments = @argument_names.values.count(:required)
152
- count_vararg_arguments = @argument_names.values.count(:vararg)
169
+ # ASSUME: either varargs or optional arguments, never both. That
170
+ # constraint is guaranteed upstream. Here, we just count
153
171
 
154
- min_length = count_required_arguments + count_vararg_arguments
155
- max_length = count_required_arguments + count_optional_arguments
172
+ min_length = count_arguments(:required) + count_arguments(:vararg)
173
+ max_length = count_arguments(:required) + count_arguments(/optional/)
174
+ max_length = nil if count_arguments(/vararg/) > 0
156
175
 
157
- if @remainder.size > max_length && count_vararg_arguments == 0
158
- range = [min_length, max_length].uniq.join(?-)
159
- raise "Expected #{range} arguments, but received too many"
160
- end
176
+ unless (min_length..max_length) === @remainder.size
161
177
 
162
- if @remainder.size < min_length
163
- too_few = @remainder.empty? ? 'none' : (@remainder.size == 1 ? 'only one' : 'too few')
164
- remark = " (considering #{@option_took_argument} takes an argument)" if @option_took_argument
165
- raise "Expected #{min_length} required arguments, but received #{too_few}#{remark}"
166
- end
167
- end
178
+ if max_length.nil?
179
+ msg = "Expected #{min_length} or more arguments,"
180
+ elsif max_length > min_length
181
+ msg = "Expected #{min_length}-#{max_length} arguments,"
182
+ else
183
+ msg = "Expected #{min_length} arguments,"
184
+ end
168
185
 
169
- def parse_required_arguments
170
- if @argument_names.values.include?(:vararg)
171
- remaining_arguments = @argument_names.length
172
- @argument_names.each do |argument_name, arity|
173
- raise "unreachable" if @remainder.empty?
174
- remaining_arguments -= 1
175
- case arity
176
- when :required
177
- @argument_values[argument_name] = @remainder.shift
178
- when :vararg
179
- @argument_values[argument_name] = @remainder.shift(@remainder.length - remaining_arguments)
180
- else
181
- raise "unreachable"
182
- end
186
+ if @remainder.empty?
187
+ msg += " but received none"
188
+ elsif @remainder.size == 1 && min_length > 1
189
+ msg += " but received only one"
190
+ elsif @remainder.size < min_length
191
+ msg += " but received too few"
192
+ elsif max_length && @remainder.size > max_length
193
+ msg += " but received too many"
194
+ # :nocov:
195
+ else
196
+ raise %{unreachable given the range check above}
197
+ # :nocov:
183
198
  end
184
- return
185
- end
186
199
 
187
- @argument_names.reverse_each do |argument_name, arity|
188
- break if arity == :optional
189
- raise "unreachable" if @remainder.empty?
190
- @argument_values[argument_name] = @remainder.pop
200
+ if @option_took_argument
201
+ msg += " (considering #{@option_took_argument} takes an argument)"
202
+ end
203
+
204
+ raise msg
191
205
  end
192
206
  end
193
207
 
194
- def parse_optional_arguments
208
+ def parse_positional_arguments
209
+ remaining_arguments = @argument_names.length
195
210
  @argument_names.each do |argument_name, arity|
196
- break unless arity == :optional
197
- break if @remainder.empty?
198
- @argument_values[argument_name] = @remainder.shift
211
+ remaining_arguments -= 1
212
+ case arity
213
+ when :required
214
+ @argument_values[argument_name] = @remainder.shift
215
+ when :vararg, :optional_vararg
216
+ @argument_values[argument_name] = @remainder.shift(@remainder.length - remaining_arguments)
217
+ when :optional
218
+ next if @remainder.empty?
219
+ @argument_values[argument_name] = @remainder.shift
220
+ # :nocov:
221
+ else
222
+ raise %{unreachable given these are all possible values}
223
+ # :nocov:
224
+ end
199
225
  end
200
226
  end
227
+
228
+ private
229
+
230
+ def count_arguments(pattern)
231
+ @argument_names.values.grep(pattern).count
232
+ end
201
233
  end
202
234
  end
@@ -6,7 +6,6 @@ class OptionsByExample
6
6
 
7
7
  attr_reader :message
8
8
  attr_reader :argument_names
9
- attr_reader :default_values
10
9
  attr_reader :option_names
11
10
 
12
11
  def initialize(text)
@@ -23,10 +22,10 @@ class OptionsByExample
23
22
  inline_options = []
24
23
 
25
24
  usage_line = text.lines.grep(/Usage:/).first
26
- raise RuntimeError, "Expected usage string, got none" unless usage_line
25
+ raise "Expected usage string, got none" unless usage_line
27
26
  tokens = usage_line.scan(/\[.*?\]|\w+ \.\.\.|\S+/)
28
- raise unless tokens.shift == 'Usage:'
29
- raise unless tokens.shift
27
+ raise "Expected usage line to start with 'Usage:'" unless tokens.shift == 'Usage:'
28
+ raise "Expected command name on same line as 'Usage:'" unless tokens.shift
30
29
  tokens.shift if tokens.first == '[options]'
31
30
 
32
31
  while /^\[(--?\w.*)\]$/ === tokens.first
@@ -34,24 +33,31 @@ class OptionsByExample
34
33
  tokens.shift
35
34
  end
36
35
 
36
+ while /^(\w+)( ?\.\.\.)?$/ === tokens.first
37
+ vararg_if_dotted = $2 ? :vararg : :required
38
+ @argument_names[sanitize $1] = vararg_if_dotted
39
+ tokens.shift
40
+ end
41
+
37
42
  while /^\[(\w+)\]$/ === tokens.first
38
43
  @argument_names[sanitize $1] = :optional
39
44
  tokens.shift
40
45
  end
41
46
 
42
- while /^(\w+)( ?\.\.\.)?$/ === tokens.first
43
- vararg_if_dotted = $2 ? :vararg : :required
44
- @argument_names[sanitize $1] = vararg_if_dotted
47
+ if /^\[(\w+) ?\.\.\.\]$/ === tokens.first
48
+ @argument_names[sanitize $1] = :optional_vararg
45
49
  tokens.shift
46
50
  end
47
51
 
48
52
  raise "Found invalid usage token '#{tokens.first}'" unless tokens.empty?
49
53
 
50
- count_optional_arguments = @argument_names.values.count(:optional)
51
- count_vararg_arguments = @argument_names.values.count(:vararg)
54
+ if count_arguments(:vararg) > 0 && count_arguments(/optional/) > 0
55
+ raise "Cannot combine vararg and optional arguments"
56
+ end
52
57
 
53
- raise "Cannot combine dotted and optional arguments" if count_optional_arguments > 0 && count_vararg_arguments > 0
54
- raise "Found more than one dotted arguments" if count_vararg_arguments > 1
58
+ if count_arguments(/vararg/) > 1
59
+ raise "Found more than one vararg arguments"
60
+ end
55
61
 
56
62
  # --- 2) Parse option names ---------------------------------------
57
63
  #
@@ -65,7 +71,6 @@ class OptionsByExample
65
71
  # -t, --timeout NUM Set connection timeout in seconds
66
72
 
67
73
  @option_names = {}
68
- @default_values = {}
69
74
 
70
75
  options = inline_options + text.lines.grep(/^\s*--?\w/)
71
76
  options.each do |string|
@@ -92,11 +97,10 @@ class OptionsByExample
92
97
  default_value = $1
93
98
  end
94
99
 
95
- [short_form, long_form].compact.each do |each|
96
- @option_names[each] = [option_name, argument_name]
100
+ ary = [option_name, argument_name, default_value]
101
+ [short_form, long_form].each do |each|
102
+ @option_names[each] = ary if each
97
103
  end
98
-
99
- @default_values[option_name] = default_value if default_value
100
104
  end
101
105
  end
102
106
 
@@ -105,5 +109,9 @@ class OptionsByExample
105
109
  def sanitize(string)
106
110
  string.tr('^a-zA-Z0-9', '_').downcase.to_sym
107
111
  end
112
+
113
+ def count_arguments(pattern)
114
+ @argument_names.values.grep(pattern).count
115
+ end
108
116
  end
109
117
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class OptionsByExample
4
- VERSION = '3.4.0'
4
+ VERSION = '4.1.0'
5
5
  end
6
6
 
7
7
 
@@ -11,6 +11,16 @@ __END__
11
11
  # Minor version bump when backward-compatible changes or enhancements
12
12
  # Patch version bump when backward-compatible bug fixes, security updates etc
13
13
 
14
+ 4.1.0
15
+ - Treat everything after double-dash as positional arguments
16
+ - Improve error message when usage is split across lines
17
+ - Add support for FLOAT arguments, eg `--ratio FLOAT`
18
+
19
+ 4.0.0
20
+ - Remove support for leading optional arguments (breaking change)
21
+ - Add support for trailing optional arguments
22
+ - Add support for optional vararg argument
23
+
14
24
  3.4.0
15
25
  - Ensure default values are coerced too
16
26
  - Print error message to stdout
@@ -76,7 +76,9 @@ class OptionsByExample
76
76
  def initialize_argument_accessors
77
77
  [
78
78
  *@usage_spec.argument_names.keys,
79
- *@usage_spec.option_names.values.select(&:last).map(&:first),
79
+ *@usage_spec.option_names.values
80
+ .map { |option_name, has_argument| option_name if has_argument }
81
+ .compact,
80
82
  ].each do |argument_name|
81
83
  instance_eval %{
82
84
  def argument_#{argument_name}
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: options_by_example
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.0
4
+ version: 4.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Adrian Kuhn
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-03 00:00:00.000000000 Z
11
+ date: 2026-04-01 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email: