Packages

A clap-inspired CLI framework for Elixir. Declarative command trees with typed options, validation, env var fallback, lifecycle hooks, param groups, shell completion, REPL mode, and in-process testing.

Current section

Files

Jump to
cheer lib cheer command dsl.ex
Raw

lib/cheer/command/dsl.ex

defmodule Cheer.Command.DSL do
@moduledoc """
Macros for declaring commands, arguments, options, subcommands,
lifecycle hooks, param groups, and validation.
"""
@doc """
Define a command block with the given `name`.
All DSL calls (`about`, `argument`, `option`, `subcommand`, etc.) go inside the block.
command "deploy" do
about "Deploy the app"
option :env, type: :string, required: true
end
"""
defmacro command(name, do: block) do
quote do
@cheer_command_name unquote(name)
unquote(block)
end
end
@doc "Set the command's description text, shown in help output."
defmacro about(text), do: quote(do: @cheer_about(unquote(text)))
@doc "Set the command's extended description, shown by `--help` (long form)."
defmacro long_about(text), do: quote(do: @cheer_long_about(unquote(text)))
@doc "Set the command's version string, printed by `--version` / `-V`."
defmacro version(text), do: quote(do: @cheer_version(unquote(text)))
@doc "Set text displayed before the auto-generated help."
defmacro before_help(text), do: quote(do: @cheer_before_help(unquote(text)))
@doc "Set text displayed after the auto-generated help."
defmacro after_help(text), do: quote(do: @cheer_after_help(unquote(text)))
@doc ~s|Set subcommand aliases (e.g. `aliases ["co", "ck"]` for `checkout`).|
defmacro aliases(list), do: quote(do: @cheer_aliases(unquote(list)))
@doc "Override the auto-generated usage line in help output."
defmacro usage(text), do: quote(do: @cheer_usage(unquote(text)))
@doc """
Hide this command from its parent's help, shell completion, and unknown-command
listings. The command stays fully dispatchable; only its visibility is affected.
Useful for internal, experimental, or legacy commands.
command "debug" do
hide true
end
"""
defmacro hide(val \\ true), do: quote(do: @cheer_hide(unquote(val)))
@doc """
Mark this command as deprecated. Shows a `(deprecated)` marker in the parent's
help and prints a warning to stderr when the command is used. Pass a string to
include a reason (and a migration hint).
command "old-name" do
deprecated "use `new-name` instead"
end
"""
defmacro deprecated(val \\ true), do: quote(do: @cheer_deprecated(unquote(val)))
@doc "Require that a subcommand is provided (error instead of showing help)."
defmacro subcommand_required(val), do: quote(do: @cheer_subcommand_required(unquote(val)))
@doc "Propagate this command's version to all subcommands."
defmacro propagate_version(val), do: quote(do: @cheer_propagate_version(unquote(val)))
@doc """
Allow matching subcommands from unambiguous name prefixes.
When enabled, `che` will resolve to `checkout` if no other subcommand starts
with `che`. Ambiguous prefixes produce an error listing the candidates.
Exact matches always take precedence over prefix inference. Matching is
done against canonical names only -- aliases are not prefix-matched.
"""
defmacro infer_subcommands(val), do: quote(do: @cheer_infer_subcommands(unquote(val)))
@doc """
Allow this command to accept unknown subcommands (e.g. plugins on `$PATH`).
When enabled, any argv token that does not match a declared subcommand and is
not an option is captured and surfaced to `run/2` via
`args[:external_subcommand]` as `{name, rest_argv}`. Declared subcommands
still take precedence. Commands that opt in should generally not also declare
positional arguments -- leftover positionals are routed to the external-sub
capture, not to arguments.
Example: a `git`-style plugin dispatcher that execs `git-<name>` from `$PATH`.
command "git-like" do
external_subcommands(true)
# ... declared subs, options ...
end
def run(args, _raw) do
case args[:external_subcommand] do
{name, rest} -> System.cmd("git-\#{name}", rest)
nil -> :ok
end
end
"""
defmacro external_subcommands(val),
do: quote(do: @cheer_external_subcommands(unquote(val)))
@doc """
Make subcommands optional so an unknown first token falls through to this
command's own positional arguments.
Without this, a command that declares both `argument`s and `subcommand`s
treats an unrecognized first token as an unknown command and errors. With it,
a token that does not match a declared subcommand is parsed as a positional
argument and option parsing continues across it, so the parent command runs.
Declared subcommands still take precedence: an exact (or, with
`infer_subcommands`, an unambiguous prefix) match dispatches to the
subcommand.
command "roba" do
args_conflicts_with_subcommands(true)
argument :prompt, type: :string, required: false
subcommand Roba.Commands.History
end
`roba history` dispatches to the subcommand, while `roba "summarize this"
--model haiku` runs the parent with `prompt: "summarize this"` and
`model: "haiku"`.
"""
defmacro args_conflicts_with_subcommands(val),
do: quote(do: @cheer_args_conflicts_with_subcommands(unquote(val)))
@doc """
Set this command's display order as a subcommand in its parent's help output.
Lower numbers appear first. Commands without an explicit order fall back to
declaration order in the parent.
"""
defmacro display_order(n), do: quote(do: @cheer_display_order(unquote(n)))
@doc """
Declare a named trailing variable argument.
Everything after the last declared positional (or after `--`) is collected
and available as `args[name]` instead of the default `:rest` key. The name
and help text are shown in the usage and help output.
Options:
* `:help` - help text shown in `--help`
* `:required` - `true` if at least one trailing arg must be provided
"""
defmacro trailing_var_arg(name, opts \\ []) do
quote do
@cheer_trailing_var_arg {unquote(name), unquote(opts)}
end
end
@doc "Register a child subcommand module."
defmacro subcommand(module), do: quote(do: @cheer_subcommands(unquote(module)))
@doc """
Declare a positional argument.
Arguments are matched in declaration order. Options:
* `:type` - `:string` (default), `:integer`, `:float`, or `:boolean`
* `:required` - `true` or `false` (default)
* `:num_args` - collect several positional tokens into a list: an integer for
an exact count or a range (`1..3`) for a variable count. Consumption is
greedy (up to the max), so a variadic argument should be declared last. Too
few values is a usage error.
* `:choices` - list of allowed values
* `:help` - help text shown in `--help`
* `:long_help` - extended help text shown by `--help` (long form)
* `:value_name` - placeholder name in help (e.g. `"FILE"`)
* `:hide` - `true` to hide from help output
* `:deprecated` - `true` for a bare marker, or a string reason; shown in help
* `:display_order` - integer controlling position in help (lower first)
* `:validate` - `fn value -> :ok | {:error, msg} end`
* `:parse` - `fn value -> {:ok, parsed} | {:error, msg} end` to transform the
value into a domain type (runs after coercion, before `:validate`)
"""
defmacro argument(name, opts \\ []) do
{validate_ast, opts} = Keyword.pop(opts, :validate)
{parse_ast, clean_opts} = Keyword.pop(opts, :parse)
quote do
@cheer_arguments {unquote(name), unquote(clean_opts)}
unquote(accessor_fn(:validate, name, validate_ast))
unquote(accessor_fn(:parse, name, parse_ast))
end
end
# Anonymous functions in DSL opts (:validate, :parse) cannot be Macro.escape'd
# into module attributes, so generate a named accessor and record the name; the
# compiler reattaches the fn into the param opts. Returns nil (a no-op in the
# surrounding quote block) when the option was not given.
defp accessor_fn(_kind, _name, nil), do: nil
defp accessor_fn(:validate, name, ast) do
quote do
@cheer_has_validate unquote(name)
def unquote(:"__cheer_validate_#{name}__")(val), do: unquote(ast).(val)
end
end
defp accessor_fn(:parse, name, ast) do
quote do
@cheer_has_parse unquote(name)
def unquote(:"__cheer_parse_#{name}__")(val), do: unquote(ast).(val)
end
end
@doc """
Declare a named option (flag).
Options:
* `:type` - `:string` (default), `:integer`, `:float`, `:boolean`, or `:count`
* `:short` - single-character alias atom (e.g. `:p` for `-p`)
* `:required` - `true` or `false` (default)
* `:default` - default value when not provided (`:count` defaults to `0`, `:multi` defaults to `[]`)
* `:default_missing_value` - value used when the flag is present with no value
(`--color` alone). Distinct from `:default` (flag absent). An explicit value
must use the `--flag=value` form; `--flag value` leaves `value` as a positional.
* `:multi` - `true` to allow repeated flags collected into a list (e.g. `--tag a --tag b`)
* `:num_args` - collect several values from a single flag invocation into a list:
an integer for an exact count (`num_args: 2` accepts `--point 1 2`) or a range
for a variable count (`num_args: 1..3`). Collection stops at the next flag or
`--`. Distinct from `:multi`, which repeats the flag. An out-of-range count is a
usage error. Negative numbers are always accepted as values (`--range -5 5`).
* `:allow_hyphen_values` - `true` to accept a value that starts with `-`, such
as `--pattern -v` or `--range -a -b` (with `num_args`). Without it, only
negative numbers are accepted as hyphen-leading values.
* `:value_delimiter` - split a single value on this string into a list, e.g.
`value_delimiter: ","` makes `--tags a,b,c` yield `["a", "b", "c"]`. Each
element is coerced to `:type` and checked against `:choices`. Combines with
`:multi` (each occurrence is split and the results flattened).
* `:parse` - `fn value -> {:ok, parsed} | {:error, msg} end` to transform the
value into a domain type (an atom, a `Date`, a struct). Runs after `:type`
coercion and `:value_delimiter` splitting, before `:choices` and `:validate`.
For a list value (`:multi`, `:num_args`, `:value_delimiter`) it is applied to
each element.
* `:env` - environment variable name to read as fallback
* `:choices` - list of allowed values
* `:help` - help text shown in `--help`
* `:long_help` - extended help text shown by `--help` (long form)
* `:value_name` - placeholder name in help (e.g. `"FILE"`)
* `:hide` - `true` to hide from help output
* `:deprecated` - `true` for a bare marker, or a string reason; shown in help
and warned to stderr when the option is used
* `:global` - `true` to propagate to all subcommands
* `:aliases` - list of alternative long names (e.g. `[:colour]` for `:color`)
* `:display_order` - integer controlling position within its help section (lower first)
* `:help_heading` - string; options sharing a heading are grouped under it in help
* `:conflicts_with` - atom or list of atoms; this option cannot be used with the named option(s)
* `:requires` - atom or list of atoms; the named option(s) must also be present
* `:required_if` - keyword list `[other_opt: value]`; this option is required when
any pair matches (i.e. `args[other_opt] == value`)
* `:required_if_all` - keyword list `[other_opt: value]`; required only when
every pair matches
* `:required_unless` - atom or list of atoms; this option is required unless any
of the named options are present
* `:required_unless_all` - list of atoms; required unless all of the named
options are present
* `:validate` - `fn value -> :ok | {:error, msg} end`
Boolean options automatically support `--no-<name>` negation (e.g. `--no-color`).
Extra positional arguments after `--` are collected into `args[:rest]`.
"""
defmacro option(name, opts \\ []) do
{validate_ast, opts} = Keyword.pop(opts, :validate)
{parse_ast, clean_opts} = Keyword.pop(opts, :parse)
quote do
@cheer_options {unquote(name), unquote(clean_opts)}
unquote(accessor_fn(:validate, name, validate_ast))
unquote(accessor_fn(:parse, name, parse_ast))
# If inside a group block, register this option in the group
if @cheer_current_group do
{group_name, group_opts} = @cheer_current_group
@cheer_groups {group_name, group_opts, unquote(name)}
end
end
end
@doc "Cross-parameter validation function. Receives args map, returns `:ok` or `{:error, msg}`."
defmacro validate(fun) do
count = next_hook_index(__CALLER__.module, :cheer_validator_count)
quote do
def __cheer_cross_validate__(unquote(count), args) do
unquote(fun).(args)
end
end
end
# -- Lifecycle hooks --
@doc "Run a function on args before `run/2`. Receives and returns args map."
defmacro before_run(fun) do
count = next_hook_index(__CALLER__.module, :cheer_before_run_count)
quote do
def __cheer_before_run__(unquote(count), args) do
unquote(fun).(args)
end
end
end
@doc "Run a function on the result after `run/2`. Receives and returns result."
defmacro after_run(fun) do
count = next_hook_index(__CALLER__.module, :cheer_after_run_count)
quote do
def __cheer_after_run__(unquote(count), result) do
unquote(fun).(result)
end
end
end
@doc "Like `before_run`, but inherited by all child subcommands."
defmacro persistent_before_run(fun) do
count = next_hook_index(__CALLER__.module, :cheer_persistent_before_run_count)
quote do
def __cheer_persistent_before_run__(unquote(count), args) do
unquote(fun).(args)
end
end
end
# Read and increment a per-kind hook counter at macro-expansion time so each
# generated clause head carries a distinct integer literal (e.g. `(0, args)`,
# `(1, args)`). Using the counter's value here rather than `Macro.var(:count)`
# is what keeps later clauses from being shadowed by clause 0. Because macros
# expand in source order, the returned index matches declaration order.
defp next_hook_index(module, attr) do
count = Module.get_attribute(module, attr) || 0
Module.put_attribute(module, attr, count + 1)
count
end
# -- Param groups --
@doc """
Define a named group of options with a constraint.
Supports:
* `mutually_exclusive: true` -- at most one option in the group can be set
* `co_occurring: true` -- all or none of the options must be set
* `required: true` -- at least one option in the group must be set
Constraints combine: `mutually_exclusive: true, required: true` means exactly
one member must be set.
"""
defmacro group(name, opts, do: block) do
quote do
@cheer_current_group {unquote(name), unquote(opts)}
unquote(block)
@cheer_current_group nil
end
end
end