From e7f173cde0363b2d9fe4b938430501f7019c03f6 Mon Sep 17 00:00:00 2001 From: Hojun-Cho Date: Fri, 15 May 2026 14:05:55 +0900 Subject: [PATCH] lib+test: add log over io.stream sink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hare-shaped lib/log v1: logger vtable carrying one println slot, stdlogger forwarding to a *io.stream, plus *logger globals (silent / default / global), setlogger, lprintln / println, lfatal / fatal. Default sink is stderr through a private rt_syscall write callback; graduates with #17 (cgen mod-mangle for fn labels), at which point the rt_syscall stub disappears the same way fmt's will. Module-scope struct/pointer-literal init isn't constexpr in cstage emit_lets, so silent/default/global are wired lazily by ensureinit on the first exported-fn entry (lib/temp rnginit pattern). Callers that read the globals directly must call some lib/log fn first. Skipped this round: printfln / lprintfln / fatalf / lfatalf and the matching printfln vtable slot — they need a fmt {n}-placeholder parser that isn't shipped yet. fatal / lfatal's exit(255) arm has no test fixture (needs fork+wait for WEXITSTATUS); left as TODO. 971_log_run wraps the @test fixture under `ww run`, mirroring 970_fmt_run. make test: 54/54; bootstrap fixed-point (990-997) holds. --- Makefile | 6 +- lib/log/log.ww | 240 +++++++++++++++++++++++++++++++++++++++++ lib/log/logtest.ww | 189 ++++++++++++++++++++++++++++++++ test/wcc/971_log_run.c | 52 +++++++++ 4 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 lib/log/log.ww create mode 100644 lib/log/logtest.ww create mode 100644 test/wcc/971_log_run.c diff --git a/Makefile b/Makefile index 3451bbe6..d4047da5 100644 --- a/Makefile +++ b/Makefile @@ -230,7 +230,7 @@ TESTS = $(BIN)/test_smoke $(BIN)/test_lex $(BIN)/test_parse $(BIN)/test_check \ $(BIN)/test_selfhost $(BIN)/test_w6a_ww $(BIN)/test_w6l_ww \ $(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_fmt_run $(BIN)/test_log_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 \ @@ -415,6 +415,10 @@ $(BIN)/test_fmt_run: test/wcc/970_fmt_run.c $(BIN)/ww $(BIN)/w6c \ $(BIN)/w6a $(BIN)/w6l $(LIB)/libwwrt.a | $(BIN) $(CC) $(CFLAGS) -o $@ $< +$(BIN)/test_log_run: test/wcc/971_log_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/log/log.ww b/lib/log/log.ww new file mode 100644 index 00000000..ea1a0f12 --- /dev/null +++ b/lib/log/log.ww @@ -0,0 +1,240 @@ +// log — process logger over a [[io.stream]] sink. Subset of Hare's +// lib/log (ref/hare/log/{logger,funcs,global,silent}.ha). +// +// Surface today: +// +// log.logger — vtable with a single `println` slot +// log.stdlogger — first-field embed of logger + a `*io.stream` sink +// log.new (sl: *stdlogger, sink: *io.stream) void +// log.silent *logger — a logger that discards every record +// log.default *logger — a stdlogger writing to stderr +// log.global *logger — the dispatch target for [[println]] / [[fatal]] +// log.println (args: fmt.formattable...) void +// log.lprintln (log: *logger, args: fmt.formattable...) void +// log.fatal (args: fmt.formattable...) never +// log.lfatal (log: *logger, args: fmt.formattable...) never +// log.setlogger (log: *logger) void +// +// Divergences from Hare: +// +// • Hare returns the stdlogger from `new` by value; ww cgen can't +// return wide structs, so [[new]] is out-parameter shaped. Same +// workaround as memio.fixed / bufio.init. +// +// • Hare initialises silent / default / _default via module-scope +// struct literal lets. cstage `emit_lets` (cmd/w6c/cgen.c:6176) +// only constexprs primitive / str-literal / array-literal lets — +// struct- and pointer-literal init aren't reachable. ww drops to +// a lazy [[ensureinit]] (lib/temp's `rnginit==0` pattern) called +// from every exported fn. The public *logger globals are zero +// initially and become non-nil after the first lib/log call; +// callers that read globals directly (rather than going through +// [[println]] / [[lprintln]] / [[setlogger]] / etc.) must call +// some lib/log fn first so init runs. +// +// • printfln / lprintfln / fatalf / lfatalf are skipped — they +// need a fmt {n}-placeholder parser that isn't shipped yet. The +// logger vtable carries only `println` today; the format-string +// entries graduate with the parser. +// +// Sink today is [[io.stream]] only — lib/io has no fd-backed stream +// yet (Hare's `io::handle = file | int` collapses to one variant). +// The default logger writes to stderr via a private io.stream that +// dispatches through an rt_syscall write callback; mirrors lib/fmt's +// defensive `rawwrite` / `rawexit` shape for the same C-symbol +// collision reason (lib/os and lib/io both export `write`/`read`/ +// `close`, and `use os;` from log would re-trip #17). Both fmt and +// log drop their rt_syscall stubs in one cleanup commit once #17 +// (cgen module-mangling of fn labels) lands. +// +// Compiler note: wwstage cgen's variadic dispatch is name-based +// (`fnparamslookup(callee.str)`), which would conflate the +// `logger.println` vtable field with the top-level [[println]] fn +// if both stages compiled this module. cstage uses type-driven +// dispatch via the call-node's lhs type and handles fn-pointer +// variadic calls correctly. Today lib/log is consumed only by tests +// routed through `ww run` (cstage), so this is a latent issue +// covered by #11 (wwstage checkfile pass). +// +// let buf: [128]u8; +// let mem: memio.state; +// let s: io.stream; +// memio.fixed(&mem, &s, buf[0:128]); +// +// let sl: log.stdlogger; +// log.new(&sl, &s); +// log.lprintln(&sl.logger, "hello", 42i64); +// // mem now holds "hello 42\n" +// +// log.setlogger(&sl.logger); +// log.println("through global"); +// +// log.setlogger(log.silent); +// log.println("dropped"); + +use fmt; +use io; + +// Direct rt_syscall bindings rather than `use os;` — os exports +// read/write/close, which collide with io.read/write/close under the +// driver's flat-scope concat. Mirrors lib/fmt and lib/memio. Removed +// once #17 (cgen mod-mangle for fn labels) lands. +@symbol("rt_syscall") fn rtsyscall3(num: i64, a: i64, b: i64, c: i64) i64; +@symbol("rt_syscall") fn rtsyscall1(num: i64, a: i64) i64; + +// rawwrite — Linux write(2) syscall (nr=1). Private stderr sink uses +// this so log's exported surface stays clear of the os.write symbol. +fn rawwrite(fd: i32, p: *u8, n: u64) i64 = { + return rtsyscall3(1i64, fd: i64, p: i64, n: i64); +}; + +// rawexit — Linux exit(2) syscall (nr=60). Used by [[fatal]] and +// [[lfatal]] for the process-terminating arm. +fn rawexit(code: i32) void = { + rtsyscall1(60i64, code: i64); +}; + +// logger — interface for log dispatch. v1 carries a single vtable +// slot. Hare layers a `printfln` slot for format-string callbacks; +// that comes back when lib/fmt grows a {n}-placeholder parser. +// +// TODO: needs fmt {n}-placeholder parser (future task) — adds a +// `printfln: fn(l: *logger, fmt: str, args: fmt.field...) void` +// slot here. +export type logger = struct { + println: fn(l: *logger, args: fmt.formattable...) void, +}; + +// stdlogger — concrete logger forwarding to a `*io.stream` sink. +// First-field embed: `&sl.logger` yields a `*logger` (same shape as +// bufio.bstream over its embedded io.stream vtable). The vtable +// callback recovers the outer stdlogger by casting the dispatch arg +// back to `*stdlogger`. +export type stdlogger = struct { + logger: logger, + sink: *io.stream, +}; + +// stderrsink — private io.stream wired through the raw stderr fd (2) +// via [[rawwrite]]. Used as [[default]]'s sink. Independent of lib/io's +// future fd-backed stream — when that lands, this collapses to a +// thin wrapper and graduates in one go. +let stderrsink: io.stream; + +// _silent — backing storage for the [[silent]] global. +let _silent: logger; + +// _default — backing storage for the [[default]] global. Its sink +// points at [[stderrsink]] after [[ensureinit]] runs. +let _default: stdlogger; + +// silent / default / global — Hare-style *logger globals. Zero-init +// at link time; lazily wired by [[ensureinit]] on the first call to +// any exported fn. See the file header for the divergence rationale. +export let silent: *logger; +export let default: *logger; +export let global: *logger; + +// initdone — guards [[ensureinit]] so the one-shot wiring runs once. +// Mirrors lib/temp's `rnginit` pattern. +let initdone: i32 = 0; + +fn ensureinit() void = { + if (initdone != 0) { return; }; + stderrsink.ctx = nil; + stderrsink.read = stderrread; + stderrsink.write = stderrwrite; + stderrsink.close = stderrclose; + + _silent.println = silentprintln; + + _default.logger.println = stdprintln; + _default.sink = &stderrsink; + + silent = &_silent; + default = &_default.logger; + global = default; + initdone = 1; +}; + +// stderrread / stderrwrite / stderrclose — io.stream callbacks for +// the private stderr sink. Read is a no-op returning io.eof; close +// is a no-op (the process owns fd 2). Write forwards to write(2) +// and translates a negative-errno into io.closed. +fn stderrread(s: *io.stream, buf: []u8) (i32 | io.eof | io.closed) = { + let e: io.eof; + return e; +}; + +fn stderrwrite(s: *io.stream, buf: []u8) (i32 | io.closed) = { + let r: i64 = rawwrite(2, buf.ptr, buf.len: u64); + if (r < 0) { + let e: io.closed; + return e; + }; + return r: i32; +}; + +fn stderrclose(s: *io.stream) (void | io.closed) = { return; }; + +// stdprintln — vtable callback for stdlogger. Forwards to +// [[fmt.fprintln]] on the sink; the (i32 | io.closed) result is +// dropped — lib/log's surface is `void` (matches Hare). +fn stdprintln(l: *logger, args: fmt.formattable...) void = { + let sl: *stdlogger = l: *stdlogger; + fmt.fprintln(sl.sink, args...); +}; + +// silentprintln — vtable callback for the silent logger. Discards +// every record without dispatching through fmt; keeps silent truly +// silent if fmt ever gets stateful. +fn silentprintln(l: *logger, args: fmt.formattable...) void = { }; + +// new — wire `sl` as a stdlogger over `sink`. Hare returns by value +// (ref/hare/log/logger.ha:20); ww cgen can't return wide structs, +// so we take an out-parameter pointer (same shape as memio.fixed, +// bufio.init). +export fn new(sl: *stdlogger, sink: *io.stream) void = { + ensureinit(); + sl.logger.println = stdprintln; + sl.sink = sink; +}; + +// lprintln — dispatch `args` through `log`. Hare's lib/log/funcs.ha +// counterpart at line 8. +export fn lprintln(log: *logger, args: fmt.formattable...) void = { + ensureinit(); + log.println(log, args...); +}; + +// println — dispatch through the [[global]] logger. +export fn println(args: fmt.formattable...) void = { + ensureinit(); + lprintln(global, args...); +}; + +// lfatal — lprintln to `log` then exit(255). `never` return marks +// the bottom type so flow-control checks treat callers as terminated. +// Hare's lib/log/funcs.ha counterpart at line 28. +export fn lfatal(log: *logger, args: fmt.formattable...) never = { + lprintln(log, args...); + rawexit(255); +}; + +// fatal — lprintln to [[global]] then exit(255). Hare's lib/log/ +// funcs.ha counterpart at line 45. +export fn fatal(args: fmt.formattable...) never = { + println(args...); + rawexit(255); +}; + +// setlogger — install `log` as the [[global]] logger. Hare's +// lib/log/global.ha counterpart at line 20. +export fn setlogger(log: *logger) void = { + ensureinit(); + global = log; +}; + +// TODO: needs fmt {n}-placeholder parser (future task) — adds +// `lprintfln` / `printfln` / `lfatalf` / `fatalf` here once the +// printfln vtable slot is in place. diff --git a/lib/log/logtest.ww b/lib/log/logtest.ww new file mode 100644 index 00000000..6bd9efa7 --- /dev/null +++ b/lib/log/logtest.ww @@ -0,0 +1,189 @@ +// logtest — exercises lib/log. Run with `out/bin/ww run lib/log/logtest.ww`. +// +// One scenario per @test fn (the ww-stdlib idiom): each scenario +// constructs a stdlogger over a memio.stream and asserts the bytes +// the logger produced. The test surface mirrors what drew called out +// in the design read: lprintln-to-memio, silent-writes-nothing, and +// setlogger-swap. The process-terminating arm ([[log.fatal]] / +// [[log.lfatal]]) needs a subprocess to verify exit(255) without +// killing the test driver — left as a TODO until the project grows +// a subprocess fixture. + +use fmt; +use io; +use log; +use memio; + +// Direct exit(2) binding rather than `use os;` — os exports +// read/write/close, which collide with io.read/write/close under +// the driver's flat-scope concat. Same workaround as bufiotest / +// fmttest / memiotest. +@symbol("rt_syscall") fn syscall1ww(num: i64, a: i64) i64; +fn doexit(code: i32) void = { + syscall1ww(60i64, code: i64); +}; + +// signalled — bumped before each scenario so a failing exit code +// pinpoints the offending case. +let signalled: i32 = 0; + +fn fail() void = { doexit(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; +}; + +// ---- default points at global after init (must run first) ------------ + +// log.default writes to stderr through the private stderrsink — we +// can't easily capture stderr from inside the test, so this scenario +// verifies the initial wiring only: after lazy [[ensureinit]] runs, +// [[log.default]] / [[log.silent]] are non-nil and [[log.global]] +// equals [[log.default]]. The check is order-sensitive: any later +// scenario that calls [[log.setlogger]] mutates the global, so this +// scenario must run before [[setloggerswap]]. +@test fn defaultwiredtoglobal() void = { + let buf: [4]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, buf[0:4]); + let sl: log.stdlogger; + log.new(&sl, &s); // triggers ensureinit + + if (log.silent == nil) { fail(); }; + if (log.default == nil) { fail(); }; + if (log.global == nil) { fail(); }; + if (log.global != log.default) { fail(); }; +}; + +// ---- lprintln to a memio sink: exact-byte assertion ------------------- + +@test fn lprintlnbasic() void = { + let buf: [32]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, buf[0:32]); + + let sl: log.stdlogger; + log.new(&sl, &s); + + log.lprintln(&sl.logger, "hello", 42i64); + + if (!streq(memio.string(&mem), "hello 42\n")) { fail(); }; +}; + +// ---- lprintln single-arg: no leading space, just trailing newline ----- + +@test fn lprintlnsingle() void = { + let buf: [16]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, buf[0:16]); + + let sl: log.stdlogger; + log.new(&sl, &s); + + log.lprintln(&sl.logger, "only"); + + if (!streq(memio.string(&mem), "only\n")) { fail(); }; +}; + +// ---- lprintln zero args: bare newline --------------------------------- + +@test fn lprintlnempty() void = { + let buf: [4]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, buf[0:4]); + + let sl: log.stdlogger; + log.new(&sl, &s); + + log.lprintln(&sl.logger); + + if (!streq(memio.string(&mem), "\n")) { fail(); }; +}; + +// ---- silent: lprintln through log.silent writes no bytes -------------- + +// The silent logger's callback discards args without touching fmt or +// any sink. Verified indirectly: we wire a memio.stream and never +// pass it to log.silent — but the @test fn calling log.new first +// triggers [[log.ensureinit]] so log.silent is non-nil here. Then +// lprintln(silent, ...) must not crash and the memio sink must stay +// empty (silent has no path to it anyway). +@test fn silentwritesnothing() void = { + let buf: [16]u8; + let mem: memio.state; + let s: io.stream; + memio.fixed(&mem, &s, buf[0:16]); + + let sl: log.stdlogger; + log.new(&sl, &s); // triggers log.ensureinit; populates log.silent + + if (log.silent == nil) { fail(); }; + + log.lprintln(log.silent, "ignored", 1i64, true); + + if (mem.pos != 0) { fail(); }; +}; + +// ---- setlogger swap: global redirects to a new sink, then to silent -- + +// Three-phase: install sl1 as global, println writes there; swap to +// sl2, println writes there only; swap to silent, println discards. +// Each memio sink starts empty and is asserted at each phase to pin +// the swap semantics. +@test fn setloggerswap() void = { + let buf1: [32]u8; + let mem1: memio.state; + let s1: io.stream; + memio.fixed(&mem1, &s1, buf1[0:32]); + let sl1: log.stdlogger; + log.new(&sl1, &s1); + + let buf2: [32]u8; + let mem2: memio.state; + let s2: io.stream; + memio.fixed(&mem2, &s2, buf2[0:32]); + let sl2: log.stdlogger; + log.new(&sl2, &s2); + + log.setlogger(&sl1.logger); + log.println("first"); + if (!streq(memio.string(&mem1), "first\n")) { fail(); }; + if (mem2.pos != 0) { fail(); }; + + log.setlogger(&sl2.logger); + log.println("second"); + if (!streq(memio.string(&mem2), "second\n")) { fail(); }; + // mem1 must be unchanged. + if (!streq(memio.string(&mem1), "first\n")) { fail(); }; + + log.setlogger(log.silent); + log.println("dropped"); + // Both sinks unchanged. + if (!streq(memio.string(&mem1), "first\n")) { fail(); }; + if (!streq(memio.string(&mem2), "second\n")) { fail(); }; +}; + +// TODO subprocess: log.fatal / log.lfatal exit(255). Verifying them +// needs a fork+wait fixture so the parent can assert WEXITSTATUS == +// 255 without the test process itself terminating. lib/os doesn't +// ship process spawning yet; revisit when that lands. + +export fn main() i32 = { + signalled = 1; defaultwiredtoglobal(); + signalled = 2; lprintlnbasic(); + signalled = 3; lprintlnsingle(); + signalled = 4; lprintlnempty(); + signalled = 5; silentwritesnothing(); + signalled = 6; setloggerswap(); + return 0; +}; diff --git a/test/wcc/971_log_run.c b/test/wcc/971_log_run.c new file mode 100644 index 00000000..44c06dbc --- /dev/null +++ b/test/wcc/971_log_run.c @@ -0,0 +1,52 @@ +/* + * 971_log_run — execute the lib/log @test fixture under the C-side + * `ww run` driver and assert exit 0. + * + * log sits at the same tier as fmt (both consume io.stream sinks); + * slotting at 971, adjacent to 970_fmt_run, keeps both stdlib-runtime + * tests ahead of the 980-989 sub-band. logtest.ww carries its own + * `export fn main()` that drives the @test fns and signals which + * case failed via the exit code, so this file is a thin wrapper — + * no @test scanning, no synthetic main generation. + */ +#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; + + const char *src = "lib/log/logtest.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); + if (rc != 0) { + fprintf(stderr, "log_run FAIL: %s exited %d\n", src, rc); + return 1; + } + printf("log_run: %s ok\n", src); + return 0; +}