Skip to content

ianthehenry/cmd

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

64 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

cmd

cmd is a Janet library for parsing command-line arguments. It features:

  • parsing named and positional arguments
  • autogenerated --help text
  • hierarchical subcommands
  • custom type parsers
  • two kinds of -- escapes
  • no dependencies
  • pure Janet

If you want to use cmd, add it to the dependencies in your project.janet file like this:

(declare-project
  :name "my-neat-command-line-app"
  :dependencies [
    {:url "https://github.com/ianthehenry/cmd.git"
     :tag "v1.1.0"}
  ])

Example

A minimal usage in a script looks like this:

(import cmd)

(cmd/def
  --greeting (optional :string "Hello")
  name :string)

(printf "%s, %s!" greeting name)
$ greet Janet
Hello, Janet!
$ greet Janet --greeting "Howdy there"
Howdy there, Janet!

While a compiled program looks like this:

(import cmd)

(cmd/main (cmd/fn
  [--greeting (optional :string "Hello")
   name :string]
  (printf "%s, %s!" greeting name)))

By adding a few more annotations, cmd will autogenerate nice --help output as well:

(import cmd)

(cmd/def "Print a friendly greeting"
  --greeting (optional :string "Hello")
    "What to say. Defaults to hello."
  name ["NAME" :string])

(printf "%s, %s!" greeting name)
$ greet --help
Print a friendly greeting

  greet NAME

=== flags ===

  [--greeting STRING] : What to say. Defaults to hello.
  [--help]            : Print this help text and exit

API

You will mostly use the following macros:

  • (cmd/def DSL) parses (cmd/args) immediately and puts the results in the current scope. You can use this to quickly parse arguments in scripts.
  • (cmd/fn "docstring" [DSL] & body) returns a simple command, which you can use in a cmd/group.
  • (cmd/group "docstring" & name command) returns a command made up of subcommands created from cmd/fn or cmd/group.
  • (cmd/main command) declares a function called main that ignores its arguments and then calls (cmd/run command (cmd/args)).

There are also some convenience helpers:

  • (cmd/peg name ~(<- (some :d))) returns an argument parser that uses the provided PEG, raising if the PEG fails to parse or if it does not produce exactly one capture. You can use this to easily create custom types.
  • (cmd/defn name "docstring" [DSL] & body) gives a name to a simple command.
  • (cmd/defgroup name "docstring" & name command) gives a name to a command group.

You probably won't need to use any of these, but if you want to integrate cmd into an existing project you can use some lower level helpers:

  • (cmd/spec DSL) returns a spec as a first-class value.
  • (cmd/parse spec args) parses the provided arguments according to the spec, and returns a table of keywords, not symbols. Note that this might have side effects if you supply an (effect) argument (like --help).
  • (cmd/run command args) runs a command returned by (cmd/fn) or (cmd/group) with the provided arguments.
  • (cmd/print-help command) prints the help for a command.
  • (cmd/args) returns (dyn *args*), normalized according to the rules described below.

There is currently no way to produce a command-line spec except by using the DSL, so it's difficult to construct one dynamically.

Aliases

You can specify multiple aliases for named parameters:

(cmd/def
  [--foo -f] :string)
(print foo)
$ run -f hello
hello

By default cmd will create a binding based on the first provided alias. If you want to change this, specify a symbol without any leading dashes:

(cmd/def
  [custom-name --foo -f] :string)
(print custom-name)
$ run -f hello
hello

Handlers

Named parameters can have the following handlers:

Count --param --param value
1 required
0 or 1 flag, effect optional
0 or more counted tuple, array, last
1 or more tuple+, array+, last+

Positional parameters can only have the values in the rightmost column.

There is also a special handler called (escape), described below.

(required type)

You can omit this handler if your type is a keyword, struct, table, or inline PEG. The following are equivalent:

(cmd/def
  --foo :string)
(cmd/def
  --foo (required :string))

However, if you are providing a custom type parser, you need to explicitly specify the required handler.

