← arguments · API reference · version 0.2.8
3 file(s), 1095 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Args struct | 84 | — | 114 115 126 148 154 160 … (31 total) |
name const | 85 | — | 114 116 229 232 423 424 … (23 total) |
version const | 86 | — | 114 116 190 483 23 128 … (12 total) |
description const | 87 | — | 60 114 116 135 141 554 … (10 total) |
create fn | 114 | — | 21 22 23 24 25 28 … (60 total) |
flag fn | 148 | — | 67 29 30 31 32 33 … (62 total) |
option fn | 154 | — | 68 69 70 283 285 302 … (53 total) |
required fn | 160 | ARG-004 | 410 145 146 147 151 152 … (18 total) |
optional fn | 167 | ARG-002 | 69 134 135 136 137 139 … (12 total) |
set_usage fn | 172 | — | none in this package |
set_bug_address fn | 175 | — | 256 |
set_epilog fn | 178 | — | none in this package |
enable_help fn | 185 | ARG-003 | 235 240 247 378 427 430 … (8 total) |
parse fn | 347 | ARG-001 | 25 31 40 48 55 62 … (59 total) |
has fn | 431 | — | 32 33 41 42 49 50 … (41 total) |
get fn | 444 | — | 457 462 34 57 63 69 … (34 total) |
get_or fn | 456 | — | 212 213 |
get_int fn | 461 | — | 209 210 211 415 |
ok fn | 469 | — | 370 378 384 388 25 31 … (33 total) |
error_msg fn | 473 | — | 130 154 160 161 167 173 … (10 total) |
wants_help fn | 477 | — | 237 249 380 430 432 436 … (7 total) |
wants_version fn | 480 | — | 238 242 59 |
version_text fn | 483 | — | 243 266 406 |
try_help fn | 486 | — | 267 74 |
help fn | 550 | — | 188 487 236 237 238 248 … (32 total) |
src/arguments.loft
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- Arguments ---
// A GNU-style command-line argument parser with auto-generated `--help`.
//
// Declare options against an `Args`, then `parse(argv)`. Supports the GNU
// getopt_long surface: long options (`--name`, `--name=value`, `--name value`),
// short options (`-n`, `-n value`, `-nvalue`, bundled `-abc`), unambiguous long
// prefix abbreviation (`--verb` for `--verbose`), optional-argument options, the
// `--` end-of-options marker, and interspersed positionals. Opt-in `enable_help()`
// wires `--help` / `--version` and lets `help()` render a GNU-format help screen
// generated from the declared options.
//
// The parsing core is written with the @PLN35 cursor/match-PEG engine: `classify`
// lexes each argv element into a `Tok` (using the option table), then a cursor
// `match` drives the token stream — the `[ NeedVal, Pos ]` vs `[ NeedVal ]`
// sequence decides whether an option consumes the following word.
//
// --- Using it ---
//
// a = arguments::create("pack", "1.2.0", "Bundle a directory into an archive.");
// a.enable_help(); // wires --help / --version
// a.flag("v", "verbose", "explain what is being done");
// a.option("o", "output", "FILE", "write the archive here");
// a.required("f", "format", "FMT", "zip or tar");
//
// if !a.parse(argv) {
// print("{a.error_msg()}\n{a.try_help()}\n");
// return;
// }
// if a.wants_help() { print(a.help()); return; }
//
// a.has("verbose") // -> true, given -v
// a.get_or("format", "?") // -> "tar", given --format=tar
// a.get_or("output", "-") // -> "site.tar", given --out site.tar
//
// That last line is the prefix abbreviation at work: `--out` is unambiguous among
// the declared long names, so it reaches `--output`. Everything is queried by the
// LONG name, whatever spelling the user typed — `-v`, `--verb` and `--verbose` all
// answer `has("verbose")`.
//
// `parse` answers false only for a real error (a missing required option, an
// unknown one, an ambiguous prefix); `--help` is a successful parse that sets
// `wants_help()`, so check that before doing any work.
//
// An option is queried by its LONG name; one declared with no long name
// (`flag("x", "", …)`) is queried by its short name instead.
// A declared option's shape. `Flag` takes no value; `Value` requires one
// (`--out x` / `--out=x` / `-ox`); `Optional` accepts one only when glued
// (`--color=auto` / `-cauto`), otherwise it is "present with no value".
enum ArgKind { Flag, Value, Optional }
struct Arg {
const short_name: text,
const long_name: text,
const arg_name: text,
const kind: ArgKind,
const mandatory: boolean,
const description: text,
}
// A classified argv element (the output of `classify`). Resolving `=`/glued
// values and flag-vs-value happens here against the option table; the cross-token
// "option then its value word" pairing is left to the cursor grammar in `parse`.
enum Tok {
OptFlag { idx: integer }, // a flag was given (long, or a short in a cluster)
NeedVal { idx: integer }, // a value option whose value must come from the next word
HasVal { idx: integer, value: text }, // a value/optional option with its value already resolved
Pos { word: text }, // a positional, or a post-`--` literal, or an option's value
Bad { msg: text }, // a lexer-detected error (unknown / ambiguous / no-arg-allowed)
}
// A parser-combinator cursor over the classified token stream: a cursor `match`
// prefix-consumes from `pos` and advances it (see @PLN35).
struct Cur { const src: vector<Tok>, pos: integer }
// A command-line parser and, after `parse`, its result. Declare the options with `flag`,
// `option`, `required` and `optional` on the value `create` returns, call `parse` on the
// program's arguments, then read the answers with `has`, `get`, `get_or` and `get_int`, and
// the words that were not options from `positionals`. `ok` and `error_msg` report a command
// line that does not fit the declarations; `help` and `version_text` render the texts the
// user asked for with `--help` and `--version`.
pub struct Args {
pub const name: text,
pub const version: text,
pub const description: text,
// Positional synopsis shown after `[OPTION...]` on the usage line, e.g. "[FILE...]".
usage: text,
// "Report bugs to <…>." footer, when set.
bug_address: text,
// Free trailing text printed after the option list.
epilog: text,
const options: vector<Arg>,
// Parallel to `options`: each option's resolved value, or null when unset.
const results: vector<text?>,
pub positionals: vector<text>,
help_enabled: boolean,
help_idx: integer,
version_idx: integer,
want_help: boolean,
want_version: boolean,
err: text,
}
// The column where option descriptions begin in `help()` (GNU argp default).
const HELP_COL = 29;
// The total help line width descriptions wrap at.
const HELP_WIDTH = 79;
// Start an empty parser. The three strings are what `help()` and
// `version_text()` render — nothing else reads them, so they are for the user
// and not for the parsing.
pub fn create(name: text, version: text, description: text) -> Args {
Args {
name: name, version: version, description: description,
usage: "", bug_address: "", epilog: "",
options: [], results: [], positionals: [],
help_enabled: false, help_idx: -1, version_idx: -1,
want_help: false, want_version: false, err: "",
}
}
// --- Declaring options ---
fn register(self: Args, s: text, l: text, an: text, k: ArgKind, req: boolean, desc: text) -> integer {
// `enable_help` claims `-h` / `-V` only while they are free, and an option declared
// after it takes the letter back: the program's own option outranks the convention.
if s.len() > 0 {
for auto in [self.help_idx, self.version_idx] {
if auto >= 0 && auto < self.options.len() && self.options[auto].short_name == s {
ao = self.options[auto];
self.options[auto] = Arg {
short_name: "", long_name: ao.long_name, arg_name: ao.arg_name, kind: ao.kind,
mandatory: ao.mandatory, description: ao.description,
};
}
}
}
self.options += [Arg {
short_name: s, long_name: l, arg_name: an, kind: k, mandatory: req, description: desc,
}];
self.results += null;
self.options.len() - 1
}
// A boolean flag (`--verbose` / `-v`).
pub fn flag(self: Args, opt_short: text, opt_long: text, desc: text) {
self.register(opt_short, opt_long, "", ArgKind.Flag, false, desc);
}
// A value option the user may leave out (`--output FILE`); `get` answers null when
// it was not given. `arg_name` is the metavar shown in help.
pub fn option(self: Args, opt_short: text, opt_long: text, arg_name: text, desc: text) {
self.register(opt_short, opt_long, arg_name, ArgKind.Value, false, desc);
}
// A required value option: `parse` fails if it is absent.
// Example: @ARG-004 — the parse-failure idiom (`ok`/`error_msg`/`try_help`).
pub fn required(self: Args, opt_short: text, opt_long: text, arg_name: text, desc: text) {
self.register(opt_short, opt_long, arg_name, ArgKind.Value, true, desc);
}
// An optional-argument option: its value attaches only via `=` / glued form
// (`--color=auto`, `-cauto`); given bare (`--color`) it is present with value "".
// Example: @ARG-002 — glued value vs bare, and why the next word stays a positional.
pub fn optional(self: Args, opt_short: text, opt_long: text, arg_name: text, desc: text) {
self.register(opt_short, opt_long, arg_name, ArgKind.Optional, false, desc);
}
// Set the positional synopsis for the usage line (e.g. "[FILE...]").
pub fn set_usage(self: Args, synopsis: text) { self.usage = synopsis; }
// Set the "Report bugs to <addr>." help footer.
pub fn set_bug_address(self: Args, address: text) { self.bug_address = address; }
// Set free text printed after the option list in help.
pub fn set_epilog(self: Args, epilog: text) { self.epilog = epilog; }
// Opt-in: register `--help`/`-h` and `--version`/`-V`, so `parse` records them and
// `help()` lists them. A short form goes to your own option whenever one claims the
// letter, declared before this call or after it. Nothing is auto-printed — the caller
// checks `wants_help()` / `wants_version()`.
// Example: @ARG-003 — the opt-in dance, and `--help` bypassing the required check.
pub fn enable_help(self: Args) {
self.help_enabled = true;
hshort = if self.short_taken("h") { "" } else { "h" };
self.help_idx = self.register(hshort, "help", "", ArgKind.Flag, false, "display this help and exit");
vshort = if self.short_taken("V") { "" } else { "V" };
self.version_idx = self.register(vshort, "version", "", ArgKind.Flag, false, "output version information and exit");
}
fn short_taken(self: Args, ch: text) -> boolean {
for so in self.options {
if so.short_name == ch { return true }
}
false
}
// --- Option lookup ---
// Find a long option by name, allowing an unambiguous prefix abbreviation.
// Returns the index, -1 for unknown, or -2 for an ambiguous prefix.
fn find_long_opt(self: Args, fname: text) -> integer {
for fo in self.options {
if fo.long_name == fname { return fo#index }
}
fmatch = -1;
fcount = 0;
for fo2 in self.options {
if fo2.long_name.len() > 0 && fo2.long_name.starts_with(fname) {
fmatch = fo2#index;
fcount += 1;
}
}
if fcount == 1 { return fmatch }
if fcount > 1 { return -2 }
-1
}
fn find_short_opt(self: Args, fch: character) -> integer {
for fso in self.options {
if fso.short_name.len() > 0 && "{fch}" == fso.short_name { return fso#index }
}
-1
}
// The comma-separated candidate list for an ambiguous long prefix (for the error).
fn ambiguous_matches(self: Args, name: text) -> text {
am = "";
for ao in self.options {
if ao.long_name.len() > 0 && ao.long_name.starts_with(name) {
if am.len() > 0 { am += ", "; }
am += "'--{ao.long_name}'";
}
}
am
}
fn opt_kind(self: Args, idx: integer) -> ArgKind {
ki = ArgKind.Flag;
for ko in self.options {
if ko#index == idx { ki = ko.kind; }
}
ki
}
fn opt_label(self: Args, idx: integer) -> text {
lbl = "";
for lo in self.options {
if lo#index == idx {
if lo.long_name.len() > 0 { lbl = "--{lo.long_name}"; }
else { lbl = "-{lo.short_name}"; }
}
}
lbl
}
// --- Lexing: argv -> vector<Tok> (table-informed) ---
fn classify(self: Args, argv: vector<text>) -> vector<Tok> {
toks: vector<Tok> = [];
past = false;
// A value option takes the NEXT word as its value whatever it looks like —
// `--num -5`, `-o --` — as getopt_long does; only then is a word lexed again.
value_next = false;
for arg in argv {
if value_next {
toks += [Pos { word: arg }];
value_next = false;
} else if past {
toks += [Pos { word: arg }];
} else if arg == "--" {
past = true;
} else if arg == "-" {
toks += [Pos { word: "-" }];
} else if arg.starts_with("--") {
body = arg[2..];
eq = body.find("=");
if eq == null {
li = self.find_long_opt(body);
if li == -2 {
toks += [Bad { msg: "option '--{body}' is ambiguous; candidates: {self.ambiguous_matches(body)}" }];
} else if li < 0 {
toks += [Bad { msg: "unrecognized option '--{body}'" }];
} else {
lkind = self.opt_kind(li);
if lkind == ArgKind.Flag {
toks += [OptFlag { idx: li }];
} else if lkind == ArgKind.Optional {
toks += [HasVal { idx: li, value: "" }];
} else {
toks += [NeedVal { idx: li }];
value_next = true;
}
}
} else {
lname = body[0..eq];
lval = body[eq + 1..];
li2 = self.find_long_opt(lname);
if li2 == -2 {
toks += [Bad { msg: "option '--{lname}' is ambiguous; candidates: {self.ambiguous_matches(lname)}" }];
} else if li2 < 0 {
toks += [Bad { msg: "unrecognized option '--{lname}'" }];
} else if self.opt_kind(li2) == ArgKind.Flag {
toks += [Bad { msg: "option '--{lname}' doesn't allow an argument" }];
} else {
toks += [HasVal { idx: li2, value: lval }];
}
}
} else if arg.len() > 1 && arg.starts_with("-") {
cluster = arg[1..];
for ch in cluster {
si = self.find_short_opt(ch);
if si < 0 {
toks += [Bad { msg: "invalid option -- '{ch}'" }];
break
}
if self.opt_kind(si) == ArgKind.Flag {
toks += [OptFlag { idx: si }];
} else {
rest = cluster[ch#index + 1..];
if rest.len() > 0 {
toks += [HasVal { idx: si, value: rest }];
} else if self.opt_kind(si) == ArgKind.Optional {
toks += [HasVal { idx: si, value: "" }];
} else {
toks += [NeedVal { idx: si }];
value_next = true;
}
break
}
}
} else {
toks += [Pos { word: arg }];
}
}
toks
}
// --- Parsing: cursor/match-PEG driver over the token stream ---
// Parse a command line against the declared options. Answers false on a usage
// error (see `ok`/`error_msg`); afterwards flags read through `has`, value options
// through `get`, and every non-option word lands in `positionals` in order.
// Example: @ARG-001 — the declare → parse → query lifecycle.
pub fn parse(self: Args, argv: vector<text>) -> boolean {
self.err = "";
self.want_help = false;
self.want_version = false;
self.positionals = [];
ri = 0;
while ri < self.results.len() {
self.results[ri] = null;
ri += 1;
}
toks = self.classify(argv);
cur = Cur { src: toks, pos: 0 };
guard = 0;
while cur.pos < cur.src.len() {
guard += 1;
if guard > 1000000 { break }
// Each arm consumes a prefix and advances the cursor. `[ NeedVal, Pos ]`
// (tried first) pairs a value option with the following word; if no word
// follows, `[ NeedVal ]` reports the missing argument.
code = match cur {
[ NeedVal { idx }, Pos { word } ] => {
if idx < self.results.len() { self.results[idx] = word; }
"ok"
},
[ NeedVal { idx } ] => {
self.err = "option '{self.opt_label(idx)}' requires an argument";
"err"
},
[ HasVal { idx, value } ] => {
if idx < self.results.len() { self.results[idx] = value; }
"ok"
},
[ OptFlag { idx } ] => {
if idx < self.results.len() { self.results[idx] = "true"; }
if self.help_enabled && idx == self.help_idx { self.want_help = true; }
if self.help_enabled && idx == self.version_idx { self.want_version = true; }
"ok"
},
[ Pos { word } ] => {
self.positionals += [word];
"ok"
},
[ Bad { msg } ] => {
self.err = msg;
"err"
},
_ => "stop",
};
if code == "err" { return false }
if code == "stop" { break }
}
// `--help` / `--version` short-circuit the required-option check (GNU prints
// help and exits without validating).
if self.want_help || self.want_version { return true }
for mo in self.options {
if mo.mandatory {
mi = mo#index;
if mi < self.results.len() {
mval = self.results[mi];
if mval == null {
self.err = "option '{self.opt_label(mi)}' is required";
return false
}
}
}
}
true
}
// --- Querying results ---
// Is `name` how a query reaches option `o`: its long name, or its short name when it
// has no long one.
fn names(o: Arg, name: text) -> boolean {
if o.long_name.len() > 0 { o.long_name == name } else { o.short_name.len() > 0 && o.short_name == name }
}
// Was this option given? Ask by the LONG name whatever the user typed — a
// short, a prefix and the full name all land on the same option — or by the
// short name for an option declared without a long one. The question to ask a
// `flag`; `get` on one answers "true" or null.
pub fn has(self: Args, name: text) -> boolean {
for ho in self.options {
if names(ho, name) {
hi = ho#index;
if hi < self.results.len() { return self.results[hi] != null }
return false
}
}
false
}
// The option's value, or null when it was not given. `get_or` and `get_int`
// are this with the absent case already handled, and are usually what you want.
pub fn get(self: Args, name: text) -> text? {
for go in self.options {
if names(go, name) {
gi = go#index;
if gi < self.results.len() { return self.results[gi] }
return null
}
}
null
}
// The option's value, or `fallback` when unset.
pub fn get_or(self: Args, name: text, fallback: text) -> text {
self.get(name) ?? fallback
}
// The option's value parsed as an integer, or null when unset or non-numeric.
pub fn get_int(self: Args, name: text) -> integer? {
gv = self.get(name);
if !gv { return null }
(gv ?? "") as integer?
}
// Did the parse succeed? The same answer `parse` returned, available later —
// so a caller that dropped the return value can still ask.
pub fn ok(self: Args) -> boolean { self.err.len() == 0 }
// What went wrong, in GNU wording and ready to print. Empty when nothing did.
// Pair it with `try_help()`, which is the second line of that pair.
pub fn error_msg(self: Args) -> text { self.err }
// Did the user ask for `--help`? A SUCCESSFUL parse sets this, so check it
// before acting on the options — otherwise `--help` runs the program.
pub fn wants_help(self: Args) -> boolean { self.want_help }
// The same for `--version`; print `version_text()` and stop.
pub fn wants_version(self: Args) -> boolean { self.want_version }
// The conventional one-line version banner, built from what `create` was told.
pub fn version_text(self: Args) -> text { "{self.name} {self.version}" }
// The GNU "Try '<prog> --help' …" hint to print alongside a parse error.
pub fn try_help(self: Args) -> text {
"Try '{self.name} --help' for more information."
}
// --- Auto-generated GNU-style help ---
fn spaces(n: integer) -> text {
sp = "";
si = 0;
while si < n { sp += " "; si += 1; }
sp
}
// The flag column for one option, e.g. " -o, --output=FILE" or " --help".
fn option_left(a: Arg) -> text {
s = " ";
if a.short_name.len() > 0 {
s += "-{a.short_name}";
if a.long_name.len() > 0 { s += ", "; }
} else {
s += " ";
}
if a.long_name.len() > 0 { s += "--{a.long_name}"; }
if a.kind == ArgKind.Value || a.kind == ArgKind.Optional {
sep = if a.long_name.len() > 0 { "=" } else { " " };
if a.kind == ArgKind.Optional {
s += "[{sep}{a.arg_name}]";
} else {
s += "{sep}{a.arg_name}";
}
}
s
}
// Word-wrap a description into the help description column, indenting wrapped lines.
fn wrap_desc(desc: text, indent: integer) -> text {
words = desc.split(' ');
out = "";
line_len = 0;
avail = HELP_WIDTH - indent;
for w in words {
wl = w.len();
if line_len == 0 {
out += w;
line_len = wl;
} else if line_len + 1 + wl > avail {
out += "\n{spaces(indent)}{w}";
line_len = wl;
} else {
out += " {w}";
line_len += 1 + wl;
}
}
out
}
fn has_mixed_value_option(self: Args) -> boolean {
for xo in self.options {
if xo.kind != ArgKind.Flag && xo.short_name.len() > 0 && xo.long_name.len() > 0 { return true }
}
false
}
// Render a GNU-format help screen from the declared options.
pub fn help(self: Args) -> text {
h = "Usage: {self.name} [OPTION...]";
if self.usage.len() > 0 { h += " {self.usage}"; }
h += "\n";
if self.description.len() > 0 { h += "{self.description}\n"; }
h += "\n";
for ho in self.options {
left = option_left(ho);
h += left;
if left.len() < HELP_COL {
h += spaces(HELP_COL - left.len());
} else {
h += "\n{spaces(HELP_COL)}";
}
h += wrap_desc(ho.description, HELP_COL);
h += "\n";
}
if self.has_mixed_value_option() {
h += "\nMandatory arguments to long options are mandatory for short options too.\n";
}
if self.epilog.len() > 0 { h += "\n{self.epilog}\n"; }
if self.bug_address.len() > 0 { h += "\nReport bugs to {self.bug_address}.\n"; }
h
}
tests/01-arguments.loft
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Tests for the arguments library.
use arguments::*;
// Handler functions: called when specific options are selected
fn on_verbose(_val: text) -> text {
"verbose_activated"
}
fn on_output(path: text) -> text {
"output:" + path
}
fn on_count(num: text) -> text {
"count:" + num
}
fn main() {
// --- create ---
a = arguments::create("myapp", "2.0", "A test application");
assert(a.name == "myapp", "create: name");
assert(a.version == "2.0", "create: version");
assert(a.description == "A test application", "create: description");
assert(a.ok(), "create: ok before parse");
// --- flag: long form ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Be verbose");
a.flag("d", "debug", "Debug mode");
assert(a.parse(["--verbose"]), "long flag: parse ok");
assert(a.has("verbose"), "long flag: verbose set");
assert(!a.has("debug"), "long flag: debug not set");
assert(a.get("verbose") == "true", "long flag: value is true");
// --- flag: short form ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Be verbose");
a.flag("d", "debug", "Debug mode");
assert(a.parse(["-v"]), "short flag: parse ok");
assert(a.has("verbose"), "short flag: verbose set");
assert(!a.has("debug"), "short flag: debug not set");
// --- combined short flags ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Be verbose");
a.flag("d", "debug", "Debug mode");
assert(a.parse(["-vd"]), "combined flags: parse ok");
assert(a.has("verbose"), "combined flags: verbose set");
assert(a.has("debug"), "combined flags: debug set");
// --- option with = ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output file");
assert(a.parse(["--output=test.txt"]), "option=: parse ok");
assert(a.has("output"), "option=: output set");
assert(a.get("output") == "test.txt", "option=: value");
// --- option with space ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output file");
assert(a.parse(["--output", "test.txt"]), "option space: parse ok");
assert(a.get("output") == "test.txt", "option space: value");
// --- short option with space ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output file");
assert(a.parse(["-o", "test.txt"]), "short option: parse ok");
assert(a.get("output") == "test.txt", "short option: value");
// --- short option glued value (-ofile.txt) ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output file");
assert(a.parse(["-otest.txt"]), "glued short: parse ok");
assert(a.get("output") == "test.txt", "glued short: value");
// --- combined short flag + short option value ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.option("o", "output", "FILE", "Output");
assert(a.parse(["-vo", "out.txt"]), "combo flag+opt: parse ok");
assert(a.has("verbose"), "combo flag+opt: flag set");
assert(a.get("output") == "out.txt", "combo flag+opt: value");
// --- positional arguments ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(a.parse(["--verbose", "file1.txt", "file2.txt"]), "positional: parse ok");
assert(a.has("verbose"), "positional: flag set");
assert(len(a.positionals) == 2, "positional: count");
assert(a.positionals[0] == "file1.txt", "positional: first");
assert(a.positionals[1] == "file2.txt", "positional: second");
// --- interspersed options and positionals (GNU permutation) ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(a.parse(["file1", "--verbose", "file2"]), "interspersed: parse ok");
assert(a.has("verbose"), "interspersed: flag still parsed after a positional");
assert(len(a.positionals) == 2, "interspersed: two positionals");
// --- -- separator stops option parsing ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(a.parse(["--verbose", "--", "--not-an-option"]), "separator: parse ok");
assert(a.has("verbose"), "separator: flag set");
assert(len(a.positionals) == 1, "separator: one positional");
assert(a.positionals[0] == "--not-an-option", "separator: value preserved");
// --- bare '-' is a positional (stdin convention) ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(a.parse(["-", "-v"]), "dash: parse ok");
assert(a.has("verbose"), "dash: -v still a flag");
assert(len(a.positionals) == 1, "dash: one positional");
assert(a.positionals[0] == "-", "dash: '-' kept as positional");
// --- unambiguous long prefix abbreviation ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.option("o", "output", "FILE", "Output");
assert(a.parse(["--verb", "--out=x.txt"]), "abbrev: parse ok");
assert(a.has("verbose"), "abbrev: --verb -> --verbose");
assert(a.get("output") == "x.txt", "abbrev: --out -> --output");
// --- ambiguous long prefix is rejected ---
a = arguments::create("app", "1.0", "Test");
a.flag("", "verbose", "Verbose");
a.flag("", "version", "Version");
assert(!a.parse(["--ver"]), "ambiguous: parse fails");
assert(a.error_msg().contains("ambiguous"), "ambiguous: error names it");
// --- optional-argument option ---
a = arguments::create("app", "1.0", "Test");
a.optional("c", "color", "WHEN", "Colorize output");
assert(a.parse(["--color"]), "optional bare: parse ok");
assert(a.has("color"), "optional bare: present");
assert(a.get("color") == "", "optional bare: empty value");
a = arguments::create("app", "1.0", "Test");
a.optional("c", "color", "WHEN", "Colorize output");
assert(a.parse(["--color=always"]), "optional=: parse ok");
assert(a.get("color") == "always", "optional=: value");
// --- required option: present ---
a = arguments::create("app", "1.0", "Test");
a.required("c", "config", "PATH", "Config file");
assert(a.parse(["--config=app.conf"]), "required present: parse ok");
assert(a.get("config") == "app.conf", "required present: value");
// --- required option: missing ---
a = arguments::create("app", "1.0", "Test");
a.required("c", "config", "PATH", "Config file");
assert(!a.parse([]), "required missing: parse fails");
assert(!a.ok(), "required missing: not ok");
assert(a.error_msg().contains("config"), "required missing: error mentions name");
// --- unknown long option (GNU wording) ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(!a.parse(["--unknown"]), "unknown long: parse fails");
assert(a.error_msg().contains("unrecognized"), "unknown long: GNU wording");
assert(a.error_msg().contains("unknown"), "unknown long: names option");
// --- unknown short option ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(!a.parse(["-x"]), "unknown short: parse fails");
assert(a.error_msg().contains("invalid option"), "unknown short: GNU wording");
// --- option missing its value ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output file");
assert(!a.parse(["--output"]), "missing value: parse fails");
assert(a.error_msg().contains("output"), "missing value: error message");
assert(a.error_msg().contains("requires an argument"), "missing value: GNU wording");
// --- flag given a value is rejected ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(!a.parse(["--verbose=1"]), "flag+value: parse fails");
assert(a.error_msg().contains("doesn't allow an argument"), "flag+value: GNU wording");
// --- empty argv ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
assert(a.parse([]), "empty: parse ok");
assert(!a.has("verbose"), "empty: no flags set");
assert(len(a.positionals) == 0, "empty: no positionals");
// --- re-parse resets state ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.parse(["--verbose", "x"]);
assert(a.parse(["y"]), "reparse: ok");
assert(!a.has("verbose"), "reparse: verbose cleared");
assert(len(a.positionals) == 1, "reparse: positionals reset");
assert(a.positionals[0] == "y", "reparse: fresh positional");
// --- multiple options together ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.option("o", "output", "FILE", "Output file");
a.option("n", "count", "NUM", "Count");
assert(a.parse(["--verbose", "--output=out.txt", "-n", "42"]), "multi: parse ok");
assert(a.has("verbose"), "multi: verbose");
assert(a.get("output") == "out.txt", "multi: output");
assert(a.get("count") == "42", "multi: count");
// --- typed / defaulted getters ---
assert(a.get_int("count") == 42, "get_int: parses");
assert(a.get_int("output") == null, "get_int: non-numeric -> null");
assert(a.get_int("verbose") == null || a.get_int("verbose") != null, "get_int: flag value tolerated");
assert(a.get_or("output", "def") == "out.txt", "get_or: set value");
assert(a.get_or("missingopt", "def") == "def", "get_or: fallback");
// --- get for unset option returns null ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output");
a.parse([]);
assert(!a.get("output"), "unset: get returns null");
assert(!a.has("output"), "unset: has returns false");
// --- get for nonexistent option returns null ---
assert(!a.get("nonexistent"), "nonexistent: get returns null");
assert(!a.has("nonexistent"), "nonexistent: has returns false");
// --- option without short name ---
a = arguments::create("app", "1.0", "Test");
a.flag("", "verbose", "Verbose");
assert(a.parse(["--verbose"]), "no short: parse ok");
assert(a.has("verbose"), "no short: flag set");
// --- opt-in --help / --version ---
a = arguments::create("myapp", "1.2", "My application");
a.flag("v", "verbose", "Be verbose");
a.enable_help();
assert(a.parse(["--help"]), "help: parse ok");
assert(a.wants_help(), "help: wants_help set");
assert(!a.wants_version(), "help: not version");
a = arguments::create("myapp", "1.2", "My application");
a.enable_help();
assert(a.parse(["--version"]), "version: parse ok");
assert(a.wants_version(), "version: wants_version set");
assert(a.version_text() == "myapp 1.2", "version_text content");
// --help bypasses required validation
a = arguments::create("myapp", "1.2", "My application");
a.required("c", "config", "PATH", "Config");
a.enable_help();
assert(a.parse(["--help"]), "help bypasses required check");
assert(a.wants_help(), "help bypass: wants_help");
// --- auto-generated GNU help text ---
a = arguments::create("myapp", "1.0", "My application");
a.flag("v", "verbose", "Enable verbose output");
a.option("o", "output", "FILE", "Output file path");
a.required("c", "config", "PATH", "Configuration file");
a.set_bug_address("bugs@example.com");
help_text = a.help();
assert(help_text.contains("Usage:"), "help: usage line");
assert(help_text.contains("myapp"), "help: program name");
assert(help_text.contains("My application"), "help: description");
assert(help_text.contains("-v, --verbose"), "help: short+long shown together");
assert(help_text.contains("--output=FILE"), "help: value metavar");
assert(help_text.contains("Configuration file"), "help: description text");
assert(help_text.contains("Mandatory arguments"), "help: mandatory note");
assert(help_text.contains("Report bugs to bugs@example.com"), "help: bug address");
assert(a.version_text().contains("1.0"), "version_text has version");
assert(a.try_help().contains("--help"), "try_help hint");
// --- handler dispatch pattern ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.option("o", "output", "FILE", "Output");
a.option("n", "count", "NUM", "Count");
a.parse(["--verbose", "--output=report.txt", "--count=5"]);
v_result = "";
o_result = "";
n_result = "";
if a.has("verbose") { v_result = on_verbose(a.get("verbose") ?? ""); }
if a.has("output") { o_result = on_output(a.get("output") ?? ""); }
if a.has("count") { n_result = on_count(a.get("count") ?? ""); }
assert(v_result == "verbose_activated", "handler: verbose called");
assert(o_result == "output:report.txt", "handler: output called");
assert(n_result == "count:5", "handler: count called");
// --- handlers NOT called for unset options ---
a = arguments::create("app", "1.0", "Test");
a.flag("v", "verbose", "Verbose");
a.option("o", "output", "FILE", "Output");
a.parse(["--verbose"]);
skip_v = "";
skip_o = "";
if a.has("verbose") { skip_v = on_verbose(a.get("verbose") ?? ""); }
if a.has("output") { skip_o = on_output(a.get("output") ?? ""); }
assert(skip_v == "verbose_activated", "skip handler: verbose called");
assert(skip_o == "", "skip handler: output NOT called");
// --- option with empty value (--output=) ---
a = arguments::create("app", "1.0", "Test");
a.option("o", "output", "FILE", "Output");
assert(a.parse(["--output="]), "empty value: parse ok");
assert(a.has("output"), "empty value: has returns true");
assert(a.get("output") == "", "empty value: get returns empty");
println("All argument tests passed");
}
fn test_flag_long() {
tf = arguments::create("app", "1.0", "Test");
tf.flag("v", "verbose", "Verbose");
assert(tf.parse(["--verbose"]), "long flag");
assert(tf.has("verbose"), "flag was set");
}
fn test_flag_short() {
ts = arguments::create("app", "1.0", "Test");
ts.flag("v", "verbose", "Verbose");
assert(ts.parse(["-v"]), "short flag");
assert(ts.has("verbose"), "flag was set");
}
fn test_option_equals() {
te = arguments::create("app", "1.0", "Test");
te.option("o", "output", "FILE", "Output");
assert(te.parse(["--output=file.txt"]), "option=value");
assert(te.get("output") == "file.txt", "value correct");
}
fn test_option_space() {
tsp = arguments::create("app", "1.0", "Test");
tsp.option("o", "output", "FILE", "Output");
assert(tsp.parse(["--output", "file.txt"]), "option value");
assert(tsp.get("output") == "file.txt", "value correct");
}
fn test_required_missing() {
trm = arguments::create("app", "1.0", "Test");
trm.required("c", "config", "PATH", "Config");
assert(!trm.parse([]), "missing required fails");
assert(!trm.ok(), "not ok");
}
fn test_combined_flags() {
tcf = arguments::create("app", "1.0", "Test");
tcf.flag("a", "alpha", "Alpha");
tcf.flag("b", "beta", "Beta");
tcf.flag("g", "gamma", "Gamma");
assert(tcf.parse(["-abg"]), "combined parse ok");
assert(tcf.has("alpha"), "alpha set");
assert(tcf.has("beta"), "beta set");
assert(tcf.has("gamma"), "gamma set");
}
fn test_positional_args() {
tp = arguments::create("app", "1.0", "Test");
assert(tp.parse(["one", "two", "three"]), "positional parse ok");
assert(len(tp.positionals) == 3, "three positionals");
assert(tp.positionals[0] == "one", "first arg");
}
fn test_separator() {
tse = arguments::create("app", "1.0", "Test");
tse.flag("v", "verbose", "Verbose");
assert(tse.parse(["--", "-v"]), "separator parse");
assert(!tse.has("verbose"), "verbose not set after --");
assert(len(tse.positionals) == 1, "one positional");
assert(tse.positionals[0] == "-v", "-v as positional");
}
fn test_abbreviation() {
tab = arguments::create("app", "1.0", "Test");
tab.flag("v", "verbose", "Verbose");
assert(tab.parse(["--verb"]), "abbrev parse");
assert(tab.has("verbose"), "abbrev resolves");
}
fn test_help_flag() {
thf = arguments::create("app", "1.0", "Test");
thf.enable_help();
assert(thf.parse(["--help"]), "help parse");
assert(thf.wants_help(), "wants_help");
}
fn test_handler_dispatch() {
th = arguments::create("app", "1.0", "Test");
th.flag("v", "verbose", "Verbose");
th.option("o", "output", "FILE", "Output");
th.parse(["--verbose", "-o", "out.txt"]);
th_v = "";
th_o = "";
if th.has("verbose") { th_v = on_verbose(th.get("verbose") ?? ""); }
if th.has("output") { th_o = on_output(th.get("output") ?? ""); }
assert(th_v == "verbose_activated", "verbose handler called");
assert(th_o == "output:out.txt", "output handler called");
}
fn test_help_text() {
tht = arguments::create("prog", "3.0", "A program");
tht.flag("v", "verbose", "Enable verbose output");
tht.option("o", "output", "FILE", "Output file path");
tht.required("c", "config", "PATH", "Configuration file");
tht_help = tht.help();
assert(tht_help.contains("prog"), "name in help");
assert(tht_help.contains("-v, --verbose"), "short+long in help");
assert(tht_help.contains("--output=FILE"), "metavar in help");
assert(tht_help.contains("Usage:"), "usage in help");
assert(tht.version_text().contains("3.0"), "version in version_text");
}
// A value option takes the next word as its value whatever it looks like, as getopt_long does.
fn test_value_that_looks_like_an_option() {
a = arguments::create("p", "1", "d");
a.option("n", "num", "N", "a number");
a.flag("v", "verbose", "v");
assert(a.parse(["--num", "-5"]), "--num -5 parses");
assert(a.get_int("num") == -5, "…and -5 is the value: {a.get(\"num\")}");
assert(a.parse(["-n", "--verbose"]), "-n --verbose parses");
assert(a.get("num") == "--verbose" && !a.has("verbose"), "…the word is the value, not the flag");
assert(a.parse(["--num", "--", "x"]), "--num -- parses");
assert(a.get("num") == "--" && a.positionals[0] == "x", "…`--` is the value and option parsing goes on");
assert(!a.parse(["--num"]), "a missing value is still an error");
assert(a.error_msg() == "option '--num' requires an argument", "…with its message");
}
// `enable_help` claims -h / -V only while no option of the program's own wants them.
fn test_help_gives_short_letters_back() {
a = arguments::create("p", "1", "d");
a.enable_help();
a.option("h", "host", "HOST", "the host");
assert(a.parse(["-h", "example.org"]), "-h parses");
assert(a.get("host") == "example.org" && !a.wants_help(), "…as the program's -h, declared after enable_help");
assert(a.parse(["--help"]), "--help still parses");
assert(a.wants_help(), "…as help");
assert(!a.help().contains("-h, --help"), "help lists --help without the letter it gave away");
b = arguments::create("p", "1", "d");
b.enable_help();
assert(b.parse(["-h"]) && b.wants_help(), "with no rival, -h is help");
}
// An option declared with no long name is queried by its short one.
fn test_short_only_option() {
a = arguments::create("p", "1", "d");
a.flag("x", "", "short only");
a.option("o", "", "FILE", "short only, with a value");
assert(a.parse(["-x", "-o", "f.txt"]), "parses");
assert(a.has("x") && a.get("o") == "f.txt", "reached by the short name");
assert(!a.has(""), "the empty name reaches nothing");
}
tests/02-worked-examples.loft
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Worked examples for the `arguments` library (@PLN141). Each `test_*` below is a
// REAL, run-every-CI demonstration of a function whose correct use is not obvious
// from its signature alone; the function documents itself by CITING the tag with
// `// Example: @ARG-00N`, and `check_doc_drift.sh examples` fails if a citation goes
// dangling (no test carries the tag). These are teaching examples, distinct from the
// exhaustive assertion suite in 01-arguments.loft.
use arguments::*;
// @ARG-001 — the declare → parse → query lifecycle. Build an `Args`, declare its
// options, then `parse(argv)` returns a boolean (false on a usage error, see @ARG-004);
// afterwards flags read through `has`, value options through `get`, and every
// non-option word lands in `positionals` in order. This SEQUENCE is the whole API.
fn test_parse_lifecycle() {
a = arguments::create("build", "1.0", "Compile a project");
a.flag("v", "verbose", "Be verbose");
a.option("o", "output", "FILE", "Where to write the result");
// A valid command line parses; short flag, long value option, and a positional.
assert(a.parse(["-v", "--output=app.bin", "src/main"]), "a valid line parses");
assert(a.has("verbose"), "the flag reads as present");
assert(a.get("output") == "app.bin", "the value option carries its value");
assert(len(a.positionals) == 1, "the leftover word is a positional");
assert(a.positionals[0] == "src/main", "positionals keep their order");
}
// @ARG-002 — an optional-argument option takes a value ONLY when it is GLUED
// (`--color=auto` or `-cauto`). Given bare (`--color`) it is present with an empty
// value — it does NOT consume the following word, and it is NOT an error. That
// glued-only rule is exactly what separates `optional` from `option` (which always
// wants a value) and from `flag` (which never takes one).
fn test_optional_glued_only() {
a = arguments::create("ls", "1.0", "List files");
a.optional("c", "color", "WHEN", "Colorize the output");
// Bare: present, value "", and the next word stays a positional.
assert(a.parse(["--color", "file.txt"]), "bare optional parses");
assert(a.has("color"), "bare --color is present");
assert(a.get("color") == "", "bare --color resolves to an empty value");
assert(a.positionals[0] == "file.txt", "the following word is NOT eaten as its value");
// Glued: the value attaches.
a = arguments::create("ls", "1.0", "List files");
a.optional("c", "color", "WHEN", "Colorize the output");
assert(a.parse(["--color=always"]), "glued optional parses");
assert(a.get("color") == "always", "--color=always carries its value");
}
// @ARG-003 — opt-in help/version. `enable_help()` registers `--help`/`-h` and
// `--version`/`-V`, but prints NOTHING itself: after `parse`, the caller asks
// `wants_help()` / `wants_version()` and renders `help()` / `version_text()`. A
// `--help` line also short-circuits the required-option check — GNU prints help
// without validating — so it parses OK even when a mandatory option is missing.
fn test_enable_help_optin() {
a = arguments::create("myapp", "2.0", "A demo application");
a.required("c", "config", "PATH", "Config file");
a.enable_help();
// --help parses despite the required --config being absent...
assert(a.parse(["--help"]), "--help parses even with a required option missing");
assert(a.wants_help(), "the caller learns to print help via wants_help()");
assert(!a.wants_version(), "it was help, not version");
// ...and nothing was printed, so the caller drives the output itself.
assert(a.help().contains("Usage:"), "help() renders the screen on demand");
}
// @ARG-004 — a required option makes `parse` FAIL when it is absent, and this is how
// EVERY usage error surfaces: `parse` returns false, `ok()` is false, `error_msg()`
// gives the GNU-style reason, and `try_help()` is the "Try '<prog> --help' …" hint to
// print beside it. A real program prints those two lines and exits non-zero.
fn test_required_error_idiom() {
a = arguments::create("deploy", "1.0", "Ship a build");
a.required("t", "target", "HOST", "Where to deploy");
assert(!a.parse([]), "a missing required option fails the parse");
assert(!a.ok(), "ok() reports the failure");
assert(a.error_msg().contains("target"), "error_msg() names the missing option");
assert(a.try_help().contains("--help"), "try_help() is the hint to show the user");
}