Skip to content

Commit 9de50be

Browse files
authored
Merge pull request #23 from cgay/subcommands
Multi-level subcommands
2 parents 4ca9af4 + 763d20d commit 9de50be

7 files changed

+321
-159
lines changed

command-line-parser.dylan

+142-87
Original file line numberDiff line numberDiff line change
@@ -106,44 +106,71 @@ end function;
106106
//======================================================================
107107

108108
define abstract class <command> (<object>)
109-
constant slot parser-tokens :: <deque> = make(<deque>); // of: <token>
110109
slot command-options :: <sequence> = make(<stretchy-vector>),
111110
init-keyword: options:;
112111
constant slot %command-help :: <string>,
113112
required-init-keyword: help:;
113+
// Subcommands may be arbitrarily nested.
114+
slot command-subcommands :: <sequence> = #[],
115+
init-keyword: subcommands:;
116+
slot selected-subcommand :: false-or(<subcommand>) = #f;
114117
end class;
115118

116-
define method initialize (cmd :: <command>, #key) => ()
119+
// The --help option is added by default but we provide a way to turn it off here.
120+
define method initialize
121+
(cmd :: <command>, #key help-option? :: <boolean> = #t, #all-keys)
122+
=> ()
123+
if (help-option?)
124+
add-help-option(cmd);
125+
end;
117126
next-method();
118-
validate-options(cmd.command-options);
127+
validate-options(cmd);
128+
end method;
129+
130+
define open abstract class <subcommand> (<command>)
131+
constant slot subcommand-name :: <string>,
132+
required-init-keyword: name:;
133+
end class;
134+
135+
define method debug-name
136+
(subcmd :: <subcommand>) => (name :: <string>)
137+
subcmd.subcommand-name
119138
end method;
120139

121-
define function validate-options (options :: <sequence>)
140+
define function validate-options
141+
(cmd :: <command>) => ()
142+
let description = if (instance?(cmd, <subcommand>))
143+
format-to-string("Subcommand %=", cmd.subcommand-name)
144+
else
145+
"Command"
146+
end;
147+
if (cmd.has-subcommands? & cmd.positional-options.size > 0)
148+
parser-error("%s has both subcommands and positional options", description);
149+
end;
122150
// Don't care if positionals are mixed in with pass-by-names because
123151
// positional-options will extract them in order.
124152
let names = make(<stretchy-vector>);
125153
let repeated-positional = #f;
126154
let optional-positional = #f;
127-
for (option in options)
155+
for (option in cmd.command-options)
128156
for (name in option.option-names)
129157
if (member?(name, names, test: \=))
130-
parser-error("Duplicate option name: %=", name);
158+
parser-error("%s has duplicate option name: %=", description, name);
131159
end;
132160
add!(names, name);
133161
end;
134162
if (repeated-positional)
135-
parser-error("only one repeated positional option (currently %=) is"
136-
" allowed and it must be the last option",
137-
repeated-positional.canonical-name);
163+
parser-error("%s has options following repeated positional option %=",
164+
description, repeated-positional.canonical-name);
138165
end;
139166
if (instance?(option, <positional-option>))
140167
if (option.option-repeated?)
141168
repeated-positional := option;
142169
end;
143170
if (option.option-required? & optional-positional)
144-
parser-error("required positional option %= may not follow"
171+
parser-error("%s has required positional option %= following"
145172
" optional positional option %=",
146-
option.canonical-name,
173+
description, option.canonical-name,
147174
optional-positional.canonical-name);
148175
end;
149176
if (~option.option-required?)
@@ -167,90 +194,105 @@ define function pass-by-name-options
167194
cmd.command-options)
168195
end function;
169196

170-
define open abstract class <subcommand> (<command>)
171-
constant slot subcommand-name :: <string>,
172-
required-init-keyword: name:;
173-
end class;
174-
175-
define method debug-name
176-
(subcmd :: <subcommand>) => (name :: <string>)
177-
subcmd.subcommand-name
178-
end method;
179-
180-
// Should this just be another method on execute-command instead?
181-
define open generic execute-subcommand
182-
(parser :: <command-line-parser>, subcmd :: <object>)
183-
=> (status :: false-or(<integer>));
184-
185-
define method execute-subcommand
186-
(parser :: <command-line-parser>, subcmd :: <object>)
187-
=> (status :: false-or(<integer>))
188-
error("don't know how to execute subcommand %=. add an execute-subcommand method?",
189-
subcmd);
190-
end method;
191-
192-
define open class <command-line-parser> (<command>)
193-
slot parser-subcommands :: <sequence> = #[],
194-
init-keyword: subcommands:;
195-
slot selected-subcommand :: false-or(<subcommand>) = #f;
196-
end class;
197-
198-
// Help options are on by default but may be turned off.
199-
define method initialize
200-
(parser :: <command-line-parser>,
201-
#key help-option? :: <boolean> = #t,
202-
help-subcommand? :: <boolean> = #t, #all-keys) => ()
203-
next-method();
204-
if (help-option?)
205-
add-help-option(parser);
206-
end;
207-
if (help-subcommand? & parser.has-subcommands?)
208-
add-help-subcommand(parser);
209-
end;
210-
end method;
211-
212197
define function has-subcommands?
213-
(parser :: <command-line-parser>) => (_ :: <boolean>)
214-
parser.parser-subcommands.size > 0
198+
(cmd :: <command>) => (_ :: <boolean>)
199+
cmd.command-subcommands.size > 0
215200
end;
216201

217202
define generic find-subcommand
218-
(parser :: <command-line-parser>, object)
203+
(cmd :: <command>, object)
219204
=> (subcommand :: false-or(<subcommand>));
220205

221206
define method find-subcommand
222-
(parser :: <command-line-parser>, class :: subclass(<subcommand>))
207+
(cmd :: <command>, class :: subclass(<subcommand>))
223208
=> (subcommand :: false-or(<subcommand>))
224-
let subs = parser.parser-subcommands;
209+
let subs = cmd.command-subcommands;
225210
let key = find-key(subs, rcurry(instance?, class));
226211
key & subs[key]
227212
end method;
228213

229214
define method find-subcommand
230-
(parser :: <command-line-parser>, name :: <string>)
215+
(cmd :: <command>, name :: <string>)
231216
=> (subcommand :: false-or(<subcommand>))
232-
let subs = parser.parser-subcommands;
217+
let subs = cmd.command-subcommands;
233218
let key = find-key(subs, method (subcmd)
234219
name = subcmd.subcommand-name
235220
end);
236221
key & subs[key]
237222
end method;
238223

224+
define method find-subcommand
225+
(cmd :: <command>, path :: <sequence>)
226+
=> (subcommand :: false-or(<subcommand>))
227+
iterate loop (cmd = cmd, i = 0)
228+
if (i >= path.size)
229+
cmd
230+
else
231+
let subs = cmd.command-subcommands;
232+
let name = path[i];
233+
let k = find-key(subs, method (subcmd)
234+
name = subcmd.subcommand-name
235+
end);
236+
k & loop(subs[k], i + 1)
237+
end
238+
end
239+
end method;
240+
239241
define function add-subcommand
240-
(parser :: <command-line-parser>, subcmd :: <subcommand>) => ()
242+
(cmd :: <command>, subcmd :: <subcommand>) => ()
241243
let name = subcommand-name(subcmd);
242-
if (parser.positional-options.size > 0)
243-
parser-error("a command line parser may not have both positional"
244+
if (cmd.positional-options.size > 0)
245+
parser-error("a command may not have both positional"
244246
" options and subcommands");
245247
end;
246-
if (find-subcommand(parser, name))
248+
if (find-subcommand(cmd, name))
247249
parser-error("a subcommand named %= already exists", name);
248250
end;
249-
parser.parser-subcommands := add!(parser.parser-subcommands, subcmd);
251+
cmd.command-subcommands := add!(cmd.command-subcommands, subcmd);
250252
end function;
251253

254+
define open generic execute-subcommand
255+
(parser :: <command-line-parser>, subcmd :: <object>)
256+
=> (status :: false-or(<integer>));
257+
258+
define method execute-subcommand
259+
(parser :: <command-line-parser>, subcmd :: <object>)
260+
=> (status :: false-or(<integer>))
261+
error("don't know how to execute subcommand %=. add an execute-subcommand method?",
262+
subcmd);
263+
end method;
264+
265+
// A <command-line-parser> is just a top-level command that handles the overall command
266+
// line processing.
267+
define open class <command-line-parser> (<command>)
268+
constant slot parser-tokens :: <deque> = make(<deque>); // of: <token>
269+
end class;
270+
271+
define method initialize
272+
(cmd :: <command-line-parser>, #key help-subcommand? :: <boolean> = #t, #all-keys)
273+
=> ()
274+
next-method();
275+
// A "help" subcommand is added only if there are other subcommands since adding
276+
// subcommands changes the way the overall command is parsed.
277+
//
278+
// TODO(cgay): This isn't called if someone uses the pattern
279+
// let cmd = make(<command-line-parser>, ...no subcommands: argument...);
280+
// add-subcommand(cmd, subcmd);
281+
// So for now if you use that pattern you have to add the help subcommand manually.
282+
// I don't like that it means there's a different level of knowledge necessary for
283+
// that pattern. We could stash away the value of `help-subcommand?` here and use
284+
// it later if add-subcommand is called. I want to see how the new macros look before
285+
// deciding how to handle this though.
286+
if (help-subcommand? & cmd.has-subcommands?)
287+
add-help-subcommand(cmd);
288+
end;
289+
end method;
290+
291+
// This wasn't really well thought out. It's only useful to call if you have subcommands
292+
// and each one has its own subclass.
252293
define generic execute-command
253-
(parser :: <command-line-parser>) => (status :: false-or(<integer>));
294+
(parser :: <command-line-parser>)
295+
=> (status :: false-or(<integer>));
254296

255297
define method execute-command
256298
(parser :: <command-line-parser>) => (status :: false-or(<integer>))
@@ -272,9 +314,8 @@ define generic add-option (cmd :: <command>, option :: <option>) => ();
272314

273315
define method add-option
274316
(cmd :: <command>, option :: <option>) => ()
275-
let new-options = add(cmd.command-options, option);
276-
validate-options(new-options);
277-
cmd.command-options := new-options;
317+
cmd.command-options := add(cmd.command-options, option);
318+
validate-options(cmd);
278319
end method;
279320

280321
define generic find-option
@@ -309,29 +350,29 @@ define function get-option-value
309350
end function;
310351

311352
define function add-argument-token
312-
(parser :: <command>, class :: <class>, value :: <string>,
353+
(parser :: <command-line-parser>, class :: <class>, value :: <string>,
313354
#rest keys, #key, #all-keys)
314355
=> ()
315356
push-last(parser.parser-tokens, apply(make, class, value: value, keys));
316357
end;
317358

318359
define function tokens-remaining?
319-
(parser :: <command>) => (remaining? :: <boolean>)
360+
(parser :: <command-line-parser>) => (remaining? :: <boolean>)
320361
~parser.parser-tokens.empty?
321362
end;
322363

323364
// TODO(cgay): This says it returns false-or, but it doesn't. I think it would be an
324365
// improvement to make it do that and get rid of tokens-remaining?.
325366
define function peek-token
326-
(parser :: <command>) => (token :: false-or(<token>))
367+
(parser :: <command-line-parser>) => (token :: false-or(<token>))
327368
unless (tokens-remaining?(parser))
328369
usage-error("Ran out of arguments.")
329370
end;
330371
parser.parser-tokens[0];
331372
end;
332373

333374
define function pop-token
334-
(parser :: <command>) => (token :: false-or(<token>))
375+
(parser :: <command-line-parser>) => (token :: false-or(<token>))
335376
unless (tokens-remaining?(parser))
336377
usage-error("Ran out of arguments.")
337378
end;
@@ -408,6 +449,9 @@ define method initialize
408449
let type = option.option-type;
409450
if (default)
410451
if (option.option-repeated?)
452+
// TODO(cgay): Verify that each element of the default value can be converted to an
453+
// instance of the option's type via parse-option-value. (I forget whether the
454+
// default is normally converted to the type: or not....)
411455
type := <collection>
412456
end;
413457
if (~instance?(default, type))
@@ -675,7 +719,7 @@ define method parse-command-line
675719
let (clean-args, extra-args) = split-args(args);
676720
let chopped-args = chop-args(clean-args);
677721
tokenize-args(parser, chopped-args);
678-
process-tokens(parser, #f);
722+
process-tokens(program-name(), parser, #f);
679723

680724
if (~empty?(extra-args))
681725
// Append any more positional options from after the '--'. If there's a
@@ -702,53 +746,64 @@ end method;
702746
define open generic parse-option
703747
(option :: <option>, args :: <command>) => ();
704748

705-
// Process the tokens, side-effecting the <command>, the <subcommand> (if any),
706-
// and the <option>s. If a subcommand is encountered, the remaining tokens are
707-
// passed to it and this is called recursively.
749+
// Process the tokens, side-effecting the <command>, the <subcommand>s (if any), and the
750+
// <option>s. If a subcommand is encountered, the remaining tokens are passed to it and
751+
// this is called recursively. Only the last (sub)command may have positional args.
708752
//
709753
// One bit of subtlety here (which would be cleaned up by separating the parser
710754
// from the command descriptions) is that the full tokenized command line is
711755
// stored in the <command-line-parser> while some of the options may be stored
712756
// in a <subcommand>.
713757
define function process-tokens
714-
(parser :: <command-line-parser>, subcmd :: false-or(<subcommand>))
715-
let pos-opts = as(<list>, (subcmd | parser).positional-options);
758+
(name :: <string>, parser :: <command-line-parser>, subcmd :: false-or(<subcommand>))
759+
let cmd = subcmd | parser;
760+
let pos-opts = as(<list>, cmd.positional-options);
761+
if (~empty?(pos-opts) & cmd.has-subcommands?)
762+
// TODO(cgay): move this to validate-options or equivalent so that it happens at
763+
// instantiation time.
764+
usage-error("The %= %s has subcommands so it may not have its own positional"
765+
" arguments.",
766+
name,
767+
if (instance?(cmd, <subcommand>)) "subcommand" else "command" end);
768+
end;
716769
while (tokens-remaining?(parser))
717770
let token = peek-token(parser);
718771
let value = token.token-value;
719772
select (token by instance?)
720773
<argument-token> =>
721774
// Got an argument token without a preceding <short/long-option-token>
722775
// so it must be a subcommand or a positional argument.
723-
if (~subcmd & parser.has-subcommands?)
724-
let sub = find-subcommand(parser, value)
776+
if (cmd.has-subcommands?)
777+
let sub = find-subcommand(cmd, value)
725778
| usage-error("%= does not name a subcommand.", value);
726779
pop-token(parser);
780+
// Store the selected subcommand in the parent subcommand, and the top-level
781+
// parser always receives the final subcommand.
782+
cmd.selected-subcommand := sub;
727783
parser.selected-subcommand := sub;
728-
//subcommand.parser-tokens := parser.parser-tokens;
729-
process-tokens(parser, sub);
784+
process-tokens(sub.subcommand-name, parser, sub);
730785
else
731786
if (empty?(pos-opts))
732787
usage-error("Too many positional arguments: %=", value);
733788
end;
734789
let option = head(pos-opts);
735790
if (option.option-repeated?)
736-
assert(pos-opts.size = 1);
791+
assert(pos-opts.size = 1); // Repeated option must be last option.
737792
else
738793
pos-opts := tail(pos-opts);
739794
end;
740-
parse-option(option, parser);
795+
parse-option(option, parser); // Parser contains the unprocessed tokens.
741796
option.option-present? := #t;
742797
end;
743798
<short-option-token>, <long-option-token> =>
744-
let option = find-option(subcmd | parser, value)
799+
let option = find-option(cmd, value)
745800
| usage-error("Unrecognized option: %s%s",
746801
if (value.size = 1) "-" else "--" end,
747802
value);
748803
if (instance?(option, <help-option>))
749804
// Handle --help early in case the remainder of the command line is
750805
// invalid or there are missing required arguments.
751-
print-help(parser, subcmd);
806+
print-help(cmd);
752807
abort-command(0);
753808
end;
754809
parse-option(option, parser);

0 commit comments

Comments
 (0)