(defn my-custom-parser [str] ...)
(cmd/def
  --foo (required my-custom-parser))

(optional type &opt default)

(cmd/def
  --foo (optional :string "default value"))
(print foo)
$ run --foo hello
hello

$ run
default value

If left unspecified, the default default value is nil.

(flag)

(cmd/def
  --dry-run (flag))
(printf "dry run: %q" dry-run)
$ run
dry run: false

$ run --dry-run
dry run: true

(counted)

(cmd/def
  [verbosity -v] (counted))
(printf "verbosity level: %q" verbosity)
$ run
verbosity: 0

$ run -vvv
verbosity: 3

({array,tuple}{,+} type)

(cmd/def
  [words --word] (tuple :string))
(pp words)
$ run --word hi --word bye
("hi" "bye")

(tuple+) and (array+) require that at least one argument is provided.

(last type &opt default) and (last+ type)

last is like optional, but the parameter can be specified multiple times, and only the last argument matters.

last+ is like required, but the parameter can be specified multiple times, and only the last argument matters.

(cmd/def
  --foo (last :string "default"))
(print foo)
$ run
default

$ run --foo hi --foo bye
bye

(effect fn)

(effect) allows you to create a flag that, when supplied, calls an arbitrary function.

(cmd/def
  --version (effect (fn []
    (print "1.0")
    (os/exit 0))))
$ run --version
1.0

You usually don't need to use the (effect) handler, because you can do something similar with a (flag):

(cmd/def
  --version (flag))
(when version
  (print "1.0")
  (os/exit 0))
$ run --version
1.0

There are three differences:

  • (effect)s run even if there are other arguments that did not parse successfully (just as value parsers do).
  • (effect) handlers do not create bindings.
  • (effect) handlers run without any of the parsed command-line arguments in scope.

(effect) mostly exists to support the default --help handler, and is a convenient way to specify other "subcommand-like" flags.

(escape &opt type)

There are two kinds of escape: hard escape and soft escape.

A "soft escape" causes all subsequent arguments to be parsed as positional arguments. Soft escapes will not create a binding.

(cmd/def
  name :string
  -- (escape))
(printf "Hello, %s!" name)
$ run -- --bobby-tables
Hello, --bobby-tables!

A hard escape stops all argument parsing, and creates a new binding that contains all subsequent arguments parsed according to their provided type.

(cmd/def
  name (optional :string "anonymous")
  --rest (escape :string))

(printf "Hello, %s!" name)
(pp rest)
$ run --rest Janet
Hello, anonymous!
("Janet")

Positional arguments

You can mix required, optional, and variadic positional parameters, although you cannot specify more than one variadic positional parameter.

(cmd/def
  first (required :string)
  second (optional :string)
  third (required :string))
(pp [first second third])
$ run foo bar
("foo" nil "bar")

$ run foo bar baz
("foo" "bar" "baz")

The variadic positional parameter for a spec can be a hard escape, if it appears as the final positional parameter in your spec. The value of a hard positional escape is a tuple containing the value of that positional argument followed by all subsequent arguments (whether or not they would normally parse as --params).

Only the final positional argument can be an escape, and like normal variadic positional arguments, it will take lower priority than optional positional arguments.

(cmd/def
  name (optional :string "anonymous")
  rest (escape :string))

(printf "Hello, %s!" name)
(pp rest)
$ run Janet all the other args
Hello, Janet!
("all" "the" "other" "args")

Enums

If the type of a parameter is a struct, it should enumerate a list of named parameters:

(cmd/def
  format {--text :plain
          --html :rich})

(print format)
$ script --text
:plain

The keys of the struct are parameter names, and the values of the struct are literal Janet values.

You can use structs with the last handler to implement a toggleable flag:

