arguments source

← 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.

ItemDefinedWorked exampleCall sites
Args struct84—114 115 126 148 154 160 … (31 total)
name const85—114 116 229 232 423 424 … (23 total)
version const86—114 116 190 483 23 128 … (12 total)
description const87—60 114 116 135 141 554 … (10 total)
create fn114—21 22 23 24 25 28 … (60 total)
flag fn148—67 29 30 31 32 33 … (62 total)
option fn154—68 69 70 283 285 302 … (53 total)
required fn160ARG-004410 145 146 147 151 152 … (18 total)
optional fn167ARG-00269 134 135 136 137 139 … (12 total)
set_usage fn172—none in this package
set_bug_address fn175—256
set_epilog fn178—none in this package
enable_help fn185ARG-003235 240 247 378 427 430 … (8 total)
parse fn347ARG-00125 31 40 48 55 62 … (59 total)
has fn431—32 33 41 42 49 50 … (41 total)
get fn444—457 462 34 57 63 69 … (34 total)
get_or fn456—212 213
get_int fn461—209 210 211 415
ok fn469—370 378 384 388 25 31 … (33 total)
error_msg fn473—130 154 160 161 167 173 … (10 total)
wants_help fn477—237 249 380 430 432 436 … (7 total)
wants_version fn480—238 242 59
version_text fn483—243 266 406
try_help fn486—267 74
help fn550—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");
}