argtype
A type language for command-line argument grammars.
Introduction
argtype describes the structure of valid command-line invocations as grammars over string arrays. It describes an interface that already exists: programs are still invoked with a plain string[] argv, and argtype writes down which arrays are valid and what they mean. It is not a new calling convention, and it is not tied to any particular tool ecosystem or programming language.
An argtype definition fully describes the shape of a CLI's arguments (everything after argv[0]). Tool-level metadata (executable name, version, container, etc.) lives outside the grammar in frontmatter or root-level attributes.
Applications
A single argtype definition is a complete structural description of a CLI's arguments. Different consumers walk the same grammar for different purposes:
| Application | What it does with the grammar |
|---|---|
| IDE support | Intellisense, diagnostics, and docs on hover for CLI invocations based on valid tokens and types at any position |
| Terminal emulators | Inline completions, argument previews, and contextual documentation as you type |
| Typed wrapper generation | Derive a typed parameter interface, emit language-specific code that builds argv from parameter values |
| Agents | Hand an agent the typed parameter interface the grammar implies; it fills the parameters instead of guessing an argv string, and the choices can be checked against the grammar before the command runs |
| GUI / form generation | Generate interactive forms or configuration UIs from the argument structure, types, and value constraints |
| Shell autocomplete | Derive bash/zsh/fish completions from the grammar's knowledge of valid tokens, choices, and types |
| Parsing | Given an argv, match it against the grammar to extract typed parameter values |
| Documentation | Generate man pages, help text, and usage strings from the structure and doc comments |
| Validation | Check that a given invocation is well-formed: required arguments present, value constraints satisfied, alternatives consistent (inter-argument constraints via the constraints extension) |
| Schema generation | Emit JSON Schema, OpenAPI, or similar from the type structure |
All of these read the same grammar.
Formal properties
argtype describes regular languages over string arrays. The combinators map directly to regular language operations:
| Combinator | Regular operation |
|---|---|
seq | Concatenation |
alt | Union |
any | Union (like alt, but the choice is marked not meaningful) |
opt | Optional (union with empty string) |
rep | Kleene star |
set | Permutation (finite union of all orderings) |
There is no recursion. Definitions cannot form cycles. This means:
- Parsing can be done in linear time (DFA/NFA).
- Autocomplete at any position is a finite, computable set.
- Validation reduces to regular language membership.
It also means argtype cannot express:
- Recursive structures (e.g.
find's arbitrarily nested-\( ... -\)groups). Nesting to a fixed depth is fine; only unbounded recursion is outside the regular language. - Context-sensitive constraints (these live in metadata or validation layers, not the grammar)
Inter-argument dependencies ("if -a is present, -b is required") can be modeled by enumerating the valid combinations with alt. That's verbose, but it stays within the regular language and keeps the dependency structure visible. Implicit constraint annotations for the same purpose are kept out of the core (they can blow up generated type signatures) and live in the opt-in constraints extension. This is separate from value constraints like int.min(1), which refine a single terminal and are core. See Value Constraints.
Most real CLI grammars are regular, so this isn't much of a restriction, and staying inside regular languages is what keeps the tooling fast.
Design principles
- String array model: a CLI invocation is a
string[]. Every node in the grammar produces zero or more elements in that array. - No CLI-specific concepts: there are no "flags", "options", or "positionals" baked in. A flag is just
seq("-f", float). A positional is a bare terminal. - Two notations: a sugar DSL for hand-authoring, and a chaining API embeddable in any host language. Both produce the same underlying representation.
- Metadata via chaining: constraints, defaults, and other metadata attach to nodes via
.method()chaining. Common cases get sugar (= valuefor defaults,///for docs). - Composable naming:
label: exprnames any node. It is sugar for.name("label").
Core and Extensions
argtype is defined in two layers.
The core answers "is this argv well-formed, and what typed parameters does it imply?". It's the combinators, terminals, literals, naming, aliases, .join(), .count(), .default(), value constraints, and doc comments. Every consumer is expected to understand the core. Parsing, well-formedness validation, autocomplete, wrapper generation, GUI generation, and documentation generation all read from the core alone. Everything in the rest of this document, unless marked otherwise, is core.
Extensions attach additional metadata for consumers that want it: outputs, inter-argument constraints, community-defined vocabularies. They are governed by two rules:
- Annotation-only. An extension may attach metadata to a node. It must never change the node's structural interpretation or the regular language the grammar describes. Anything that changes the shape of valid argv (like
.join()or.count()) belongs in the core, not an extension. - Ignorable. A consumer that does not understand an extension must ignore its annotations and process the node as if they were absent. A grammar stays valid (just less informative) for consumers that don't implement a given extension.
A document does not declare which extensions it uses. The extensions are implied by the annotations present: a consumer that implements a given extension reads its annotations, and one that does not ignores them (rule 2 above). There is no per-document manifest to keep in sync - a declaration that merely restated the annotations already in the document would be redundant, and an author-side declaration cannot protect a consumer anyway (the author who writes .output() is exactly who would not forget to declare outputs). If capability negotiation is ever needed, the right place for it is the consumer declaring what it supports, not the document restating what it uses.
Chaining methods share one flat namespace across the core and all extensions, with no vendor prefixes. (Prefixing in the -webkit-/X- style is widely considered a mistake: prefixes leak into the de facto standard and then force renames.) Core method names are reserved; extensions add to the same namespace and must not collide with a core name or with each other. Coordinating that space (the way JSON Schema vocabularies share one keyword space) is left to the extension ecosystem as it grows; with only a handful of first-party extensions today, there is no formal registry.
| Extension | Adds | Status |
|---|---|---|
outputs | .output() and output path templates (files a tool produces) | defined |
constraints | Inter-argument constraints (.requires(), .conflicts(), …) for validation-only consumers | draft |
mediatypes | .mediaType(mime) on a path (media type of the referenced file) | defined |
paths | .mutable() / .resolveParent() on a path (how a runner stages the file) | defined |
String Array Model
Each node type contributes elements to the argv array:
| Node | Argv contribution |
|---|---|
"literal" | One element: the literal string |
Terminal (int, float, str, path) | One element: the string representation of the value |
seq(a, b, ...) | Elements of a, then b, etc., in order |
set(a, b, ...) | Elements of all children, unordered |
opt(a) | Elements of a, or nothing |
rep(a) | Elements of a repeated zero or more times |
alt(a, b, ...) | Elements of whichever alternative is chosen |
The .join(separator?) modifier collapses a node's entire subtree into a single string element by string-interpolating all child elements with the given separator (default: ""):
rep(path) → ["a.txt", "b.txt", "c.txt"] (3 elements)
rep(path).join(",") → ["a.txt,b.txt,c.txt"] (1 element)
seq("--x=", int).join() → ["--x=42"] (1 element)
seq("--x=", int).join("") → ["--x=42"] (same)Micro-syntax
Many CLI tools pack structure within a single argument: key=value pairs, comma-separated lists, filter graphs (ffmpeg -vf "scale=1920:1080,fps=30"), etc. This intra-argument structure is called micro-syntax.
.join() is the mechanism that lets argtype describe micro-syntax: model the internal structure with combinators, then collapse it into one argv element. The grammar still captures the structure for typing, completion, and documentation, while the join produces the single-string form the tool actually expects.
KeyValue = seq(str, "=", str).join()
// value: key "=" val → argv: ["key=val"]
/// ffmpeg video filter micro-syntax: -vf "scale=1920:1080,fps=30"
Filter = seq(str, "=", rep(str).join(":")).join()
seq("-vf", filters: rep(Filter).join(","))
// → argv: ["-vf", "scale=1920:1080,fps=30"]Within the limits of a regular language, argtype can describe any micro-syntax that has a fixed, non-recursive structure.
Terminal Types
Terminals produce a single argv element: the string representation of a typed value.
| Terminal | Description |
|---|---|
int | Integer value |
float | Floating-point value |
str | Arbitrary string value |
path | File system path |
Literals
Any quoted string is a literal, a fixed string token in the argv array:
"--verbose"
"-f"
"run"Literals carry no parametrization. They appear as-is in the command line.
Structural Combinators
When opt or rep receive multiple children, they are implicitly wrapped in a seq:
opt("-q", quality: float)
// is equivalent to:
opt(seq("-q", quality: float))This means opt("-q", quality: float) represents an optional group where both the literal "-q" and the float value appear together or not at all.
seq, set, alt, and any take multiple children natively. Use parentheses for anonymous sequences within them (see alt).
seq: Ordered Sequence
An ordered sequence of nodes. Children contribute argv elements in the specified order.
seq("--input", path, "--output", path)
→ ["--input", "/in.png", "--output", "/out.png"]Parenthesized comma-separated items are anonymous sequences when nested inside other combinators:
alt(
("--fast", threshold: float),
("--robust", iterations: int, tolerance: float),
)
// equivalent to:
alt(
seq("--fast", threshold: float),
seq("--robust", iterations: int, tolerance: float),
)set: Unordered Collection
An unordered collection of nodes. All children are required, but their relative order in argv is not meaningful.
seq(
input: path,
set(
opt("-q", quality: float),
opt("-o"),
opt("-v"),
),
output: path,
)Here input and output are ordered (positional) via the outer seq, while the nodes inside set can appear in any order. The argument builder may emit set children in any order. The parser must accept them in any order. The solver does not parametrize ordering.
set is to seq what any is to alt: same members, but one structural dimension is declared not meaningful. For set it's the order of the members; for any, which branch. A builder picks freely, and the derived interface doesn't surface the choice.
All set children are required unless explicitly wrapped in opt. In the example above, input and output must always be present, while each opt(...) may or may not appear.
Mutual exclusivity inside set
Use alt inside set to express mutually exclusive groups within an unordered collection:
set(
opt("-v"),
opt("-q", quality: float),
mode: alt(
("-t", text_input: path),
("-b", binary_input: path),
),
)The alt is one required member of the set (exactly one alternative must be chosen), alongside the optional flags.
opt: Optional
A node that may or may not be present in the argv.
opt("-v") // present or absent
opt("-f", intensity: float) // the whole group is optional
opt("-t", int, int, int) // optional group of 4 elementsWhen opt wraps a literal with no further children, the solver resolves it to a bool binding (the presence/absence of that token), defaulting to false (absent). When it wraps a literal with value children, it resolves to an optional<T> binding.
rep: Repetition
A node that can repeat zero or more times.
rep("-v") // zero or more "-v" tokens → count
rep(seq("-i", input: path)) // zero or more flag-value pairs → listWhen rep wraps a literal with no value children, the solver resolves it to a count binding. Otherwise it resolves to a list<T>.
Repetition count can be constrained:
rep(int).count(3) // exactly 3 ints (e.g. 3D coordinate)
rep(path).countMin(1).countMax(10) // 1 to 10 pathsalt: Alternative
Exactly one of the given alternatives. Can use the alt(...) combinator or the | infix operator.
// combinator form, for complex alternatives
alt(
("--fast", threshold: float),
("--robust", iterations: int),
)
// infix form, for simple alternatives
mode: "--fast" | "--slow" | "--balanced"Both forms are equivalent. | binds tighter than both label: and ,, so:
"--mode", mode: "fast" | "slow"
// parses as: seq("--mode", name("mode", alt("fast", "slow")))
// not: alt(seq("--mode", "fast"), "slow")Discriminants
When the arms are bare literals, the alternative is a string enum - the value is the chosen literal, and no discriminant is needed:
mode: "fast" | "slow" | "accurate"When arms carry data (they are structs, not literals), a consumer that turns the alternative into a tagged union needs a discriminant to tell the arms apart. argtype uses the arm's label for this, scoped to the union:
// discriminants: "value", "image"
mul: alt(
value: float,
image: path,
)
// discriminants: "add", "mul" (label the arm, not just its inner field)
op: alt(
add: ("-add", amount: float),
mul: ("-mul", amount: float),
)Two consequences worth knowing:
- Label the arm, not only its inner field. An unlabeled data-bearing arm has no name to use as a discriminant, so a generator falls back to a positional tag (
variant_0,variant_1). Give each arm alabel:to get a meaningful discriminant. - Discriminants are local to their union. Because the label alone is the tag, two arms in the same alternative must have distinct labels (
value/image, not two arms both labelledinput). This keeps tags short and readable without a global prefix; it is the argtype author's job to make sibling arm labels distinct, exactly as with sibling field names in aseq/set. A consumer that finds a collision (in union arms orseq/setfields) may disambiguate it (e.g. by suffixing_2) and should warn rather than silently drop a variant or field.
any: Interchangeable Alternative
Like alt (exactly one of the given branches), but the choice itself carries no information: the branches are interchangeable forms of the same thing. any is to alt what set is to seq: same members, with the distinguishing dimension (which branch) declared not meaningful.
any("--output", "-output", "-o")
// parses: any one of these three tokens
// emits: "--output" (the first branch)Consequences:
- Parsers / validators / autocomplete treat
anyexactly likealt: all branches are accepted. (As a regular language,any(a, b)isa | b.) - Argument builders / wrapper & API generators emit the first branch and don't surface the choice as a parameter, just as they may emit
setmembers in any order. - The solver produces no binding for the
anyitself, and branches are expected to be binding-compatible: if they contain named nodes, the names and types should line up (in practiceanywraps bare literal forms, or the bindings live outside it). Branch 0 is authoritative.
any has no infix operator (like set, it is combinator-only). Use parentheses for anonymous sequences inside it, as with alt.
The canonical use is a flag that accepts several syntactic forms:
Output = seq(any("--output", "-output", "-o"), path)
opt(any(Output, Output.join("=")))
// accepts: ["--output", "x"] | ["-output", "x"] | ["-o", "x"] | ["--output=x"] | ...
// emits: ["--output", "x"]See Common option-form alternatives for the full pattern.
Naming
label: expr attaches a name to any node. It is sugar for expr.name("label").
// sugar
quality: int
// desugared
int.name("quality")Names serve two purposes:
- The solver uses them to generate binding names in the parametrization.
- Extensions can reference named nodes by name (the
outputsextension does this in its path templates).
Names can appear at any level:
convert: set(
opt("-quality", quality: int = 80),
thumbnail: opt("-thumbnail", size: str),
input: path,
)Quoted labels
A label is normally a bare identifier. When a node's name is not a valid identifier - it starts with a digit, or contains ., -, @, or other non-identifier characters, as many real tool and option names do (1deval, 3dQwarp, 1d_tool.py, @Atlasize) - write the label as a quoted string:
"1deval": seq(
expression: str,
opt("-1D", "1D": float),
)A quoted label is sugar for .name("...") with that exact string. It desugars identically to a bare label (quality: int and "quality": int are the same), so the two forms are interchangeable; use a bare identifier when you can and quote only when the name requires it.
Quoting is deliberately the escape hatch rather than a looser identifier grammar. Characters that appear in real names - . (also the method-chain operator), (, ,, : - are structural, and a digit-leading bare word is ambiguous with a number, so an explicit quote is the only way to carry an arbitrary name unambiguously. (An outputs template references a node by bare name, so a node that must be referenced from a template should still be given an identifier-safe name.)
Type Aliases
Name = expr defines a type alias. Aliases are pure substitution: wherever the alias is used, the expression is inlined. They add no new expressive power but improve readability and reduce repetition.
Dimension = rep(int).count(2)
KeyValue = seq(str, "=", str).join()
convert: seq(
input: path,
output: path,
set(
opt("-resize", size: Dimension),
opt("--define", defs: rep(KeyValue)),
),
)Being pure substitution, an alias may also wrap extension methods: Image = path.mediaType("image/png") works in any document whose consumer implements the mediatypes extension.
Aliases use PascalCase by convention. They must be non-recursive (an alias cannot reference itself or form a cycle).
The distinction from naming: name: expr (with :) attaches a name to a node in the grammar tree. Name = expr (with =) defines a reusable alias that is expanded at every use site.
Documentation
/// doc comments attach documentation to the following node:
/// Output image quality percentage.
/// Higher values produce larger files with less compression.
opt("-quality", quality: int = 80)Regular comments use // and are not attached to nodes.
Within a /// block the text reflows like Markdown prose: a single line break is a soft wrap (the lines join with a space) and a blank line (an empty ///) starts a new paragraph. Wrap a long description across several /// lines for readability - it renders as flowing paragraphs, not hard-broken lines. (A chained .description(...) sets its text verbatim, without reflow.)
Title and description
By default a /// block is the node's description. To also give it a short title, make the first line a Markdown H1 heading (# Title); everything after it (past the blank line) is the description.
/// # Fractional intensity threshold
///
/// Smaller values give larger brain outline estimates.
/// The valid range is 0 to 1.
opt("-f", fractional_intensity: float.min(0).max(1) = 0.5)
// title: "Fractional intensity threshold"
// description: "Smaller values give larger brain outline estimates. The valid range is 0 to 1."A block with no leading # heading is description-only - no title, no matter how many paragraphs it has:
/// Input image to process.
input: pathOnly a leading H1 (# on the first line) is a title; a ## sub-heading or a # anywhere else stays part of the description. A block that is just /// # Title is title-only. The title is a node's short label / summary and the tool's title at the root; the description is the long-form text. Consumers that only want one string use the description (falling back to the title when there is no description).
Programmatic equivalent: .title("...") sets the title and .description("...") sets the description. Unlike a /// block, these set each field verbatim - a leading # in .description("...") is literal text, not a title.
When a node has both a /// block and a chained .title() / .description(), the /// block is applied first and wins; a later .title() or .description() only fills in a part the block left unset.
Metadata Chaining
Metadata attaches to the node it describes via .method() chaining. Each piece of metadata belongs to the node it is semantically about.
Type-specific methods apply only to the node kinds that can carry them: .min()/.max() to numeric (int/float) terminals, .count()/.countMin()/.countMax() to rep, .join() to seq/set/rep/opt, and .mediaType()/.mutable()/.resolveParent() to path. Applying one to an incompatible node is an error, not a warning: silently dropping it would change the argv the grammar accepts or produces (or quietly discard a declared value/arity constraint) with no signal, so a consumer that implements the method must reject the document rather than ignore the misplacement. (.default() / = value is the exception: a default never changes which argv is valid, so a misplaced one cannot ship a wrong wrapper. It is meaningful on a terminal and on the combinators that model a defaultable value - opt, alt, rep. Only on a seq/set struct is a default meaningless; there it is dropped with a warning rather than rejected as an error.)
Defaults
= value (sugar) or .default(value) attaches a default value to a terminal (or to an opt/alt/rep; on a seq/set struct a default is meaningless and is dropped with a warning, per Metadata Chaining).
// sugar
quality: int = 80
// chaining
quality: int.default(80)The default belongs to the terminal node, not to a parent opt. It describes what value to use when the user doesn't provide one.
= value is exactly sugar for .default(value), with no positional restriction: it may follow a method chain too. int.min(1).max(100) = 80 is equivalent to int.min(1).max(100).default(80). (Earlier drafts restricted = to a bare terminal, forcing a switch to .default(...) the moment any method was chained; that asymmetry was a footgun and has been removed.)
Value Constraints
These refine the value a single terminal accepts, narrowing the set of strings it matches. They are part of the core.
iterations: int.min(1).max(100)
quality: int.min(1).max(100)Note: enumerated string values are expressed as alternatives, not as a constraint on str. Use mode: "fast" | "robust" | "accurate" instead.
Two things that look like value constraints but aren't core:
- Media type of the file a
pathrefers to (path.mediaType("image/png")). That's metadata about the file's contents, not the argv element, so it lives in themediatypesextension. - Inter-argument constraints ("if
-ais present,-bis required"): see Core and Extensions and theconstraintsextension.
Repetition Count
.count(n) fixes the count to exactly n. For a range, use .countMin(n) and .countMax(n), which mirror the .min() / .max() value constraints on terminals - they compose, and either may be used on its own for a one-sided bound.
coord: rep(int).count(3) // exactly 3
inputs: rep(path).countMin(1).countMax(10) // 1 to 10
at_least: rep(str).countMin(1) // 1 or more
at_most: rep(int).countMax(4) // 0 to 4.count(n) is sugar for .countMin(n).countMax(n).
Join
.join(separator?) collapses a node's subtree into a single argv element by string-interpolating all child elements. The separator defaults to "" when omitted.
rep(path).join(",")
// values: ["/a.txt", "/b.txt"] → argv: ["/a.txt,/b.txt"]
seq("--x=", int).join()
// value: 42 → argv: ["--x=42"]Without .join, each child produces its own argv element.
.join() is meaningful only on the multi-element structures (seq, set, rep, opt). On any other node - a terminal or literal (already one argv element), or an alt (where a join has no defined meaning) - it is an error, per the metadata-chaining rule: it almost always signals the author meant to join an enclosing seq/rep, and silently accepting it would hide that mistake.
Outputs (extension)
Declaring that a node produces an output file (and describing that file's path) is done by the outputs extension, not the core: outputs describe what the program does, not which argv is valid, and are advisory rather than authoritative. See the extension for .output() and its template syntax.
Frontmatter
Tool-level metadata that is not part of the argument grammar lives in a frontmatter block:
---
exe: "convert"
version: "7.1"
container:
image: "docker://imagemagick:7.1"
authors:
- "ImageMagick Studio LLC"
urls:
- "https://imagemagick.org"
references:
- "Still, M. The Definitive Guide to ImageMagick. 2006."
stdout:
name: "output_log"
description: "Processing log written to stdout"
---
/// # ImageMagick Image Converter
///
/// Convert between image formats and apply transformations.
convert: set(
...
)Frontmatter is optional. Its schema is separate from the argument grammar.
The frontmatter subset
The block is a documented subset of YAML, not YAML at large. Anything a YAML emitter produces for the shapes below is read the same way YAML reads it, so a block written by hand, by PyYAML, or by any other emitter is accepted:
- scalars — quoted (
"a",'a'), numeric,true/false,null/~, or bare - flow sequences —
[a, b] - block sequences —
- item, with the dashes either indented under their key or at the key's own column - nested mappings, to any depth
- sequences of mappings —
- name: "A"with the item's remaining keys aligned undername #comments, which as in YAML start only at the beginning of a line or after whitespace
Everything else — anchors (&x), aliases (*x), tags (!!str), merge keys (<<), block scalars (|, >), multi-document streams — is rejected with a diagnostic, never half-interpreted. A : separates a key from its value only when a space or the end of the line follows it, so a bare https://x is one scalar rather than a key.
Two rules bind an implementation:
- Every line the reader cannot consume must produce a diagnostic. Declining a shape is allowed; declining it in silence is not, because the block is the only place a tool's identity and provenance live.
- Reading the block must never be able to rewrite it. A formatter re-emits the region as it was written, so a shape the reader does not model still survives the round trip intact.
Frontmatter keys
All keys are optional. Unknown keys are ignored.
| Key | Type | Meaning |
|---|---|---|
exe | string | The executable (argv[0]). The grammar describes only the arguments after it. |
version | string | Tool version. An unquoted numeric value (6.0) is accepted and coerced to a string. |
authors | string[] | Tool authors. |
urls | string[] | Reference URLs. |
references | string[] | Literature references (citations). |
container | map | image (string, required) and type (docker | singularity). |
stdout / stderr | map | A captured stream output: name (string, required) and description (string). May also be given as a bare string (the name). |
The root is just an expression, and like any node it may carry a label: or be left anonymous (a bare expr, e.g. seq("hello", "world")). The tool's id is resolved as root name, then exe, then id; a root with none of these is a valid anonymous grammar whose identity comes from the embedding context. As in YAML, a # starts a comment only after whitespace, so an inline # (e.g. a URL fragment) needs no quoting.
Sugar DSL
The sugar DSL is the primary notation for hand-authoring argtype definitions.
Grammar
(* Top-level *)
file = [ frontmatter ] { alias } { doc_comment } root
root = named_expr | expr (* the root may be named or anonymous *)
(* Aliases *)
alias = identifier "=" expr
(* Frontmatter: a YAML-compatible subset, not YAML at large - see Frontmatter *)
frontmatter = "---" newline { fm_entry } "---" newline
fm_entry = fm_key ":" ( fm_value newline | newline fm_block )
fm_key = identifier | quoted_string
fm_value = fm_scalar | fm_flow_seq
fm_block = { fm_entry } | { "-" fm_item } (* nested mapping, or block sequence *)
fm_item = fm_value newline | fm_entry { fm_entry }
fm_flow_seq = "[" [ fm_value { "," fm_value } ] "]"
fm_scalar = quoted_string | number | "true" | "false" | "null" | "~" | bare_text
(* Expressions *)
expr = alt_expr
alt_expr = chain_expr { "|" chain_expr }
chain_expr = primary_expr { "." method_call } [ "=" default_value ]
primary_expr = combinator | terminal | literal | named_expr | alias_ref | group
(* Combinators *)
combinator = ( "seq" | "set" | "opt" | "rep" | "alt" | "any" ) "(" expr_list ")"
expr_list = [ doc_or_expr { "," doc_or_expr } [ "," ] ]
doc_or_expr = { doc_comment } [ named_expr | expr ]
(* Terminals and literals *)
terminal = ( "int" | "float" | "str" | "path" )
literal = quoted_string
default_value = number | quoted_string (* `= value`; see chain_expr - allowed after a method chain too *)
(* Naming *)
named_expr = label ":" expr
label = identifier | quoted_string (* quoted for non-identifier names *)
name = identifier (* alias & template-ref names *)
(* Alias reference *)
alias_ref = identifier
(* Grouping: anonymous sequence *)
group = "(" expr_list ")"
(* Chaining *)
method_call = identifier "(" [ argument_list ] ")"
argument_list = argument { "," argument } [ "," ]
argument = number | quoted_string | template_literal | named_expr
(* Documentation *)
doc_comment = "///" rest_of_line
comment = "//" rest_of_line
(* Template literals *)
template_literal = "`" { template_char | template_escape | template_ref } "`"
template_ref = "{" [ name { "." method_call } ] "}"
template_escape = "\" ( "{" | "}" | "`" | "\" ) (* a literal { } ` or \ *)
(* Primitives *)
identifier = letter { letter | digit | "_" }
quoted_string = '"' { char } '"'
number = [ "-" ] digit { digit } [ "." digit { digit } ]
[ ( "e" | "E" ) [ "+" | "-" ] digit { digit } ]Precedence
| Precedence | Operator | Description |
|---|---|---|
| Highest | .method() | Chaining |
| | Alternative | |
label: | Naming | |
| Lowest | , | Sequence separator |
Trailing Commas
Trailing commas are allowed in all comma-separated lists.
Chaining API
The chaining API is a programmatic notation embeddable in any host language. Every sugar construct desugars into chaining calls.
Desugaring rules
| Sugar | Chaining API |
|---|---|
"literal" | lit("literal") |
int, float, str, path | int(), float(), str(), path() |
label: expr / "label": expr | expr.name("label") |
value = default | value.default(default) |
/// description | .description("description") |
/// # Title (+ blank + body) | .title("Title").description("body") |
(a, b) | seq(a, b) |
a | b | alt(a, b) |
Name = expr | const Name = expr (host language variable) |
Builder interface
// Terminals: produce builder nodes
int(): TerminalNode
float(): TerminalNode
str(): TerminalNode
path(): TerminalNode
lit(s: string): LiteralNode
// Structural combinators: produce builder nodes
seq(...nodes: Node[]): SeqNode
set(...nodes: Node[]): SetNode
opt(...nodes: Node[]): OptNode
rep(...nodes: Node[]): RepNode
alt(...nodes: Node[]): AltNode
any(...nodes: Node[]): AnyNode
// Common metadata: available on all nodes
interface Node {
name(s: string): Node
title(s: string): Node // short summary title
description(s: string): Node // long-form description
join(separator?: string): Node // default: ""
}
// Terminal-specific metadata
interface TerminalNode extends Node {
default(value: number | string): TerminalNode
min(n: number): TerminalNode // int / float only (see Metadata Chaining)
max(n: number): TerminalNode // int / float only
}
// Repetition-specific metadata
interface RepNode extends Node {
count(n: number): RepNode // exactly n; sugar for countMin(n).countMax(n)
countMin(n: number): RepNode
countMax(n: number): RepNode
}Extensions add their own methods:
outputs—.output()onNode, plusref,t, andtemplatefor building output path templates.constraints—.requires(),.conflicts(), … onNode.mediatypes—.mediaType(mime)on apath.
Example: chaining API in TypeScript
import { set, seq, opt, rep, lit, int, float, str, path } from "argtype"
const convert = set(
opt(lit("-quality"), int().name("quality").default(80))
.description("Output image quality (1-100)"),
opt(lit("-resize"), str().name("geometry"))
.description("Resize to given dimensions"),
opt(lit("-thumbnail"), str().name("size"))
.description("Generate a thumbnail"),
opt(lit("-strip"))
.description("Strip all metadata"),
path().name("input").description("Input image file"),
path().name("output").description("Output image file"),
).name("convert")Examples
Simple tool: string array model
---
exe: "echo"
---
echo: rep(message: str)Invocation: echo "hello" "world" → ["hello", "world"]
Flags and values
---
exe: "convert"
---
convert: seq(
input: path,
set(
opt("-q", quality: int.min(1).max(100).default(80)),
opt("-f", format: "png" | "jpg" | "webp"),
opt("-v"),
),
output: path,
)Invocation: convert in.png -q 90 -f webp out.webp → ["in.png", "-q", "90", "-f", "webp", "out.webp"]
The set(...) members can appear in any order, but input and output are ordered by the outer seq.
Joined values
---
exe: "cut"
---
cut: set(
seq("-d", delimiter: str),
seq("-f", fields: rep(int).join(",")),
)Invocation: cut -d ":" -f 1,3,5 → ["-d", ":", "-f", "1,3,5"]
Subcommands
---
exe: "git"
---
git: alt(
commit: seq(
"commit",
set(
opt("-m", message: str),
opt("--amend"),
opt("--no-edit"),
),
),
push: seq(
"push",
set(
opt("--force"),
opt("-u", upstream: str),
),
remote: str = "origin",
branch: str,
),
)Note: remote: str = "origin" is a positional argument with a default. This creates ambiguity when parsing (is git push main setting remote or branch?). argtype describes the grammar; resolving parse ambiguity is an implementation concern (e.g. via parser combinators or heuristics).
3D coordinate with fixed count
---
exe: "tool"
---
tool: set(
opt("--center", center: rep(float).count(3)),
input: path,
)Invocation: tool --center 1.5 2.0 3.5 photo.png → ["--center", "1.5", "2.0", "3.5", "photo.png"]
Patterns
Reusable recipes built from the core combinators.
Common option-form alternatives
Many tools accept the same flag in several syntactic forms: --output=PATH, --output PATH, -o PATH, -output PATH. Define the value-bearing form once, derive the joined (=) form from it, and wrap the interchangeable pieces in any so generators know they're cosmetic variants:
Output = seq(any("--output", "-output", "-o"), path)
opt(any(Output, Output.join("=")))
// accepts: ["--output", "x"] | ["-output", "x"] | ["-o", "x"] | ["--output=x"] | ...
// emits: ["--output", "x"]Use alt (not any) when the choice does carry meaning: --input and --output are different things, not two spellings of one thing.
Future Language Features
The following are out of scope for the sugar DSL today but are natural additions (distinct from the extension mechanism, which adds metadata vocabularies rather than language constructs):
- Imports and modules: sharing type aliases across files and packages (e.g. a shared image processing type vocabulary)
- Generic/parameterized aliases:
Flagged<T> = seq(str, T)or similar
Note that the chaining API already provides both of these for free via the host language. Aliases are variables, generics are functions, imports are modules:
import { Image, Dimension } from "@imagemagick/types"
const Flagged = (flag: string, inner: Node) => seq(lit(flag), inner)
const convert = set(
opt(Flagged("-quality", int().name("quality").default(80))),
path().name("input"),
).name("convert")That's the upside of two notations: the sugar DSL stays small, and the chaining API gets imports, generics, and modules from its host for free.