(cmd/def
  verbose (last {--verbose true --no-verbose :false} false)

(print verbose)
$ run --verbose --verbose --no-verbose
false

You can specify aliases inside a struct like this:

(cmd/def
  format {[--text -t] :plain
          --html :rich})

(print format)
$ script -t
:plain

Variants

If the type of a parameter is a table, it's parsed similarly to an enum, but will result in a value of the form [:tag arg].

(cmd/def
  format @{--text :string
           --html :string})
(pp format)
$ run --text ascii
(:text "ascii")

$ run --html utf-8
(:html "utf-8")

You can also specify an arbitrary expression to use as a custom tag, by making the values of the table bracketed tuples of the form [tag type]:

(cmd/def
  format @{--text :string
           --html [(+ 1 2) :string]})
(pp format)
$ run --text ascii
(:text "ascii")

$ run --html utf-8
(3 "utf-8")

Argument types

There are a few built-in argument parsers:

  • :string
  • :file - like :string, but prints differently in help output
  • :number
  • :int - any integer, positive or negative
  • :int+ - non-negative integer (>= 0)
  • :int++ - positive integer (> 0)

You can also use any function as an argument. It should take a single string, and return the parsed value or error if it could not parse the argument.

There is also a helper, cmd/peg, which you can use to create ad-hoc argument parsers:

(def host-and-port (cmd/peg "HOST:PORT" ~(group (* (<- (to ":")) ":" (number :d+)))))
(cmd/def address (required host-and-port))
(def [host port] address)
(print "host = " host ", port = " port)

Help

cmd will automatically generate a --help flag that prints the full docstring for a command.

When printing the help for groups, cmd will only print the first line of each subcommand's docstring.

You can give useful names to arguments by replacing argument types with a tuple of ["ARG-NAME" type]. For example:

(def name ["NAME" :string])
(cmd/def 
  name (required name))
(printf "Hello, %s!" name)
$ greet --help
  script.janet NAME

=== flags ===

  [--help] : Print this help text and exit

If you're supplying an argument name for a required parameter, you must use an explicit (required) clause: --foo (required ["ARG" :string]), not --foo ["ARG" :string].

If you're writing a variant, the argument name must come after the tag:

(cmd/def 
  variant @{--foo [:tag ["ARG" :string]]})

Argument normalization

By default, cmd performs the following normalizations:

Before After
-xyz -x -y -z
--foo=bar --foo bar
-xyz=bar -x -y -z bar

Additionally, cmd will detect when your script is run with the Janet interpreter (janet foo.janet --flag), and will automatically ignore the foo.janet argument.

You can bypass these normalizations by using cmd/parse or cmd/run, which will parse exactly the list of arguments you provide them.

Missing features

These are not fundamental limitations of this library, but merely unimplemented features that you might wish for. If you wish for them, let me know!

  • You cannot make "hidden" aliases. All aliases will appear in the help output.
  • You cannot specify separate docstrings for different enum or variant choices. All of the parameters will be grouped into a single entry in the help output, so the docstring has to describe all of the choices.
  • There is no good way to re-use common flags across multiple subcommands.
  • There is no auto-generated shell completion file, even though we have sufficient information to create one.

Changelog

v1.1.0 - 2023-06-19

  • Docstrings no longer have to be string literals, so you can construct a dynamic docstring with (string/format ...). Note that the expression has to be a form to disambiguate it from a parameter name, so if you have the docstring in a variable already you have to write (|docstring) instead of docstring in order for the macro to parse it correctly.
  • cmd/parse now errors if there was a parse error, instead of returning just the arguments that parsed correctly

v1.0.4 - 2023-04-12

  • Fix --help output when used in a compiled executable.

v1.0.3 - 2023-04-12

  • Fix cmd when used in a compiled executable.

v1.0.2 - 2023-04-02

  • --help output only prints the basename of the executable in the usage line, regardless of the path that it was invoked with
  • --help output for grouped commands now includes the subcommand path in the usage line
  • positional arguments print more nicely in the usage line

v1.0.1 - 2023-03-22

  • improved error message for unknown subcommands when using cmd/group
  • cmd/peg can now take a pre-compiled PEG

v1.0.0 - 2023-03-05

  • Initial release.