Skip to content

Commit 70f88e8

Browse files
committed
Support short option bundling
This enables short option bundling such as with `-rf` which will with this option trigger separate `-r` and `-f` handlers. ``` require "option_parser" removed = false forced = false OptionParser.parse(%w(-rf), bundling: true) do |parser| parser.on("-r", "") { removed = true } parser.on("-f", "") { forced = true } end {removed, forced} # => {true, true} ```
1 parent 5a44077 commit 70f88e8

File tree

2 files changed

+153
-9
lines changed

2 files changed

+153
-9
lines changed

spec/std/option_parser_spec.cr

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,79 @@ describe "OptionParser" do
248248
expect_doesnt_capture_option [] of String, "-f FLAG"
249249
end
250250

251+
describe "bundling" do
252+
it "parses bundled boolean short options" do
253+
args = %w(-rf)
254+
called = [] of String
255+
OptionParser.parse(args, bundling: true) do |opts|
256+
opts.on("-r", "") { called << "-r" }
257+
opts.on("-f", "") { called << "-f" }
258+
end
259+
called.should eq(%w(-r -f))
260+
args.size.should eq(0)
261+
end
262+
263+
it "re-triggers handlers for repeated short flags" do
264+
args = %w(-vvv)
265+
verbosity = 0
266+
OptionParser.parse(args, bundling: true) do |opts|
267+
opts.on("-v", "") { verbosity += 1 }
268+
end
269+
verbosity.should eq(3)
270+
args.size.should eq(0)
271+
end
272+
273+
it "uses rest of token as required value and stops bundling" do
274+
args = %w(-ovalue -r)
275+
value = nil
276+
r = false
277+
OptionParser.parse(args, bundling: true) do |opts|
278+
opts.on("-o VALUE", "") { |v| value = v }
279+
opts.on("-r", "") { r = true }
280+
end
281+
value.should eq("value")
282+
r.should be_true
283+
args.size.should eq(0)
284+
end
285+
286+
it "assigns remainder as value for later required option" do
287+
args = %w(-ab123)
288+
a = false
289+
b = nil
290+
OptionParser.parse(args, bundling: true) do |opts|
291+
opts.on("-a", "") { a = true }
292+
opts.on("-b VALUE", "") { |v| b = v }
293+
end
294+
a.should be_true
295+
b.should eq("123")
296+
args.size.should eq(0)
297+
end
298+
299+
it "raises on invalid option inside bundle" do
300+
expect_raises OptionParser::InvalidOption, "Invalid option: -j" do
301+
OptionParser.parse(["-rj"], bundling: true) do |opts|
302+
opts.on("-r", "") { }
303+
end
304+
end
305+
end
306+
307+
it "consumes rest of bundle as argument value when middle option requires argument" do
308+
args = %w(-aeb)
309+
a = false
310+
b = false
311+
e = nil
312+
OptionParser.parse(args, bundling: true) do |opts|
313+
opts.on("-a", "") { a = true }
314+
opts.on("-b", "") { b = true }
315+
opts.on("-e VALUE", "") { |v| e = v }
316+
end
317+
a.should be_true
318+
b.should be_false
319+
e.should eq("b")
320+
args.size.should eq(0)
321+
end
322+
end
323+
251324
describe "gnu_optional_args" do
252325
it "doesn't get optional argument for short flag after space" do
253326
flag = nil

src/option_parser.cr

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ class OptionParser
112112
# and uses it to parse the passed *args* (defaults to `ARGV`).
113113
#
114114
# Refer to `#gnu_optional_args?` for the behaviour of the named parameter.
115-
def self.parse(args = ARGV, *, gnu_optional_args : Bool = false, &) : self
116-
parser = OptionParser.new(gnu_optional_args: gnu_optional_args)
115+
def self.parse(args = ARGV, *, gnu_optional_args : Bool = false, bundling : Bool = false, &) : self
116+
parser = OptionParser.new(gnu_optional_args: gnu_optional_args, bundling: bundling)
117117
yield parser
118118
parser.parse(args)
119119
parser
@@ -122,7 +122,7 @@ class OptionParser
122122
# Creates a new parser.
123123
#
124124
# Refer to `#gnu_optional_args?` for the behaviour of the named parameter.
125-
def initialize(*, @gnu_optional_args : Bool = false)
125+
def initialize(*, @gnu_optional_args : Bool = false, @bundling : Bool = false)
126126
@flags = [] of String
127127
@handlers = Hash(String, Handler).new
128128
@stop = false
@@ -133,8 +133,8 @@ class OptionParser
133133
# Creates a new parser, with its configuration specified in the block.
134134
#
135135
# Refer to `#gnu_optional_args?` for the behaviour of the named parameter.
136-
def self.new(*, gnu_optional_args : Bool = false, &)
137-
new(gnu_optional_args: gnu_optional_args).tap { |parser| yield parser }
136+
def self.new(*, gnu_optional_args : Bool = false, bundling : Bool = false, &)
137+
new(gnu_optional_args: gnu_optional_args, bundling: bundling).tap { |parser| yield parser }
138138
end
139139

