From 0fc7a72cbcfcfcad410fcd42ffe009ea07895153 Mon Sep 17 00:00:00 2001 From: agra Date: Fri, 5 Jun 2026 01:01:25 +0300 Subject: [PATCH] feat(lang): std.cli exit-code + --json contract helpers [F3.3] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation milestone close — the minimal exit-code / --json contract `dist` relies on, in pure sx (no compiler change). - EX_OK (0) / EX_USAGE (64, sysexits.h) / EX_UNAVAILABLE (70) named constants in std.cli. - exit_ok() / exit_usage() terminators routing through the canonical process.exit(code: u8) — removes the hand-rolled cli_bail_exit `_exit` binding; the unsupported-platform path now uses proc.exit(EX_UNAVAILABLE). - --json read is parsed.json (already parsed by F3.2); documented as the detection point with a stdout-pure / stderr-human convention. - examples/0718-modules-cli-exit-json.sx exercises the contract: json true with --json / false without, EX_USAGE == 64, and a usage path that exits 64 via exit_usage() (expected .exit = 64). - readme.md gains a std.cli command-line-interface subsection. --- examples/0718-modules-cli-exit-json.sx | 69 +++++++++++++++++++ .../expected/0718-modules-cli-exit-json.exit | 1 + .../0718-modules-cli-exit-json.stderr | 1 + .../0718-modules-cli-exit-json.stdout | 6 ++ library/modules/std/cli.sx | 46 +++++++++++-- readme.md | 27 ++++++++ 6 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 examples/0718-modules-cli-exit-json.sx create mode 100644 examples/expected/0718-modules-cli-exit-json.exit create mode 100644 examples/expected/0718-modules-cli-exit-json.stderr create mode 100644 examples/expected/0718-modules-cli-exit-json.stdout diff --git a/examples/0718-modules-cli-exit-json.sx b/examples/0718-modules-cli-exit-json.sx new file mode 100644 index 0000000..59af353 --- /dev/null +++ b/examples/0718-modules-cli-exit-json.sx @@ -0,0 +1,69 @@ +// std.cli EXIT-CODE + `--json` contract (F3.3 — FOUNDATION MILESTONE CLOSE). +// +// The minimal contract `dist` (and any sx CLI front-end) relies on: +// +// 1. NAMED EXIT CODES — `EX_OK` (0) and `EX_USAGE` (64, the sysexits.h +// usage-error code) are public constants in `std.cli`. +// 2. TERMINATORS — `exit_ok()` / `exit_usage()` end the process with the +// matching code, routing through the canonical `process.exit(code: u8)`. +// 3. `--json` MODE — the reserved global `--json` flag surfaces as +// `parsed.json`: TRUE when `--json` is in the argv, FALSE when it is not. +// In json mode stdout carries ONLY the machine result; human text goes +// to stderr (here via `log.err`, which writes `ERROR: …` to fd 2). +// +// The run DELIBERATELY ends on the usage path: after the assertions it +// triggers an `UnknownCommand`, writes the human diagnostic to STDERR, and +// terminates via `exit_usage()` — so the process exits 64 (EX_USAGE), +// captured in expected/0718-…​.exit. (sx `print`/`out` are unbuffered, so the +// stdout assertion lines still appear despite the `_exit`.) + +#import "modules/std.sx"; +#import "modules/std/cli.sx"; +log :: #import "modules/log.sx"; + +report :: (label: string, ok: bool) { + if ok { print("{}: ok\n", label); } else { print("{}: FAIL\n", label); } +} + +main :: () -> ! { + publish_flags : []FlagSpec = .[ + FlagSpec.{ name = "out", takes_value = true, required = true }, + ]; + cmds : []Command = .[ + Command.{ group = "ci", command = "publish", flags = publish_flags }, + ]; + + // ── 1. Named exit-code constants (the dist contract) ────────────── + report("ex-ok-is-0", EX_OK == 0); + report("ex-usage-is-64", EX_USAGE == 64); + + // ── 2. `--json` detection — true with the flag, false without ───── + d : Diag = .{}; + with_json : []string = .["ci", "publish", "--out", "dist", "--json"]; + without_json : []string = .["ci", "publish", "--out", "dist"]; + pj := try parse(with_json, cmds, @d); + pn := try parse(without_json, cmds, @d); + report("json-set-true", pj.json); + report("json-set-false", !pn.json); + + // ── 3. json mode keeps stdout machine-pure ──────────────────────── + // When `parsed.json`, the front-end emits ONLY the machine result on + // stdout. `out` writes the bytes verbatim (no `{}` interpolation), so + // the JSON braces are literal. + if pj.json { + out("{\"out\":\""); + out(pj.value_of("out")); + out("\"}\n"); + } + + // ── 4. Usage error → human text to stderr → exit_usage() (= 64) ─── + // A bad command raises a `CliError`. The front-end maps every usage + // error to `EX_USAGE`: it writes the human diagnostic to STDERR (stdout + // stays machine-clean) and terminates with the usage code via the + // canonical `process.exit`. + bad : []string = .["ci", "deploy", "--out", "x"]; // unknown command + _, e := parse(bad, cmds, @d); + report("usage-error-raised", e == error.UnknownCommand); + log.err("unknown command '{}' (argv index {})", d.token, d.index); + exit_usage(); // -> _exit(EX_USAGE = 64) +} diff --git a/examples/expected/0718-modules-cli-exit-json.exit b/examples/expected/0718-modules-cli-exit-json.exit new file mode 100644 index 0000000..900731f --- /dev/null +++ b/examples/expected/0718-modules-cli-exit-json.exit @@ -0,0 +1 @@ +64 diff --git a/examples/expected/0718-modules-cli-exit-json.stderr b/examples/expected/0718-modules-cli-exit-json.stderr new file mode 100644 index 0000000..b16b68b --- /dev/null +++ b/examples/expected/0718-modules-cli-exit-json.stderr @@ -0,0 +1 @@ +ERROR: unknown command 'deploy' (argv index 1) diff --git a/examples/expected/0718-modules-cli-exit-json.stdout b/examples/expected/0718-modules-cli-exit-json.stdout new file mode 100644 index 0000000..843078b --- /dev/null +++ b/examples/expected/0718-modules-cli-exit-json.stdout @@ -0,0 +1,6 @@ +ex-ok-is-0: ok +ex-usage-is-64: ok +json-set-true: ok +json-set-false: ok +{"out":"dist"} +usage-error-raised: ok diff --git a/library/modules/std/cli.sx b/library/modules/std/cli.sx index 22057e3..03291fa 100644 --- a/library/modules/std/cli.sx +++ b/library/modules/std/cli.sx @@ -25,11 +25,13 @@ // // Platform: macOS only for now, via the C runtime's `_NSGetArgv()` // (char***) and `_NSGetArgc()` (int*). On any other OS the accessors bail -// loudly (message + non-zero exit) rather than returning a silent empty. +// loudly (message + `EX_UNAVAILABLE` exit via `process.exit`) rather than +// returning a silent empty. // ===================================================================== #import "modules/std.sx"; #import "modules/compiler.sx"; +proc :: #import "modules/process.sx"; libc :: #library "c"; @@ -42,9 +44,39 @@ libc :: #library "c"; ns_get_argv :: () -> *s64 #foreign libc "_NSGetArgv"; ns_get_argc :: () -> *s32 #foreign libc "_NSGetArgc"; -// Bound to POSIX `_exit(2)`. Used only on the unsupported-platform path to -// terminate loudly instead of handing back a misleading empty slice. -cli_bail_exit :: (code: s32) -> noreturn #foreign libc "_exit"; +// ===================================================================== +// EXIT-CODE & `--json` CONTRACT (F3.3) — the minimal surface `dist` (and +// any sx CLI front-end) relies on. Two pieces: +// +// 1. NAMED EXIT CODES (sysexits.h subset). A front-end ends with the +// code that matches the outcome: `EX_OK` on success, `EX_USAGE` +// after a `parse` failure (a `CliError` — bad command / flag / +// value), `EX_UNAVAILABLE` when the platform is unsupported. +// 2. TERMINATORS. `exit_ok()` / `exit_usage()` end the process with the +// matching code. They route through the canonical +// `process.exit(code: u8)` (modules/process.sx) — there is NO second +// hand-rolled `_exit` binding in this module; the unsupported-platform +// path below goes through `proc.exit(EX_UNAVAILABLE)` too. +// +// `--json` MODE needs no new code here: the parser already surfaces it as +// `parsed.json` (true iff `--json` appears in the argv — see `Parsed.json`). +// The convention a front-end follows: in json mode stdout carries ONLY the +// machine result, and human diagnostics go to stderr (e.g. via +// `modules/log.sx`'s `log.err`). Detect json mode by reading `parsed.json`. +// ===================================================================== + +EX_OK :u8: 0; // success +EX_USAGE :u8: 64; // command-line usage error (sysexits.h EX_USAGE) +EX_UNAVAILABLE :u8: 70; // service / platform unavailable (sysexits.h) + +// End the process successfully (`EX_OK` = 0). Thin wrapper over the +// canonical `process.exit` terminator — immediate `_exit(2)`, no unwinding. +exit_ok :: () -> noreturn { proc.exit(EX_OK); } + +// End the process with the usage-error code (`EX_USAGE` = 64). A CLI +// front-end calls this after `parse` raises a `CliError` and the human +// diagnostic has been written to stderr. +exit_usage :: () -> noreturn { proc.exit(EX_USAGE); } // Number of process arguments (argc). >= 1 for any normally-launched // process, since argv[0] is the executable path. @@ -53,7 +85,7 @@ os_argc :: () -> s64 { case .macos: { return cast(s64) ns_get_argc().*; } else: { out("std.cli: unsupported platform — only macOS is implemented (needs _NSGetArgv/_NSGetArgc).\n"); - cli_bail_exit(70); + proc.exit(EX_UNAVAILABLE); } } } @@ -88,7 +120,7 @@ os_args :: (buf: []string) -> []string { } else: { out("std.cli: unsupported platform — only macOS is implemented (needs _NSGetArgv/_NSGetArgc).\n"); - cli_bail_exit(70); + proc.exit(EX_UNAVAILABLE); } } } @@ -194,7 +226,7 @@ Parsed :: struct { group: string; command: string; cmd_index: s64; - json: bool; + json: bool; // `--json` mode: true iff `--json` is in argv rest: []string; spec: []FlagSpec; // view of the matched command's flag specs values: [16]FlagValue; // fixed inline storage, indexed by spec position diff --git a/readme.md b/readme.md index 6bed385..e1846ec 100644 --- a/readme.md +++ b/readme.md @@ -425,6 +425,33 @@ The standard library (`modules/std.sx`) provides: - **Math**: `sqrt`, `sin`, `cos` - **Introspection**: `type_of`, `type_name`, `field_count`, `field_name`, `field_value`, `size_of` +### Command-line interface (`modules/std/cli.sx`) + +`std.cli` builds command-line front-ends over an explicit logical argv +(`[]string`): `os_args(buf)` reads the real process argv, and +`parse(args, commands, diag) -> !Parsed` does subcommand dispatch + `--flag` +parsing. On top of that it defines the small **exit-code / `--json` contract** +a CLI program (e.g. `dist`) relies on: + +```sx +#import "modules/std/cli.sx"; + +p, e := parse(args, cmds, @diag); // (Parsed, !CliError) +if e == error.UnknownCommand { + log.err("unknown command '{}'", diag.token); // human text -> stderr + exit_usage(); // usage error -> exit 64 +} +if p.json { /* emit ONLY machine output on stdout */ } +``` + +- **Named exit codes** — `EX_OK` (0), `EX_USAGE` (64, the sysexits.h + command-line-usage code), `EX_UNAVAILABLE` (70, unsupported platform). +- **Terminators** — `exit_ok()` / `exit_usage()` end the process with the + matching code; both route through the canonical `process.exit(code: u8)`. +- **`--json` mode** — the reserved global `--json` flag surfaces as + `parsed.json` (true iff `--json` is in the argv). Convention: in json mode + stdout carries only the machine result; human diagnostics go to stderr. + ## Cross-Compilation ```sh