// std/cli/argparse — declarative argument parser for CLI subcommand
// `.harn` scripts dispatched via the harn-cli wedge (harn#2293 epic,
// harn#2295 G2). Each ported subcommand declares a parser spec and
// calls parse(spec, argv); the returned ParseResult is either
// `{ ok: dict }` with one entry per registered arg (plus `rest` for
// anything after `--`), or `{ err: ParseError }`.
//
// Out of scope here (per ticket): nested subcommands (each subcommand
// is its own script and top-level dispatch has already picked it),
// shell completions, value-range validators beyond `value_required`.
//
// Argument kinds:
// "positional" — required by default; set `required: false` to opt out.
// Set `variadic: true` to greedily collect remaining
// positionals into a list.
// "flag" — takes a value. `--long val`, `--long=val`, `-s val`,
// or `-sVALUE`. Set `multi: true` to collect repeats.
// "switch" — boolean toggle. `--long` or `-s`, no value allowed.
/**
* Declarative spec for a single argument or flag.
*
* - `name`: required, becomes the key in `parse` output.
* - `kind`: "positional" | "flag" | "switch".
* - `short`/`long`: flag/switch only. Conventionally include the
* leading dashes (`"-m"`, `"--model"`).
* - `required`: positionals default true; flags/switches default false.
* - `multi`: flag only. Collects repeats into a list.
* - `variadic`: positional only. Greedy.
* - `value_name`: shown in --help; defaults to UPPERCASE(name).
* - `help`: one-line description.
* - `default`: value when omitted; falls back to false for switches and
* `[]` for multi flags.
*/
type ArgSpec = {
name: string,
kind: string,
short?: string,
long?: string,
required?: bool,
multi?: bool,
variadic?: bool,
value_name?: string,
help?: string,
default?: any,
}
/** Parser declaration. */
type ParserSpec = {name: string, about?: string, args: list<ArgSpec>, examples?: list<string>}
/**
* Structured error returned in `ParseResult.err`. Callers decide whether
* to render with `render_help` or rethrow.
*
* `kind` values:
* "missing_required" — a required arg was not supplied
* "unknown_flag" — `--foo` not registered in the spec
* "unknown_arg" — extra positional past the registered count
* "value_required" — a flag was passed without its value
* "bad_value" — value supplied where none expected (switch)
*/
type ParseError = {kind: string, arg?: string, hint?: string}
type ParseResult = {ok?: dict, err?: ParseError}
/**
* Validate a parser spec and return it. Currently a pass-through plus
* structural checks; in the future this may precompute lookup tables.
*
* Throws on programmer error (invalid kind or missing name) since the
* spec is static and any failure here means the script is buggy.
*
* @effects: []
* @allocation: none
* @errors: ["argparse: every arg must have a non-empty name", "argparse: arg N has invalid kind 'K'; expected positional|flag|switch"]
* @api_stability: stable
* @example: parser({name: "demo", args: [{name: "input", kind: "positional"}]})
*/
pub fn parser(spec: ParserSpec) -> ParserSpec {
for arg in spec.args {
let name = arg.name
if name == nil || name == "" {
throw "std/cli/argparse: every arg must have a non-empty name"
}
let kind = arg.kind ?? ""
if kind != "positional" && kind != "flag" && kind != "switch" {
throw "std/cli/argparse: arg " + name + " has invalid kind '" + kind
+ "'; expected positional|flag|switch"
}
}
return spec
}
/** --- Lookup helpers --------------------------------------------------- */
fn __find_long(args, key) {
for arg in args {
if arg?.long == key {
return arg
}
}
return nil
}
fn __find_short(args, key) {
for arg in args {
if arg?.short == key {
return arg
}
}
return nil
}
fn __positionals(args) {
var out = []
for arg in args {
if arg?.kind == "positional" {
out = out.push(arg)
}
}
return out
}
fn __flags_only(args) {
var out = []
for arg in args {
if arg?.kind == "flag" || arg?.kind == "switch" {
out = out.push(arg)
}
}
return out
}
/** --- Parse ------------------------------------------------------------ */
fn __record_value(collected, arg, value) {
if arg?.multi {
let prior = collected[arg.name] ?? []
return collected.merge({[arg.name]: prior.push(value)})
}
return collected.merge({[arg.name]: value})
}
fn __apply_defaults(collected, args) {
var out = collected
for arg in args {
if out[arg.name] != nil {
continue
}
if arg?.default != nil {
out = out.merge({[arg.name]: arg.default})
continue
}
if arg?.kind == "switch" {
out = out.merge({[arg.name]: false})
continue
}
if arg?.kind == "flag" && arg?.multi {
out = out.merge({[arg.name]: []})
}
}
return out
}
fn __first_missing_required(args, collected) -> string {
for arg in args {
if collected[arg.name] != nil {
continue
}
// Positionals default to required; flags/switches default to
// optional. The `?? true|false` form makes the nil case explicit
// since the linter rewrites bare `arg?.required` to a truthy
// check (which treats nil as false and breaks the positional
// default).
let default_req = arg?.kind == "positional"
let is_required = arg?.required ?? default_req
if is_required {
return arg.name
}
}
return ""
}
fn __long_split(body) {
// body is the post-"--" text, e.g. "foo=bar baz".
// Returns {key: "--KEY", value: VALUE_OR_NIL}.
if !contains(body, "=") {
return {key: "--" + body, value: nil}
}
let parts = split(body, "=")
let key = parts[0]
// Re-join everything after the first "=" so a value containing
// further `=` survives intact.
let value = join(parts.slice(1, len(parts)), "=")
return {key: "--" + key, value: value}
}
/**
* Parse argv against a spec. Returns `{ ok: dict }` with one key per
* registered arg (plus `rest: list<string>` for everything after `--`)
* on success, or `{ err: ParseError }` on failure.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: parse(spec, argv)
*/
pub fn parse(spec: ParserSpec, argv: list<string>) -> ParseResult {
var collected = {}
var rest = []
var pending: any = nil
var seen_dash_dash = false
var positional_idx = 0
let positionals = __positionals(spec.args)
for arg_str in argv {
if seen_dash_dash {
rest = rest.push(arg_str)
continue
}
if pending != nil {
collected = __record_value(collected, pending, arg_str)
pending = nil
continue
}
if arg_str == "--" {
seen_dash_dash = true
continue
}
// Long flag: --key, --key=value
if starts_with(arg_str, "--") && len(arg_str) > 2 {
let body = substring(arg_str, 2, len(arg_str))
let {key, value: value_opt} = __long_split(body)
let arg = __find_long(spec.args, key)
if arg == nil {
return {err: {kind: "unknown_flag", arg: arg_str, hint: "no flag named ${key}"}}
}
if arg.kind == "switch" {
if value_opt != nil {
return {err: {kind: "bad_value", arg: arg_str, hint: "switch ${key} does not take a value"}}
}
collected = collected.merge({[arg.name]: true})
continue
}
if value_opt != nil {
collected = __record_value(collected, arg, value_opt)
} else {
pending = arg
}
continue
}
// Short flag: -x, -xVALUE
if starts_with(arg_str, "-") && len(arg_str) > 1 {
let short = substring(arg_str, 0, 2)
let arg = __find_short(spec.args, short)
if arg == nil {
return {err: {kind: "unknown_flag", arg: arg_str, hint: "no flag named ${short}"}}
}
if arg.kind == "switch" {
if len(arg_str) > 2 {
return {err: {kind: "bad_value", arg: arg_str, hint: "switch ${short} does not take a value"}}
}
collected = collected.merge({[arg.name]: true})
continue
}
// Flag: either inline -xVALUE or split -x VALUE
if len(arg_str) > 2 {
let v = substring(arg_str, 2, len(arg_str))
collected = __record_value(collected, arg, v)
} else {
pending = arg
}
continue
}
// Positional
if positional_idx >= len(positionals) {
return {err: {kind: "unknown_arg", arg: arg_str, hint: "unexpected positional"}}
}
let pos = positionals[positional_idx]
if pos?.variadic {
// Don't advance positional_idx — variadics swallow the rest.
let prior = collected[pos.name] ?? []
collected = collected.merge({[pos.name]: prior.push(arg_str)})
} else {
collected = collected.merge({[pos.name]: arg_str})
positional_idx = positional_idx + 1
}
}
if pending != nil {
let label = pending?.long ?? pending?.short ?? pending.name
return {err: {kind: "value_required", arg: label, hint: "flag requires a value"}}
}
collected = __apply_defaults(collected, spec.args)
let missing = __first_missing_required(spec.args, collected)
if missing != "" {
return {
err: {kind: "missing_required", arg: missing, hint: "required argument '${missing}' was not provided"},
}
}
return {ok: collected.merge({rest: rest})}
}
/** --- Help rendering --------------------------------------------------- */
fn __value_name(arg) -> string {
if arg?.value_name != nil {
return arg.value_name
}
return uppercase(arg.name)
}
fn __usage_token(arg) -> string {
if arg.kind == "positional" {
// Positionals default to required when `required` is unset.
let is_required = arg?.required ?? true
if !is_required {
return "[${arg.name}]"
}
if arg?.variadic {
return "<${arg.name}>..."
}
return "<${arg.name}>"
}
let flag_marker = arg?.long ?? arg?.short ?? ("--" + arg.name)
if arg.kind == "switch" {
return "[${flag_marker}]"
}
return "[${flag_marker} <${__value_name(arg)}>]"
}
fn __option_lhs(arg) -> string {
let short = arg?.short ?? ""
let long = arg?.long ?? ""
let combined = if short != "" && long != "" {
"${short}, ${long}"
} else if long != "" {
" ${long}"
} else if short != "" {
"${short}"
} else {
" --${arg.name}"
}
if arg.kind == "flag" {
return " ${combined} <${__value_name(arg)}>"
}
return " ${combined}"
}
fn __positional_lhs(arg) -> string {
// Positionals default to required when `required` is unset.
let is_required = arg?.required ?? true
let token = if arg?.variadic {
"<${arg.name}>..."
} else if !is_required {
"[${arg.name}]"
} else {
"<${arg.name}>"
}
return " ${token}"
}
fn __help_arg_line(arg) -> string {
let lhs = if arg.kind == "positional" {
__positional_lhs(arg)
} else {
__option_lhs(arg)
}
let help = arg?.help ?? ""
if help == "" {
return lhs
}
return str_pad(lhs, 32, " ") + " " + help
}
/**
* Render the `--help` text for a parser spec. The format is stable —
* snapshot tests pin it — so changes here must intentionally update
* the conformance fixtures.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: render_help(spec)
*/
pub fn render_help(spec: ParserSpec) -> string {
var lines = []
if spec.about != nil && spec.about != "" {
lines = lines.push(spec.about)
lines = lines.push("")
}
let flags = __flags_only(spec.args)
let positionals = __positionals(spec.args)
var usage_tokens = [" " + spec.name]
if len(flags) > 0 {
usage_tokens = usage_tokens.push("[OPTIONS]")
}
for arg in positionals {
usage_tokens = usage_tokens.push(__usage_token(arg))
}
lines = lines.push("USAGE:")
lines = lines.push(join(usage_tokens, " "))
if len(positionals) > 0 {
lines = lines.push("")
lines = lines.push("ARGS:")
for arg in positionals {
lines = lines.push(__help_arg_line(arg))
}
}
if len(flags) > 0 {
lines = lines.push("")
lines = lines.push("OPTIONS:")
for arg in flags {
lines = lines.push(__help_arg_line(arg))
}
lines = lines.push(str_pad(" -h, --help", 32, " ") + " Print help")
}
if spec.examples != nil && len(spec.examples) > 0 {
lines = lines.push("")
lines = lines.push("EXAMPLES:")
for ex in spec.examples {
lines = lines.push(" ${ex}")
}
}
return join(lines, "\n")
}