140140
# Returns whether the GNU convention is followed for optional arguments.
@@ -173,6 +173,27 @@ class OptionParser
173173
# ```
174174
property? gnu_optional_args : Bool
175175

176+
# Returns whether short option bundling is enabled.
177+
#
178+
# If true, short options can be grouped after a single dash:
179+
#
180+
# ```
181+
# require "option_parser"
182+
#
183+
# removed = false
184+
# forced = false
185+
# OptionParser.parse(%w(-rf), bundling: true) do |parser|
186+
# parser.on("-r", "") { removed = true }
187+
# parser.on("-f", "") { forced = true }
188+
# end
189+
#
190+
# {removed, forced} # => {true, true}
191+
# ```
192+
#
193+
# Without `bundling: true`, a token like `-rf` is interpreted as
194+
# the flag `-r` with an inline value `f`.
195+
property? bundling : Bool
196+
176197
# Establishes the initial message for the help printout.
177198
# Typically, you want to write here the name of your program,
178199
# and a one-line template of its invocation.
@@ -401,9 +422,12 @@ class OptionParser
401422
break
402423
end
403424

404-
flag, value = parse_arg_to_flag_and_value(arg)
405-
406-
arg_index = handle_flag(flag, value, arg_index, args, handled_args)
425+
if bundling? && bundled_short_arg?(arg)
426+
arg_index = handle_bundled_short_options(arg, arg_index, args, handled_args)
427+
else
428+
flag, value = parse_arg_to_flag_and_value(arg)
429+
arg_index = handle_flag(flag, value, arg_index, args, handled_args)
430+
end
407431

408432
arg_index += 1
409433
end
@@ -444,19 +468,66 @@ class OptionParser
444468
end
445469
end
446470

471+
private def bundled_short_arg?(arg : String) : Bool
472+
arg.starts_with?('-') && !arg.starts_with?("--") && arg.size > 2
473+
end
474+
447475
# Parses a command-line argument into a flag and optional inline value.
448476
private def parse_arg_to_flag_and_value(arg : String) : {String, String?}
449477
if arg.starts_with?("--")
450478
name, separator, value = arg.partition("=")
451479
if separator == "="
452480
return {name, value}
453481
end
454-
elsif arg.starts_with?('-') && arg.size > 2
482+
elsif bundled_short_arg?(arg)
455483
return {arg[0..1], arg[2..]}
456484
end
457485
{arg, nil}
458486
end
459487

488+
private def handle_bundled_short_options(arg : String, arg_index : Int32, args : Array(String), handled_args : Array(Int32)) : Int32
489+
rest = arg[1..]
490+
rest.each_char_with_index do |char, index|
491+
flag = "-#{char}"
492+
493+
suffix = index + 1 < rest.bytesize ? rest[(index + 1)..] : nil
494+
495+
if handler = @handlers[flag]?
496+
case handler.value_type
497+
in FlagValue::None
498+
next_index = handle_flag(flag, nil, arg_index, args, handled_args)
499+
return next_index unless next_index == arg_index
500+
in FlagValue::Required
501+
value = suffix
502+
if value && !value.empty?
503+
handled_args << arg_index
504+
handler.block.call(value)
505+
else
506+
next_index = handle_flag(flag, nil, arg_index, args, handled_args)
507+
return next_index unless next_index == arg_index
508+
end
509+
return arg_index
510+
in FlagValue::Optional
511+
value = suffix
512+
if value && !value.empty? && gnu_optional_args?
513+
handled_args << arg_index
514+
handler.block.call(value)
515+
return arg_index
516+
else
517+
next_index = handle_flag(flag, nil, arg_index, args, handled_args)
518+
return next_index unless next_index == arg_index
519+
end
520+
end
521+
else
522+
@invalid_option.call(flag)
523+
return arg_index
524+
end
525+
end
526+
527+
handled_args << arg_index
528+
arg_index
529+
end
530+
460531
# Processes a single flag/subcommand. Matches original behaviour exactly.
461532
private def handle_flag(flag : String, value : String?, arg_index : Int32, args : Array(String), handled_args : Array(Int32)) : Int32
462533
return arg_index unless handler = @handlers[flag]?

0 commit comments

Comments
 (0)