8.1.53. Command-Line Argument Parsing (clargs)
This tutorial covers daslib/clargs — a structure macro that turns an
annotated struct into a type-safe CLI parser. Declare your flags as struct
fields; the macro generates the parse functions and the runtime flag metadata
for that struct.
Prerequisites: structs, enums, arrays, and Result / Option
(Option<T> and Result<T, E>).
options gen2
require daslib/clargs
8.1.53.1. Defining a CLI args struct
Annotate any struct with [CommandLineArgs]. The macro adds these functions
to your module:
parse_args(type<T>; args : array<string>) : Result<T; string>— parse a provided listparse_args(type<T>) : Result<T; string>— parse the process command line, read throughget_user_args()parse_args_with_help(var dst : T; prog_name : string) : int— parse, and answer--help/-hon the way (generated only for structs that declare neither flag themselves)get_command_info(type<T>) : CommandInfo— runtime flag metadata
parse_args builds a fresh T and hands it back inside a Result.
move_unwrap takes the struct out on success; unwrap_err gives a one-line
message naming the flag on failure.
Field names map to flag names with underscores converted to dashes
(output_file → --output-file).
[CommandLineArgs]
struct Config {
name : string // --name
count : int // --count
verbose : bool // --verbose
timeout : float // --timeout
}
var res <- parse_args(type<Config>, ["--name", "Alice", "--count=42", "--verbose", "--timeout=1.5"])
let cfg <- res |> move_unwrap
// cfg.name == "Alice"
// cfg.count == 42
// cfg.verbose == true
// cfg.timeout == 1.5
Each flag accepts two forms: --flag value (space-separated) and
--flag=value (equals sign, no space). A flag given twice keeps the last
value, so a wrapper script can set a default that the user overrides. Tokens
that look like flags but match no field are ignored, which lets another parser
share the same argv.
8.1.53.2. Supported field types
Field type |
Accepted flag values |
|---|---|
|
Any string |
|
Decimal integer (optional leading |
|
Decimal float with optional exponent |
|
Bare flag (true), |
|
Enum entry name as a string (e.g. |
|
Flag may appear multiple times |
|
Same as |
8.1.53.3. Bool flags
A bare --verbose sets the field to true. Use =true or =false
to be explicit:
let on <- parse_args(type<Config>, ["--verbose"]) |> move_unwrap
let off <- parse_args(type<Config>, ["--verbose=false"]) |> move_unwrap
// on.verbose == true
// off.verbose == false
8.1.53.4. Enum flags
Pass the enum entry name as a string. An unknown name is an error:
enum LogLevel {
Debug
Info
Warning
Error
}
[CommandLineArgs]
struct LogConfig {
level : LogLevel // --level accepts "Debug", "Info", "Warning", "Error"
}
var res <- parse_args(type<LogConfig>, ["--level", "Warning"])
let cfg <- res |> move_unwrap
// cfg.level == LogLevel.Warning
let bad <- parse_args(type<LogConfig>, ["--level", "Verbose"])
// bad |> unwrap_err == "--level: invalid enum value 'Verbose'"
8.1.53.5. Array flags
An array<string> field collects every occurrence of the flag into the
array. Both forms (--tag value and --tag=value) are supported:
[CommandLineArgs]
struct BuildConfig {
tags : array<string>
}
var res <- parse_args(type<BuildConfig>, ["--tags=debug", "--tags", "release", "--tags=profile"])
let cfg <- res |> move_unwrap
// cfg.tags == ["debug", "release", "profile"]
8.1.53.6. Required flags
@clarg_required makes a flag mandatory. parse_args fails when the flag
is absent:
[CommandLineArgs]
struct DeployConfig {
host : string
@clarg_required
token : string
}
let missing <- parse_args(type<DeployConfig>, ["--host=prod.example.com"])
// missing |> unwrap_err == "--token: missing required flag"
var res <- parse_args(type<DeployConfig>, ["--host=prod.example.com", "--token=secret"])
let cfg <- res |> move_unwrap
// cfg.token == "secret"
8.1.53.7. Optional fields
An absent int field parses as 0, which is also a value a user can type.
Declare the field Option<T> when the two cases must stay apart:
[CommandLineArgs]
struct RetryConfig {
@clarg_doc = "retry count; none when the flag is absent"
retries : Option<int>
}
var res <- parse_args(type<RetryConfig>, ["--retries=3"])
let cfg <- res |> move_unwrap
if (cfg.retries |> is_some) {
print("retries = {cfg.retries |> unwrap}\n")
} else {
print("no --retries; the built-in policy decides\n")
}
string, int, float, bool, and array<string> all wrap. An
enum field does not — wrap one and the macro fails at compile time.
8.1.53.8. Field-level attributes
Field annotations fine-tune the flag a field becomes:
@clarg_name = "flag"Overrides the auto-generated flag name. clargs prepends the
--.@clarg_short = "X"Attaches a single-character short flag.
@clarg_doc = "text"Description for the help renderer and for
get_command_info.@clarg_skipExcludes the field from the CLI schema. Set it in code instead.
@clarg_requiredThe flag must be supplied.
@clarg_positionalFills the field from a bare token instead of a flag.
@clarg_countSums every occurrence of the flag into a plain
intfield.@clarg_mutex_group = "name"Puts the flag in a group whose members exclude each other.
@clarg_env = "NAME"Reads an environment variable when the flag is absent.
[CommandLineArgs]
struct AppConfig {
@clarg_name = "output-dir"
@clarg_doc = "Directory to write output files"
out_path : string // flag is --output-dir, not --out-path
@clarg_doc = "Number of parallel workers (default: 1)"
workers : int
@clarg_skip
internal_id : int // not a CLI flag
}
var res <- parse_args(type<AppConfig>, ["--output-dir=/tmp/out", "--workers=4"])
var cfg <- res |> move_unwrap
cfg.internal_id = 99 // set in code; there is no --internal-id
// cfg.out_path == "/tmp/out"
// cfg.workers == 4
8.1.53.9. Positional arguments
@clarg_positional fields take the bare tokens of the command line, in
declaration order. A plain string positional is required, an
Option<string> one is optional, and an array<string> one swallows every
remaining token:
[CommandLineArgs]
struct PkgConfig {
@clarg_positional
@clarg_doc = "subcommand"
command : string
@clarg_positional
@clarg_doc = "package name"
pkg : Option<string>
@clarg_doc = "project root"
root : string = "."
}
var res <- parse_args(type<PkgConfig>, ["--root", "/tmp", "install", "dasImgui"])
let cfg <- res |> move_unwrap
// cfg.command == "install"
// cfg.pkg |> unwrap == "dasImgui"
// cfg.root == "/tmp"
Flags and positionals interleave freely: clargs first removes every flag it
knows (with its value, when the --flag value form is used), then reads what
is left in order. Unknown flag-shaped tokens are dropped rather than counted
as positionals.
The macro rejects orders it cannot fill: an array<string> positional must
be last, a required positional cannot follow an optional one, and
@clarg_positional combines with none of @clarg_short, @clarg_count,
or @clarg_env. A missing required positional reports
"<command>: missing required positional argument".
8.1.53.10. Counting occurrences
@clarg_count on a plain int field counts how often the flag appears —
the -v -v -v idiom for verbosity levels. Long and short forms sum together:
[CommandLineArgs]
struct VerbosityConfig {
@clarg_count
@clarg_short = "v"
@clarg_doc = "verbosity; repeat to raise the level"
verbose : int
}
var res <- parse_args(type<VerbosityConfig>, ["-v", "-v", "--verbose"])
let cfg <- res |> move_unwrap
// cfg.verbose == 3
Count flags carry no value, so --verbose=2 reports
"--verbose: count flag does not accept a value". Bundling is not
implemented: -vvv matches nothing and leaves the field at 0.
8.1.53.11. Mutually exclusive flags
Flags that share a @clarg_mutex_group name may not appear together:
[CommandLineArgs]
struct OutputConfig {
@clarg_mutex_group = "color"
color : bool
@clarg_name = "no-color"
@clarg_mutex_group = "color"
no_color : bool
}
let clash <- parse_args(type<OutputConfig>, ["--color", "--no-color"])
// clash |> unwrap_err == "--color, --no-color: mutually exclusive (group 'color')"
Groups are independent — one flag from each of two groups is fine. The check reads the command line only, so a value that arrived from an environment twin never collides with an explicit flag.
8.1.53.12. Environment twins
Any flag can also read an environment variable. @clarg_env = "NAME" names
one per field; [CommandLineArgs(env_prefix = "TOOL")] derives
TOOL_LONG_NAME from every long flag name, with hyphens becoming
underscores. @clarg_env = "" opts a single field out of that derivation:
[CommandLineArgs(env_prefix = "MYTOOL")]
struct ServeConfig {
@clarg_doc = "listen port"
port : int // --port, or MYTOOL_PORT
@clarg_env = "MYTOOL_ROOT_DIR"
@clarg_doc = "document root"
root : string // --root, or MYTOOL_ROOT_DIR
@clarg_env = ""
@clarg_doc = "debug logging"
debug : bool // --debug only
}
The command line wins over the variable, and the variable wins over the field
initializer. Booleans read "", 0, false, off, and no (any
case) as false and anything else as true. A variable that is set but empty
counts as unset. Garbage in an int, float, or enum variable is an error, the
same way it is on the command line. @clarg_required is satisfied by either
carrier. Positional, count, and repeatable fields have no environment form.
Libraries have no argv at all, so their ambient knobs use the sibling
annotation [EnvConfig], which reads the same @clarg_doc /
@clarg_env vocabulary and generates env_config(type<T>) : T.
8.1.53.13. Error handling
parse_args returns Result<T; string>. is_err reports the outcome,
unwrap_err gives the message, and move_unwrap takes the parsed struct:
[CommandLineArgs]
struct TypedConfig {
count : int
}
def load_config(argv : array<string>) : bool {
var res <- parse_args(type<TypedConfig>, argv)
if (res |> is_err) {
print("usage error: {res |> unwrap_err}\n")
return false
}
let cfg <- res |> move_unwrap
print("count = {cfg.count}\n")
return true
}
// load_config(["--count", "not_a_number"]) prints
// usage error: --count: invalid int value 'not_a_number'
Parsing stops at the first failure. Common messages:
"--flag: invalid int value 'abc'""--flag: invalid float value 'abc'""--flag: invalid enum value 'Unknown'""--flag: invalid bool value: 'yes'""--flag: missing value"— the flag is there, its value is not"--flag: missing required flag""<name>: missing required positional argument""--flag: count flag does not accept a value""--fast, --slow: mutually exclusive (group 'mode')"
8.1.53.14. Short flags
@clarg_short = "X" attaches a single-character short flag. Both the long
and short forms are recognised, with identical value syntax (-X value,
-X=value, or bare -X for booleans):
[CommandLineArgs]
struct ServerConfig {
@clarg_short = "p"
@clarg_doc = "listen port"
port : int
@clarg_short = "v"
@clarg_doc = "verbose logging"
verbose : bool
@clarg_short = "t"
@clarg_doc = "tag (repeated)"
tags : array<string>
}
var res <- parse_args(type<ServerConfig>, ["-p", "8080", "-v", "-t=alpha", "-t=beta"])
let cfg <- res |> move_unwrap
// cfg.port == 8080
// cfg.verbose == true
// cfg.tags == ["alpha", "beta"]
Mixing long and short occurrences of an array flag preserves command-line order:
--tags=a -t b --tags=c collects ["a", "b", "c"].
The macro rejects a short flag it cannot parse back: two fields sharing one
character, a value longer than one character, and -, =, or whitespace
as the character are all compile-time errors.
8.1.53.15. Introspection with get_command_info
get_command_info(type<T>) returns a CommandInfo value containing a
CommandArgumentInfo entry for each parsed flag — the same data the
help renderer uses, exposed for programmatic inspection (custom help formats,
validation rules, configuration dumps, shell completion):
let info <- get_command_info(type<ServerConfig>)
for (arg in info.args) {
print(" {arg.short_flag_name}, {arg.flag_name} ({arg.value_type}) {arg.doc_string}\n")
}
// output:
// -p, --port (tInt) listen port
// -v, --verbose (tBool) verbose logging
// -t, --tags (tString) tag (repeated)
CommandInfo carries args plus has_user_help, which is true when
the struct declares its own --help or -h flag.
CommandArgumentInfo fields:
Field |
Type |
Description |
|---|---|---|
|
|
Struct field name |
|
|
|
|
|
|
|
|
Environment twin, or |
|
|
|
|
|
Default as text; |
|
|
Base type ( |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Entry names for enum fields, empty otherwise |
8.1.53.16. Help rendering
The library ships a --help renderer over CommandInfo:
print_help(info, prog_name)— writes the formatted help to stdout.format_help(info, prog_name) : string— returns the same text, useful in tests or when redirecting into a logger.format_help_with_auto_help(info, prog_name) : string— the same text with a-h, --helprow appended, unless the struct declares its own.
parse_args_with_help wires all of that up for a struct with no help flag of
its own. It prints the help and returns 0 when it sees --help or
-h, returns -1 after a clean parse (your struct is populated), and
returns 1 on a parse error it has already logged at LOG_ERROR:
[CommandLineArgs]
struct DemoConfig {
@clarg_short = "n"
@clarg_doc = "user's display name"
name : string
@clarg_doc = "iteration count"
count : int
@clarg_short = "v"
@clarg_doc = "verbose logging"
verbose : bool
}
[export]
def main() : int {
var cfg = DemoConfig()
let rc = parse_args_with_help(cfg, "demo")
return rc if (rc >= 0)
print("hello, {cfg.name}\n")
return 0
}
The rendered output, from a standalone daslang -exe binary:
Usage: demo [flags]
Flags:
-n, --name=STRING user's display name
--count=INT iteration count
-v, --verbose verbose logging
-h, --help show this help and exit
Format rules:
Per-flag line:
-X, --long=PLACEHOLDER doc_string. Fields with no@clarg_shortleave the short slot blank to keep the long flags vertically aligned.=PLACEHOLDERis the uppercased type name (STRING/INT/FLOAT/ENUM). Bool and count flags omit it.Enum values render inline as
(V1|V2|V3).The doc column then picks up the markers that apply:
(env: NAME),(required),(repeated)for array flags,(repeats)for count flags, and(mutex: group).Positionals get their own
Positional arguments:block and appear in the usage line as<name>,[<name>], or[<name>...]for the array tail.The usage line follows the host. A standalone binary owns argv, so it reads
Usage: demo [flags]; under the script host the same call rendersUsage: daslang demo -- [flags], which is the line a user can copy.Defaults are not rendered. Write them into
@clarg_docwhen they matter.
Under the script host, daslang itself takes -h and --help before it
forwards anything to your script, so the auto help flag is reachable only from
a standalone binary. A script that needs a help flag declares its own and
wires it to -?, which the host leaves alone. A field that spells
--help or -h also turns parse_args_with_help off for that struct —
once you name the flag, the exit policy is yours:
[CommandLineArgs]
struct ScriptConfig {
@clarg_short = "n"
@clarg_doc = "user's display name"
name : string
@clarg_short = "?"
@clarg_name = "show-help"
@clarg_doc = "show this help and exit"
show_help : bool
}
var res <- parse_args(type<ScriptConfig>)
if (res |> is_err) {
print("error: {res |> unwrap_err}\n\n")
print_help(get_command_info(type<ScriptConfig>), "demo")
} else {
let cfg <- res |> move_unwrap
if (cfg.show_help) {
print_help(get_command_info(type<ScriptConfig>), "demo")
}
}
8.1.53.17. Reading process arguments
parse_args(type<T>) reads argv through get_user_args(), which picks the
slice that belongs to your program:
get_user_args()argv[1..]for a standalonedaslang -exebinary, and the slice after the--separator under the interpreter or the JIT. One spelling works in all three, which is why the generatedparse_argsuses it.get_cli_arguments() / get_cli_arguments(argv)— script-styleReturns the slice after the
--separator in argv (or empty if no--). This is what daslang script invocations look like, where daslang itself owns argv up to the--and the script gets everything after:daslang.exe my_script.das -- --name Alice --count 5
get_program_args() / get_program_args(argv)— standalone-tool styleReturns
argv[1..]— the full argv with the program name stripped. Use this for AOT’d binaries that own the full argv themselves and have no--separator (das-fmt, daspkg, lint, aot-style tools):[CommandLineArgs] struct FmtConfig { @clarg_doc = "rewrite files in place" write : bool @clarg_positional @clarg_doc = "files to format" files : array<string> } var res <- parse_args(type<FmtConfig>, get_program_args()) if (res |> is_err) { print("error: {res |> unwrap_err}\n") } else { let cfg <- res |> move_unwrap print("formatting {length(cfg.files)} files\n") }
The explicit-argv overloads make the splitting logic unit-testable without touching the live process state:
let scripted <- get_cli_arguments(["host", "script.das", "--", "--foo", "bar"])
// scripted == ["--foo", "bar"]
let standalone <- get_program_args(["fmt.exe", "--write", "file.das"])
// standalone == ["--write", "file.das"]
See also
Full source: tutorials/language/53_clargs.das
Previous tutorial: Option<T> and Result<T, E>
Next tutorial: Glob Pattern Matching
Structs, Enumerations and Bitfields, Arrays, Annotations and Options