Every // ---- section banner dies (132 -> 0): names carry the WHAT. Narration deleted (filename restatements, run-with lines, what-the- next-line-does); every ref/hare cite, task cite, divergence, ABI/ layout contract, and ownership qualifier kept (borrowed-view lines restored where the sweep over-cut). Comment-only proven: all 442 walk-workdir .s and 32 import-probe .s byte-identical before/after; libbyteid 56-roster all-ID.
400 lines
12 KiB
Plaintext
400 lines
12 KiB
Plaintext
// shlex — POSIX shell tokenizer + quoter. Port of Hare's lib/shlex
|
|
// (ref/hare/shlex/{split.ha,escape.ha}) using ww byte-indexed cursors
|
|
// in place of Hare's strings::iterator.
|
|
//
|
|
// Owning model: split() returns a fresh `[]str` of strings.dup'd
|
|
// elements; release with [[strings.freeall]] (Hare's natural disposer
|
|
// shape). quotestr() returns an os.alloc'd str; release via
|
|
// `os.free(r.ptr, r.len: u64)` exactly like [[strings.dup]].
|
|
//
|
|
// Quoting rules (POSIX shell, byte-wise):
|
|
//
|
|
// - whitespace separators ' ' / '\t' / '\n' (collapse runs)
|
|
// - '\\<c>' outside quotes: literal <c>; '\\<newline>' deleted;
|
|
// trailing bare '\\' → syntaxerr
|
|
// - '"..."': '\\<c>' processed inside (any <c>); unterminated → syntaxerr
|
|
// - "'...'": literal until closing "'"; no escapes inside;
|
|
// unterminated → syntaxerr
|
|
// - "Empty ''" yields a literal empty token (preserves quoting
|
|
// intent — distinguishes `cmd ''` from `cmd`)
|
|
//
|
|
// Divergences from Hare:
|
|
//
|
|
// - Drop nomem: ww os.alloc has no recoverable failure path (OOM
|
|
// yields a poisonous pointer that faults on deref; see lib/os.ww
|
|
// comment). Same precedent as strings.dup.
|
|
// Hare's (...|syntaxerr|nomem) collapses to (...|syntaxerr).
|
|
//
|
|
// - Byte-wise iteration via i32 cursor instead of Hare's
|
|
// strings::iterator (no UTF-8 rune iteration in the language
|
|
// stack yet; same precedent as fnmatch). scan_double / scan_single
|
|
// / scan_backslash work on bytes; an escaped multibyte sequence
|
|
// round-trips as the byte sequence it appears in the input.
|
|
//
|
|
// - memio glue uses the value-return shape (#94 fold-eFinal):
|
|
// let st: memio.stream = memio.dynamic();
|
|
// ... io.write(&st.vt, ...) ...
|
|
// let view: str = memio.string(&st); // borrowed
|
|
// mirroring Hare's `let s = memio::dynamic()` return-by-value.
|
|
// `&st.vt` is the [[io.stream]] (= `*io.vtable`) the dispatchers
|
|
// take.
|
|
//
|
|
// - quote() takes `io.stream` (not Hare's `io::handle`) — that's
|
|
// the ww-divergent shape lib/io ships until io fold-2 (#5).
|
|
//
|
|
// - quote() emits raw bytes directly into the sink; without
|
|
// [[memio.appendrune]] / [[memio.concat]] in lib/memio yet, the
|
|
// "write one byte" path goes through `io.write(s, buf[0:1])`
|
|
// against a stack `[N]u8`. Same shape as fmt's rune-arm.
|
|
//
|
|
// - Internal [[dupstr]] inlined rather than `use strings;`: keeps
|
|
// the dep surface small for a 6-line helper. shlex doesn't reach
|
|
// for any other strings:: routine, and callers free split()'s
|
|
// result via [[strings.freeall]] directly.
|
|
//
|
|
// Caller layout — split:
|
|
//
|
|
// match (shlex.split("ls -l '/etc/passwd'")) {
|
|
// case let toks: []str => {
|
|
// let i: i32 = 0;
|
|
// for (i < toks.len) { /* toks[i] */ i += 1; };
|
|
// strings.freeall(toks);
|
|
// };
|
|
// case shlex.syntaxerr => { /* bad input */ };
|
|
// };
|
|
//
|
|
// Caller layout — quote into a fixed buffer:
|
|
//
|
|
// let buf: [128]u8;
|
|
// let st: memio.stream = memio.fixed(buf[0:128]);
|
|
// shlex.quote(&st.vt, "hello world"); // writes 'hello world'
|
|
// let view: str = memio.string(&st);
|
|
|
|
package shlex;
|
|
|
|
import io;
|
|
import memio;
|
|
import os;
|
|
import strings;
|
|
|
|
// syntaxerr — the input wasn't a valid shell-tokenizable string
|
|
// (unterminated quote / bare trailing backslash). Mirrors Hare's
|
|
// shlex::syntaxerr; a `!void` so it can ride a tagged-union return
|
|
// alongside `[]str`.
|
|
export type syntaxerr = !void;
|
|
|
|
// strerror — human-friendly rendering of [[syntaxerr]]. Mirrors
|
|
// Hare's shlex::strerror(syntaxerr) str.
|
|
export fn strerror(err: syntaxerr) str = {
|
|
return "Invalid shell syntax";
|
|
};
|
|
|
|
// dupstr — local strings.dup. Inlined (same algorithm and same
|
|
// {nil, 0} handling for empty input).
|
|
fn dupstr(s: str) str = {
|
|
let r: str;
|
|
r.ptr = nil;
|
|
r.len = 0;
|
|
if (s.len == 0) { return r; };
|
|
let buf: []u8 = alloc([], s.len: u64)!;
|
|
let i: i32 = 0;
|
|
for (i < s.len) { buf[i] = s[i]; i += 1; };
|
|
buf.len = s.len;
|
|
return strings.frombytes(buf);
|
|
};
|
|
|
|
// freepartial — drop a partially built [[split]] result on the
|
|
// syntaxerr path. Hare uses a `defer if (!ok)` guard; ww has no defer,
|
|
// so we hand-roll the cleanup at every error return. Mirror of
|
|
// [[strings.freeall]] (skip empty {nil, 0} elements; skip the header
|
|
// free if cap == 0).
|
|
fn freepartial(slice: []str) void = {
|
|
let i: i32 = 0;
|
|
for (i < slice.len) {
|
|
if (slice[i].len > 0) {
|
|
os.free(slice[i].ptr: *void, slice[i].len: u64);
|
|
};
|
|
i += 1;
|
|
};
|
|
if (slice.cap > 0) {
|
|
os.free(slice.ptr: *void, (slice.cap: u64) * size(str): u64);
|
|
};
|
|
};
|
|
|
|
// writebyte — send one byte to `s` via [[io.write]]. The `[1]u8` slice
|
|
// dance mirrors fmt's rune-arm (lib/fmt/fmt.ww:210-218); memio doesn't
|
|
// ship an appendbyte/appendrune in the ww port yet, so this is the
|
|
// uniform write-one-byte primitive across the module.
|
|
//
|
|
// The dynamic memio sink never errors; the [[split]] + [[quotestr]]
|
|
// callers ignore the error arm because their stream is always
|
|
// memio.dynamic. [[quote]] (caller-supplied sink) propagates it through
|
|
// its `(size | io.error)` return.
|
|
fn writebyte(s: io.stream, b: u8) (size | io.error) = {
|
|
let buf: [1]u8;
|
|
buf[0] = b;
|
|
return io.write(s, buf[0:1]);
|
|
};
|
|
|
|
// scan_backslash — consume one `\<c>` escape at `*pos`. POSIX:
|
|
// "<backslash> followed by <newline> shall be removed" — we eat the
|
|
// newline silently. A trailing bare backslash (`*pos == in.len`) is
|
|
// `syntaxerr`. Otherwise the next byte is appended verbatim. Mirrors
|
|
// Hare's scan_backslash (split.ha:86).
|
|
fn scan_backslash(out: io.stream, in: str, pos: *i32) (void | syntaxerr) = {
|
|
if (*pos >= in.len) { let e: syntaxerr; return e; };
|
|
let r: u8 = in[*pos];
|
|
*pos += 1;
|
|
if (r == '\n') { return; }; // deleted per POSIX
|
|
let _w = writebyte(out, r);
|
|
return;
|
|
};
|
|
|
|
// scan_double — consume a `"..."` group at `*pos`. Closes on '"';
|
|
// '\<c>' inside processed by [[scan_backslash]]; unterminated → err.
|
|
// Mirrors Hare's scan_double (split.ha:110).
|
|
fn scan_double(out: io.stream, in: str, pos: *i32) (void | syntaxerr) = {
|
|
let loop: bool = true;
|
|
for (loop) {
|
|
if (*pos >= in.len) { let e: syntaxerr; return e; };
|
|
let r: u8 = in[*pos];
|
|
*pos += 1;
|
|
if (r == '"') { loop = false; }
|
|
else { if (r == '\\') {
|
|
let er = scan_backslash(out, in, pos);
|
|
match (er) {
|
|
case let e: syntaxerr => return e;
|
|
case void => {};
|
|
};
|
|
} else {
|
|
let _w = writebyte(out, r);
|
|
}; };
|
|
};
|
|
return;
|
|
};
|
|
|
|
// scan_single — consume a `'...'` group at `*pos`. Closes on "'";
|
|
// no escapes inside (POSIX: single-quoted text is fully literal);
|
|
// unterminated → err. Mirrors Hare's scan_single (split.ha:135).
|
|
fn scan_single(out: io.stream, in: str, pos: *i32) (void | syntaxerr) = {
|
|
let loop: bool = true;
|
|
for (loop) {
|
|
if (*pos >= in.len) { let e: syntaxerr; return e; };
|
|
let r: u8 = in[*pos];
|
|
*pos += 1;
|
|
if (r == '\'') { loop = false; }
|
|
else { let _w = writebyte(out, r); };
|
|
};
|
|
return;
|
|
};
|
|
|
|
// split — tokenize `in` according to POSIX shell quoting rules.
|
|
// Returns a fresh `[]str` of [[dupstr]]'d elements; on success, free
|
|
// with [[strings.freeall]]. Empty input returns an empty slice (not
|
|
// an error). Mirrors Hare's split (split.ha:16).
|
|
//
|
|
// Algorithm: a single forward pass over the input, accumulating bytes
|
|
// into a memio.dynamic sink. Whitespace runs flush the buffer as one
|
|
// token (skipping the flush before the first token, so leading
|
|
// whitespace doesn't push an empty entry). The `dirty` flag tracks
|
|
// whether the current iteration began processing input — it's the
|
|
// signal for "an empty quote group was the only content of this
|
|
// token", which still pushes a literal "" (e.g. `cmd ''`).
|
|
export fn split(in: str) ([]str | syntaxerr) = {
|
|
let st: memio.stream = memio.dynamic();
|
|
let snk: io.stream = &st.vt;
|
|
|
|
let slice: []str;
|
|
slice.ptr = nil: *str;
|
|
slice.len = 0;
|
|
slice.cap = 0;
|
|
|
|
let first: bool = true;
|
|
let dirty: bool = false;
|
|
let pos: i32 = 0;
|
|
|
|
for (pos < in.len) {
|
|
let r: u8 = in[pos];
|
|
pos += 1;
|
|
dirty = true;
|
|
|
|
if (r == ' ' || r == '\t' || r == '\n') {
|
|
let inner: bool = true;
|
|
for (inner) {
|
|
if (pos >= in.len) { inner = false; }
|
|
else {
|
|
let r2: u8 = in[pos];
|
|
if (r2 != ' ' && r2 != '\t' && r2 != '\n') {
|
|
inner = false;
|
|
} else {
|
|
pos += 1;
|
|
};
|
|
};
|
|
};
|
|
if (!first) {
|
|
let view: str = memio.string(&st);
|
|
let owned: str = dupstr(view);
|
|
append(slice, owned);
|
|
memio.reset(&st);
|
|
};
|
|
dirty = false;
|
|
} else { if (r == '\\') {
|
|
let er = scan_backslash(snk, in, &pos);
|
|
match (er) {
|
|
case let e: syntaxerr => {
|
|
let _c = io.close(snk);
|
|
freepartial(slice);
|
|
return e;
|
|
};
|
|
case void => {};
|
|
};
|
|
} else { if (r == '"') {
|
|
let er = scan_double(snk, in, &pos);
|
|
match (er) {
|
|
case let e: syntaxerr => {
|
|
let _c = io.close(snk);
|
|
freepartial(slice);
|
|
return e;
|
|
};
|
|
case void => {};
|
|
};
|
|
} else { if (r == '\'') {
|
|
let er = scan_single(snk, in, &pos);
|
|
match (er) {
|
|
case let e: syntaxerr => {
|
|
let _c = io.close(snk);
|
|
freepartial(slice);
|
|
return e;
|
|
};
|
|
case void => {};
|
|
};
|
|
} else {
|
|
let _w = writebyte(snk, r);
|
|
}; }; }; };
|
|
|
|
if (first) { first = false; };
|
|
};
|
|
|
|
if (dirty) {
|
|
let view: str = memio.string(&st);
|
|
let owned: str = dupstr(view);
|
|
append(slice, owned);
|
|
};
|
|
|
|
let _c = io.close(snk);
|
|
return slice;
|
|
};
|
|
|
|
// issafe — true if `s` is composed entirely of bytes that pass through
|
|
// shell tokenisation unmodified (alnum + `@%+=:,./-`). Used by
|
|
// [[quote]] to skip emitting quotes around boring strings. Mirrors
|
|
// Hare's is_safe (escape.ha:9), narrowed to ASCII byte tests because
|
|
// ww has no rune iteration.
|
|
fn issafe(s: str) bool = {
|
|
let i: i32 = 0;
|
|
for (i < s.len) {
|
|
let c: u8 = s[i];
|
|
// Hare's switch list: '@', '%', '+', '=', ':', ',', '.', '/', '-'.
|
|
if (c == '@' || c == '%' || c == '+' || c == '='
|
|
|| c == ':' || c == ',' || c == '.' || c == '/'
|
|
|| c == '-') {
|
|
i += 1;
|
|
} else {
|
|
let alnum: bool = false;
|
|
if (c >= '0' && c <= '9') { alnum = true; };
|
|
if (c >= 'A' && c <= 'Z') { alnum = true; };
|
|
if (c >= 'a' && c <= 'z') { alnum = true; };
|
|
if (!alnum) { return false; };
|
|
i += 1;
|
|
};
|
|
};
|
|
return true;
|
|
};
|
|
|
|
// quote — emit `s` shell-quoted to `sink`. Returns total bytes written,
|
|
// or `io.error` if the sink rejects mid-write. Mirrors Hare's
|
|
// shlex::quote (escape.ha:25).
|
|
//
|
|
// Strategy:
|
|
// - empty `s` → `''`
|
|
// - is_safe(s) → emit raw, no quoting
|
|
// - otherwise → wrap in single quotes; an embedded `'`
|
|
// becomes `'"'"'` (close, double-quoted
|
|
// literal `'`, reopen).
|
|
export fn quote(sink: io.stream, s: str) (size | io.error) = {
|
|
if (s.len == 0) {
|
|
let buf: [2]u8;
|
|
buf[0] = '\''; buf[1] = '\'';
|
|
let r = io.write(sink, buf[0:2]);
|
|
match (r) {
|
|
case let n: size => return n;
|
|
case let e: io.error => return e;
|
|
};
|
|
};
|
|
if (issafe(s)) {
|
|
let raw: []u8;
|
|
raw.ptr = s.ptr;
|
|
raw.len = s.len;
|
|
let r = io.write(sink, raw);
|
|
match (r) {
|
|
case let n: size => return n;
|
|
case let e: io.error => return e;
|
|
};
|
|
};
|
|
|
|
let total: size = 0;
|
|
let r1 = writebyte(sink, '\'');
|
|
match (r1) {
|
|
case let n: size => { total += n; };
|
|
case let e: io.error => return e;
|
|
};
|
|
let i: i32 = 0;
|
|
for (i < s.len) {
|
|
let c: u8 = s[i];
|
|
if (c == '\'') { // "'" → '"'"'
|
|
let pat: [5]u8;
|
|
pat[0] = '\''; pat[1] = '"'; pat[2] = '\'';
|
|
pat[3] = '"'; pat[4] = '\'';
|
|
let rr = io.write(sink, pat[0:5]);
|
|
match (rr) {
|
|
case let n: size => { total += n; };
|
|
case let e2: io.error => return e2;
|
|
};
|
|
} else {
|
|
let rr = writebyte(sink, c);
|
|
match (rr) {
|
|
case let n: size => { total += n; };
|
|
case let e2: io.error => return e2;
|
|
};
|
|
};
|
|
i += 1;
|
|
};
|
|
let r2 = writebyte(sink, '\'');
|
|
match (r2) {
|
|
case let n: size => { total += n; };
|
|
case let e: io.error => return e;
|
|
};
|
|
return total;
|
|
};
|
|
|
|
// quotestr — convenience: [[quote]] into a fresh memio.dynamic sink
|
|
// and return the dup'd result as an owned `str`. Caller releases via
|
|
// `os.free(r.ptr, r.len: u64)`. Mirrors Hare's shlex::quotestr
|
|
// (escape.ha:50).
|
|
//
|
|
// memio.dynamic's writes never error, so the `match` on `quote(...)`
|
|
// discards the error arm — it can't reach this caller's sink. quote()'s
|
|
// `(size | io.error)` shape is preserved for the caller-supplied-sink
|
|
// path.
|
|
export fn quotestr(s: str) str = {
|
|
let st: memio.stream = memio.dynamic();
|
|
let snk: io.stream = &st.vt;
|
|
|
|
let _r = quote(snk, s);
|
|
let view: str = memio.string(&st);
|
|
let owned: str = dupstr(view);
|
|
|
|
let _c = io.close(snk);
|
|
return owned;
|
|
};
|