From 763d20dd9968f84ca78c435e4a5a3cb28c65ec2f Mon Sep 17 00:00:00 2001 From: Carl Gay Date: Wed, 14 Jul 2021 03:55:50 +0000 Subject: [PATCH] Support multi-level subcommands Motivating use case: dylan new library --type exe my-lib dylan new workspace ... In programs with a fairly large set of subcommands this just reduces the top-level clutter and need to stuff everything into a top-level command by adding hyphens or what-have-you. By default subcommands support the `--help` option and there is also a top-level `help` subcommand that accepts any (multi-level) subcommand for which it displays help. For example, dylan new --help dylan help new workspace --- command-line-parser.dylan | 229 +++++++++++------- help.dylan | 94 ++++--- library.dylan | 3 +- parsers.dylan | 2 +- ...mmand-line-parser-test-suite-library.dylan | 8 +- tests/command-line-parser-test-suite.dylan | 2 +- tests/subcommands-test.dylan | 142 +++++++++-- 7 files changed, 321 insertions(+), 159 deletions(-) diff --git a/command-line-parser.dylan b/command-line-parser.dylan index 5d9e0db..fade93b 100644 --- a/command-line-parser.dylan +++ b/command-line-parser.dylan @@ -106,44 +106,71 @@ end function; //====================================================================== define abstract class () - constant slot parser-tokens :: = make(); // of: slot command-options :: = make(), init-keyword: options:; constant slot %command-help :: , required-init-keyword: help:; + // Subcommands may be arbitrarily nested. + slot command-subcommands :: = #[], + init-keyword: subcommands:; + slot selected-subcommand :: false-or() = #f; end class; -define method initialize (cmd :: , #key) => () +// The --help option is added by default but we provide a way to turn it off here. +define method initialize + (cmd :: , #key help-option? :: = #t, #all-keys) + => () + if (help-option?) + add-help-option(cmd); + end; next-method(); - validate-options(cmd.command-options); + validate-options(cmd); +end method; + +define open abstract class () + constant slot subcommand-name :: , + required-init-keyword: name:; +end class; + +define method debug-name + (subcmd :: ) => (name :: ) + subcmd.subcommand-name end method; -define function validate-options (options :: ) +define function validate-options + (cmd :: ) => () + let description = if (instance?(cmd, )) + format-to-string("Subcommand %=", cmd.subcommand-name) + else + "Command" + end; + if (cmd.has-subcommands? & cmd.positional-options.size > 0) + parser-error("%s has both subcommands and positional options", description); + end; // Don't care if positionals are mixed in with pass-by-names because // positional-options will extract them in order. let names = make(); let repeated-positional = #f; let optional-positional = #f; - for (option in options) + for (option in cmd.command-options) for (name in option.option-names) if (member?(name, names, test: \=)) - parser-error("Duplicate option name: %=", name); + parser-error("%s has duplicate option name: %=", description, name); end; add!(names, name); end; if (repeated-positional) - parser-error("only one repeated positional option (currently %=) is" - " allowed and it must be the last option", - repeated-positional.canonical-name); + parser-error("%s has options following repeated positional option %=", + description, repeated-positional.canonical-name); end; if (instance?(option, )) if (option.option-repeated?) repeated-positional := option; end; if (option.option-required? & optional-positional) - parser-error("required positional option %= may not follow" + parser-error("%s has required positional option %= following" " optional positional option %=", - option.canonical-name, + description, option.canonical-name, optional-positional.canonical-name); end; if (~option.option-required?) @@ -167,90 +194,105 @@ define function pass-by-name-options cmd.command-options) end function; -define open abstract class () - constant slot subcommand-name :: , - required-init-keyword: name:; -end class; - -define method debug-name - (subcmd :: ) => (name :: ) - subcmd.subcommand-name -end method; - -// Should this just be another method on execute-command instead? -define open generic execute-subcommand - (parser :: , subcmd :: ) - => (status :: false-or()); - -define method execute-subcommand - (parser :: , subcmd :: ) - => (status :: false-or()) - error("don't know how to execute subcommand %=. add an execute-subcommand method?", - subcmd); -end method; - -define open class () - slot parser-subcommands :: = #[], - init-keyword: subcommands:; - slot selected-subcommand :: false-or() = #f; -end class; - -// Help options are on by default but may be turned off. -define method initialize - (parser :: , - #key help-option? :: = #t, - help-subcommand? :: = #t, #all-keys) => () - next-method(); - if (help-option?) - add-help-option(parser); - end; - if (help-subcommand? & parser.has-subcommands?) - add-help-subcommand(parser); - end; -end method; - define function has-subcommands? - (parser :: ) => (_ :: ) - parser.parser-subcommands.size > 0 + (cmd :: ) => (_ :: ) + cmd.command-subcommands.size > 0 end; define generic find-subcommand - (parser :: , object) + (cmd :: , object) => (subcommand :: false-or()); define method find-subcommand - (parser :: , class :: subclass()) + (cmd :: , class :: subclass()) => (subcommand :: false-or()) - let subs = parser.parser-subcommands; + let subs = cmd.command-subcommands; let key = find-key(subs, rcurry(instance?, class)); key & subs[key] end method; define method find-subcommand - (parser :: , name :: ) + (cmd :: , name :: ) => (subcommand :: false-or()) - let subs = parser.parser-subcommands; + let subs = cmd.command-subcommands; let key = find-key(subs, method (subcmd) name = subcmd.subcommand-name end); key & subs[key] end method; +define method find-subcommand + (cmd :: , path :: ) + => (subcommand :: false-or()) + iterate loop (cmd = cmd, i = 0) + if (i >= path.size) + cmd + else + let subs = cmd.command-subcommands; + let name = path[i]; + let k = find-key(subs, method (subcmd) + name = subcmd.subcommand-name + end); + k & loop(subs[k], i + 1) + end + end +end method; + define function add-subcommand - (parser :: , subcmd :: ) => () + (cmd :: , subcmd :: ) => () let name = subcommand-name(subcmd); - if (parser.positional-options.size > 0) - parser-error("a command line parser may not have both positional" + if (cmd.positional-options.size > 0) + parser-error("a command may not have both positional" " options and subcommands"); end; - if (find-subcommand(parser, name)) + if (find-subcommand(cmd, name)) parser-error("a subcommand named %= already exists", name); end; - parser.parser-subcommands := add!(parser.parser-subcommands, subcmd); + cmd.command-subcommands := add!(cmd.command-subcommands, subcmd); end function; +define open generic execute-subcommand + (parser :: , subcmd :: ) + => (status :: false-or()); + +define method execute-subcommand + (parser :: , subcmd :: ) + => (status :: false-or()) + error("don't know how to execute subcommand %=. add an execute-subcommand method?", + subcmd); +end method; + +// A is just a top-level command that handles the overall command +// line processing. +define open class () + constant slot parser-tokens :: = make(); // of: +end class; + +define method initialize + (cmd :: , #key help-subcommand? :: = #t, #all-keys) + => () + next-method(); + // A "help" subcommand is added only if there are other subcommands since adding + // subcommands changes the way the overall command is parsed. + // + // TODO(cgay): This isn't called if someone uses the pattern + // let cmd = make(, ...no subcommands: argument...); + // add-subcommand(cmd, subcmd); + // So for now if you use that pattern you have to add the help subcommand manually. + // I don't like that it means there's a different level of knowledge necessary for + // that pattern. We could stash away the value of `help-subcommand?` here and use + // it later if add-subcommand is called. I want to see how the new macros look before + // deciding how to handle this though. + if (help-subcommand? & cmd.has-subcommands?) + add-help-subcommand(cmd); + end; +end method; + +// This wasn't really well thought out. It's only useful to call if you have subcommands +// and each one has its own subclass. define generic execute-command - (parser :: ) => (status :: false-or()); + (parser :: ) + => (status :: false-or()); define method execute-command (parser :: ) => (status :: false-or()) @@ -272,9 +314,8 @@ define generic add-option (cmd :: , option ::