diff --git a/Makefile b/Makefile index dfe799b4..9705ebef 100644 --- a/Makefile +++ b/Makefile @@ -234,7 +234,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_w6c_ww $(BIN)/test_ww_ww $(BIN)/test_self_rebuild \ $(BIN)/test_dyn_ww $(BIN)/test_selfcheck $(BIN)/test_at_test_ww \ $(BIN)/test_fmt_run $(BIN)/test_log_run $(BIN)/test_fnmatch_run \ - $(BIN)/test_shlex_run $(BIN)/test_getenv_run \ + $(BIN)/test_shlex_run $(BIN)/test_getenv_run $(BIN)/test_dirs_run \ $(BIN)/test_memio_run $(BIN)/test_temp_run $(BIN)/test_getopt_run \ $(BIN)/test_base32_run $(BIN)/test_base64_run \ $(BIN)/test_adler32_run $(BIN)/test_crc16_run \ @@ -452,6 +452,10 @@ $(BIN)/test_getenv_run: test/wcc/974_getenv_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +$(BIN)/test_dirs_run: test/wcc/975_dirs_run.c $(BIN)/ww $(BIN)/w6c \ + $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) + $(CC) $(CFLAGS) -o $@ $< + $(BIN)/test_memio_run: test/wcc/980_memio_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< diff --git a/lib/dirs/dirs.ww b/lib/dirs/dirs.ww new file mode 100644 index 00000000..47f1d43d --- /dev/null +++ b/lib/dirs/dirs.ww @@ -0,0 +1,187 @@ +// dirs — XDG base directory paths. Port of Hare's lib/dirs +// (ref/hare/dirs/xdg.ha) using the lib/temp-style static `[256]u8` +// pathbuf in place of Hare's `path::buffer` (lib/path doesn't ship +// a buffer type yet). +// +// Surface today (1:1 with Hare's xdg.ha minus runtime()): +// +// dirs.config(prog: str) str — XDG_CONFIG_HOME/, fallback $HOME/.config/ +// dirs.cache(prog: str) str — XDG_CACHE_HOME/, fallback $HOME/.cache/ +// dirs.data(prog: str) str — XDG_DATA_HOME/, fallback $HOME/.local/share/ +// dirs.state(prog: str) str — XDG_STATE_HOME/, fallback $HOME/.local/state/ +// +// Returns are static-buffer views — borrowed for the lifetime of +// the next dirs call (any of the four above). Callers needing the +// bytes to outlive the next call duplicate via [[strings.dup]]. +// Same precedent as Hare's dirs:: (which doc-strings the same +// "overwritten on subsequent calls" contract). +// +// Lookup algorithm (mirrors Hare's xdg.ha:lookup): +// +// 1. If $XDG__HOME is set AND its first byte is '/' +// (Hare's path::abs check), return "/" after +// mkdir-recursive (mode 0o755). +// 2. Otherwise return "$HOME//" after mkdir- +// recursive. The non-absolute and unset/empty XDG cases both +// fall through to this branch — matches Hare's `yield` +// after the `path::abs` test. +// +// rt_aborts in two cases (matches Hare's `as str` cast on missing +// HOME, plus its `fs::strerror` fatal on mkdir failure): +// +// - HOME is not set +// - mkdirs(path, 0o755) fails for a non-EEXIST reason +// +// Skipped from v1 (with reasons preserved for the next graduator): +// +// - runtime() — needs lib/os.stat + a getuid primitive for the +// uid/perm/isdir verification Hare's runtime() does. A relaxed +// env-only version would defeat the perm contract that's the +// entire point of XDG_RUNTIME_DIR. Defer (drew/rob aligned). +// +// - XDG_CONFIG_DIRS / XDG_DATA_DIRS — system search paths. Hare's +// xdg.ha doesn't ship them; lib/CLAUDE.md prohibits richer- +// than-Hare surfaces. Defer until upstream Hare adds them. +// +// - lib/fmt's fatalf-style error reporting on mkdir failure. Hare +// uses `fmt::fatalf("Error creating {}: {}", path, ...)`; ww's +// lib/fmt is print-string-only (no {n}-placeholder parser), so +// we hand the bare context "dirs: mkdirs failed" to rt_abort. +// Graduates when the {n}-placeholder parser lands. + +use os; + +@symbol("rt_abort") fn rtabort(msg: str) void; + +// pathbuf — module-level scratch path. Sized for any +// "/.local/share/" composition under reasonable +// HOME and prog lengths; dirs returns a view into pathbuf[0..pathlen]. +// NUL byte at pathbuf[pathlen] for direct handoff to [[os.mkdirs]] +// (same precedent as lib/temp). +let pathbuf: [256]u8; +let pathlen: i32 = 0; + +// SEP — '/' byte. Same constant as lib/path's SEP. +def SEP: u8 = 47u8; + +// MODE_0755 — directory creation mode passed to [[os.mkdirs]]. +// Hare uses the literal `0o755` at every call site; ww doesn't ship +// octal literals, so we name the constant once. +def MODE_0755: i32 = 493i32; + +// puts — append `s` to pathbuf at offset `off`, capping against the +// buffer's capacity to leave room for the trailing NUL. Returns the +// new offset. Same shape as lib/temp.puts. +fn puts(off: i32, s: str) i32 = { + let i: i32 = 0; + for (i < s.len) { + if (off + i >= 255) { break; }; + pathbuf[off + i] = s[i]; + i += 1; + }; + return off + i; +}; + +// build — assemble "//" into pathbuf, NUL-terminate, +// and store the length in [[pathlen]]. If `sub` is empty, the +// "/" segment is skipped and the result is "/". +// Embedded '/' in `sub` (e.g. ".local/share") is fine — [[os.mkdirs]] +// handles intermediate dirs. +fn build(base: str, sub: str, prog: str) void = { + let off: i32 = 0; + off = puts(off, base); + if (sub.len > 0) { + if (off < 255) { pathbuf[off] = SEP; off += 1; }; + off = puts(off, sub); + }; + if (off < 255) { pathbuf[off] = SEP; off += 1; }; + off = puts(off, prog); + if (off > 255) { off = 255; }; + pathbuf[off] = 0u8; + pathlen = off; +}; + +// view — return a `str` view into pathbuf[0..pathlen]. Borrowed +// for the lifetime of the next dirs call. +fn view() str = { + let r: str; + r.ptr = &pathbuf[0]; + r.len = pathlen; + return r; +}; + +// ensure — wrap [[os.mkdirs]] with the rt_abort-on-non-EEXIST +// behaviour Hare's lookup uses (`fmt::fatalf` on the HOME branch). +// Centralised so both branches in [[lookup]] share the error path. +fn ensure() void = { + match (os.mkdirs(&pathbuf[0], MODE_0755)) { + case void => {}; + case let _e: os.oserror => rtabort("dirs: mkdirs failed"); + }; +}; + +// lookup — Hare's lookup() inlined: probe $envvar, fall through to +// $HOME/ on unset / empty / non-absolute XDG values. Auto- +// mkdirs the result. rt_aborts if HOME is unset (no fallback at +// the bottom of the chain — matches Hare's `as str` cast which +// would also fault on missing HOME). +fn lookup(prog: str, envvar: str, dflt: str) str = { + match (os.getenv(envvar)) { + case let xdg: str => { + if (xdg.len > 0) { + if (xdg[0] == SEP) { // Hare's path::abs(path) + build(xdg, "", prog); + ensure(); + return view(); + }; + }; + }; + case void => {}; + }; + let home: str; + match (os.getenv("HOME")) { + case let h: str => { home = h; }; + case void => rtabort("dirs: HOME is not set"); + }; + build(home, dflt, prog); + ensure(); + return view(); +}; + +// config — directory suitable for storing config files for `prog`. +// $XDG_CONFIG_HOME/ if set+absolute, else $HOME/.config/. +// Auto-created with mode 0o755. Returns a borrowed str view into +// the module-level pathbuf; subsequent dirs calls overwrite it. +// +// Mirrors Hare's dirs::config (xdg.ha:47). +export fn config(prog: str) str = { + return lookup(prog, "XDG_CONFIG_HOME", ".config"); +}; + +// cache — directory suitable for cache files for `prog`. +// $XDG_CACHE_HOME/ if set+absolute, else $HOME/.cache/. +// +// Mirrors Hare's dirs::cache (xdg.ha:53). +export fn cache(prog: str) str = { + return lookup(prog, "XDG_CACHE_HOME", ".cache"); +}; + +// data — directory suitable for persistent data files for `prog`. +// $XDG_DATA_HOME/ if set+absolute, else $HOME/.local/share/. +// Hare composes the default via path::set(.local, share); we emit +// the composed string directly since lib/path doesn't ship a +// path::buffer type yet. +// +// Mirrors Hare's dirs::data (xdg.ha:59). +export fn data(prog: str) str = { + return lookup(prog, "XDG_DATA_HOME", ".local/share"); +}; + +// state — directory suitable for storing program state files for +// `prog`. $XDG_STATE_HOME/ if set+absolute, else +// $HOME/.local/state/. +// +// Mirrors Hare's dirs::state (xdg.ha:69). +export fn state(prog: str) str = { + return lookup(prog, "XDG_STATE_HOME", ".local/state"); +}; diff --git a/lib/dirs/dirstest.ww b/lib/dirs/dirstest.ww new file mode 100644 index 00000000..f82b360c --- /dev/null +++ b/lib/dirs/dirstest.ww @@ -0,0 +1,140 @@ +// dirstest — exercises lib/dirs against an env state pre-arranged by +// the C driver (test/wcc/975_dirs_run.c). +// +// Pre-arranged env (set via setenv/unsetenv before exec'ing +// `ww run lib/dirs/dirstest.ww`; see 975_dirs_run.c): +// +// HOME = (mkdtemp'd /tmp/wwdirs-XXXXXX) +// XDG_CONFIG_HOME = /cfg (absolute → XDG branch) +// XDG_CACHE_HOME = "relative/path" (non-abs → fallback to HOME) +// XDG_DATA_HOME unset (fallback to HOME) +// XDG_STATE_HOME unset (fallback to HOME) +// WW_TEST_TMPDIR = (so this fixture can compute +// the expected paths) +// +// Cohorts (one @test fn per row, in numeric order from main): +// +// 1. config("myapp") — XDG-set+absolute → /cfg/myapp +// 2. cache("myapp") — XDG-set+relative → /.cache/myapp (abs-check fallback) +// 3. data("myapp") — XDG-unset → /.local/share/myapp (HOME fallback) +// 4. state("myapp") — XDG-unset → /.local/state/myapp (HOME fallback) +// +// Static-buffer gotcha: dirs.config / dirs.cache / dirs.data / +// dirs.state all return views into the same module-level pathbuf. +// Each @test calls EXACTLY ONE dirs.* function, builds its expected +// string into a separate `scratch: [256]u8` BEFORE that call, and +// asserts immediately. Don't call two dirs.* fns and compare across +// — the second clobbers the first's view. +// +// `signalled` global bumps before each test so a failing exit code +// pinpoints the offending row (WEXITSTATUS = signalled + 10). Same +// shape as temptest / shlextest / fnmatchtest / ostest. +// +// Don't `use io;` here — lib/os and lib/io collide on read/write/ +// close (task #17). We need only os.getenv + os.exit. + +use dirs; +use os; + +let signalled: i32 = 0; + +fn fail() void = { os.exit(signalled + 10); }; + +fn streq(a: str, b: str) bool = { + if (a.len != b.len) { return false; }; + let i: i32 = 0; + for (i < a.len) { + if (a[i] != b[i]) { return false; }; + i += 1; + }; + return true; +}; + +// scratch — local buffer for expected-path construction. Independent +// of dirs.pathbuf; both views stay valid through the streq compare +// because they live in different buffers. +let scratch: [256]u8; + +fn putscratch(off: i32, s: str) i32 = { + let i: i32 = 0; + for (i < s.len) { + if (off + i >= 255) { break; }; + scratch[off + i] = s[i]; + i += 1; + }; + return off + i; +}; + +// expected — build "//" into scratch and return a +// view. Mirrors dirs.build but writes into our scratch buffer so +// the expected and got views can be compared without aliasing. +fn expected(tmpl: str, sub: str, prog: str) str = { + let off: i32 = 0; + off = putscratch(off, tmpl); + if (off < 255) { scratch[off] = 47u8; off += 1; }; // '/' + off = putscratch(off, sub); + if (off < 255) { scratch[off] = 47u8; off += 1; }; + off = putscratch(off, prog); + let r: str; + r.ptr = &scratch[0]; + r.len = off; + return r; +}; + +fn gettmpdir() str = { + match (os.getenv("WW_TEST_TMPDIR")) { + case let s: str => return s; + case void => { + fail(); + let empty: str; + return empty; + }; + }; +}; + +// ---- 1: XDG_CONFIG_HOME=/cfg → /cfg/myapp -------------- + +@test fn test_config_xdg_set() void = { + let tmpl = gettmpdir(); + let exp = expected(tmpl, "cfg", "myapp"); + let got = dirs.config("myapp"); + if (!streq(got, exp)) { fail(); }; +}; + +// ---- 2: XDG_CACHE_HOME="relative/path" → fallback to $HOME/.cache -- +// +// Exercises Hare's `if (!path::abs(path)) yield;` branch — a +// non-absolute XDG_*_HOME silently falls through to the HOME path. + +@test fn test_cache_xdg_relative_fallback() void = { + let tmpl = gettmpdir(); + let exp = expected(tmpl, ".cache", "myapp"); + let got = dirs.cache("myapp"); + if (!streq(got, exp)) { fail(); }; +}; + +// ---- 3: XDG_DATA_HOME unset → fallback to $HOME/.local/share ------- + +@test fn test_data_unset_fallback() void = { + let tmpl = gettmpdir(); + let exp = expected(tmpl, ".local/share", "myapp"); + let got = dirs.data("myapp"); + if (!streq(got, exp)) { fail(); }; +}; + +// ---- 4: XDG_STATE_HOME unset → fallback to $HOME/.local/state ------ + +@test fn test_state_unset_fallback() void = { + let tmpl = gettmpdir(); + let exp = expected(tmpl, ".local/state", "myapp"); + let got = dirs.state("myapp"); + if (!streq(got, exp)) { fail(); }; +}; + +export fn main() i32 = { + signalled = 1; test_config_xdg_set(); + signalled = 2; test_cache_xdg_relative_fallback(); + signalled = 3; test_data_unset_fallback(); + signalled = 4; test_state_unset_fallback(); + return 0; +}; diff --git a/lib/os/os.ww b/lib/os/os.ww index 5836b819..49bc9253 100644 --- a/lib/os/os.ww +++ b/lib/os/os.ww @@ -193,6 +193,49 @@ export fn rmdir(path: *u8) i32 = { return syscall1(nr.RMDIR, path: i64): i32; }; +// mkdirs — recursive mkdir. Creates `path` and any non-existent +// parent directories with the given mode. EEXIST is silently +// accepted (matches Hare's `errors::exists` skip in os::mkdirs); +// any other syscall failure surfaces as `oserror`. +// +// `path` must be NUL-terminated AND its bytes must be writable — +// mkdirs temporarily replaces '/' separators with NUL while +// invoking [[mkdir]] on each prefix, then restores them. Pointing +// `path` at a string literal will segfault. Callers hold the bytes +// in a writable buffer (rt_alloc'd, a static `[N]u8`, etc.) — same +// precedent as [[temp.named]]'s pathbuf. +// +// Mirrors Hare's os::mkdirs (recursive variant of os::mkdir). +export fn mkdirs(path: *u8, mode: i32) (void | oserror) = { + // Find the path length (excluding trailing NUL). + let n: i32 = 0; + for (path[n] != 0u8) { n += 1; }; + if (n == 0) { return; }; + + // Walk forward; at each '/' boundary, NUL-terminate the prefix, + // mkdir it, restore the slash, continue. Skip index 0 so a + // leading '/' on absolute paths doesn't trigger an empty mkdir. + let i: i32 = 1; + for (i < n) { + if (path[i] == 47u8) { // '/' + path[i] = 0u8; + let r: i32 = mkdir(path, mode); + path[i] = 47u8; + if (r < 0) { + if (r != -17) { return r: i64: oserror; }; + }; + }; + i += 1; + }; + + // mkdir the full path. + let r: i32 = mkdir(path, mode); + if (r < 0) { + if (r != -17) { return r: i64: oserror; }; + }; + return; +}; + // getpid(2). Used by the driver to mint unique scratch paths. export fn getpid() i32 = { return syscall0(nr.GETPID): i32; diff --git a/test/wcc/975_dirs_run.c b/test/wcc/975_dirs_run.c new file mode 100644 index 00000000..9bc85192 --- /dev/null +++ b/test/wcc/975_dirs_run.c @@ -0,0 +1,84 @@ +/* + * 975_dirs_run — execute the lib/dirs @test fixture under the C-side + * `ww run` driver and assert exit 0. + * + * Workflow: + * 1. mkdtemp /tmp/wwdirs-XXXXXX — fresh per run, no cross-run + * stale state, no parallel-test conflicts. + * 2. setenv the four cohort env states (see lib/dirs/dirstest.ww + * file header for the contract). + * 3. exec `ww run lib/dirs/dirstest.ww`. The fixture reads + * WW_TEST_TMPDIR + the XDG_/HOME envs and asserts. + * 4. system("rm -rf ") to reclaim the tmpdir + the + * auto-created child dirs (dirs.* mkdir-recursive on the way + * down, so the directory tree is non-empty by test exit). + * + * Same wrapper shape as 970-974; cleanup-on-failure is rm-rf'd so a + * test failure never leaves residue in /tmp. + */ +#include +#include +#include +#include +#include + +static int +runwait(const char *cmd) +{ + int rc = system(cmd); + if (rc == -1) return -1; + if (WIFEXITED(rc)) return WEXITSTATUS(rc); + return 1; +} + +int +main(void) +{ + const char *bin = getenv("BIN"); + if (!bin) bin = "out/bin"; + char absbin[1024]; + if (bin[0] != '/') { + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return 1; + snprintf(absbin, sizeof absbin, "%s/%s", cwd, bin); + bin = absbin; + } + char cwd[1024]; + if (getcwd(cwd, sizeof cwd) == NULL) return 1; + + /* mkdtemp the per-run scratch root. */ + char tmpl[64]; + strcpy(tmpl, "/tmp/wwdirs-XXXXXX"); + if (mkdtemp(tmpl) == NULL) { + perror("mkdtemp"); + return 1; + } + + /* Pre-arrange env. The child `ww run` inherits these. */ + char cfg[128]; + snprintf(cfg, sizeof cfg, "%s/cfg", tmpl); + setenv("HOME", tmpl, 1); + setenv("XDG_CONFIG_HOME", cfg, 1); + setenv("XDG_CACHE_HOME", "relative/path", 1); /* non-abs → fallback */ + unsetenv("XDG_DATA_HOME"); + unsetenv("XDG_STATE_HOME"); + setenv("WW_TEST_TMPDIR", tmpl, 1); + + const char *src = "lib/dirs/dirstest.ww"; + char path[1024], cmd[2048]; + snprintf(path, sizeof path, "%s/%s", cwd, src); + snprintf(cmd, sizeof cmd, "%s/ww run %s", bin, path); + int rc = runwait(cmd); + + /* Cleanup regardless of pass/fail — no residue under /tmp. */ + char rmcmd[256]; + snprintf(rmcmd, sizeof rmcmd, "rm -rf %s", tmpl); + (void)system(rmcmd); + + if (rc != 0) { + fprintf(stderr, "dirs_run FAIL: %s exited %d\n", src, rc); + return 1; + } + printf("dirs_run: %s ok\n", src); + return 0; +}