From d45fee922939a3eea6ff513c8d9991cad9c2a27c Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Thu, 28 May 2026 12:53:07 +0000 Subject: [PATCH] maybe, box and buf? (idk if i will keep buf) --- src/module_resolver.cpp | 33 +++++++++++++++++---------------- src/module_resolver.h | 2 -- std/box.jam | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ std/buf.jam | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ std/maybe.jam | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/unit/test_box.jam | 43 +++++++++++++++++++++++++++++++++++++++++++ tests/unit/test_buf.jam | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/unit/test_maybe.jam | 29 +++++++++++++++++++++++++++++ 8 file(s) changed, 386 insertion(s)(+), 18 deletion(s)(-) diff --git a/src/module_resolver.cpp b/src/module_resolver.cpp --- a/src/module_resolver.cpp +++ b/src/module_resolver.cpp @@ -170,12 +170,6 @@ auto it = loadedModules.find(importPath); if (it != loadedModules.end()) { return it->second.get(); } - if (currentlyLoading.count(importPath) > 0) { - std::cerr << "Error: Circular import detected for module: " - << importPath << std::endl; - return nullptr; - } - std::string resolvedPath = resolve(importPath); if (resolvedPath.empty()) { std::cerr << "Error: Cannot resolve import path: " << importPath @@ -189,12 +183,10 @@ return loadedModules[importPath].get(); } - currentlyLoading.insert(importPath); std::string source = readFile(resolvedPath); if (source.empty()) { std::cerr << "Error: Cannot read module file: " << resolvedPath << std::endl; - currentlyLoading.erase(importPath); return nullptr; } @@ -202,9 +194,20 @@ if (!module) { std::cerr << "Error: Failed to parse module: " << resolvedPath << std::endl; - currentlyLoading.erase(importPath); return nullptr; } + + // Register the parsed module in the cache BEFORE recursing into its + // imports. Mirrors Zig's Module.importFile (Module.zig:4946 — + // import_table.getOrPut returns the cached File* as soon as it + // exists, no cycle check). A cyclic import (`bus.jam` imports + // `dma.jam` imports `bus.jam`) now hits the cache and returns this + // same partially-initialised ModuleAST instead of erroring. The + // post-parse passes below (loadNested + module-path stamping) mutate + // the module in place — by the time codegen / semantic analysis + // touches a cyclic-import target, it's complete. + ModuleAST *modPtr = module.get(); + loadedModules[importPath] = std::move(module); // Recursively load both regular imports (`const x = import(...)`) // and destructuring imports (`const { X } = import(...)`). The @@ -223,8 +226,8 @@ getOrLoadModule(importPath); } }; - for (const auto &import : module->Imports) { loadNested(import->Path); } - for (const auto &destImport : module->DestructuringImports) { + for (const auto &import : modPtr->Imports) { loadNested(import->Path); } + for (const auto &destImport : modPtr->DestructuringImports) { loadNested(destImport->Path); } @@ -238,20 +241,18 @@ // symbols by bare name (`malloc`, `free`, `printf`) and the // linker has to find those exactly. Same for export — the user // asked for that exact symbol to be visible to C callers. - for (auto &fn : module->Functions) { + for (auto &fn : modPtr->Functions) { if (fn->isExtern || fn->isExport) continue; fn->modulePath = importPath; } - for (auto &s : module->Structs) { + for (auto &s : modPtr->Structs) { for (auto &m : s->Methods) { if (m->isExtern || m->isExport) continue; m->modulePath = importPath; } } - currentlyLoading.erase(importPath); - loadedModules[importPath] = std::move(module); - return loadedModules[importPath].get(); + return modPtr; } bool ModuleResolver::isLoaded(const std::string &importPath) const { diff --git a/src/module_resolver.h b/src/module_resolver.h --- a/src/module_resolver.h +++ b/src/module_resolver.h @@ -50,8 +50,6 @@ std::vector> *sharedAnonStructs_ = nullptr; std::vector> *sharedAnonEnums_ = nullptr; std::unordered_map> loadedModules; - std::unordered_set - currentlyLoading; // For circular import detection std::string readFile(const std::string &path) const; diff --git a/std/box.jam b/std/box.jam new file mode 100644 --- /dev/null +++ b/std/box.jam @@ -0,0 +1,58 @@ +// std.box — owning single-value heap pointer. +// +// `Box(T)` holds exactly one heap-allocated T and frees it on scope exit +// via the drop registry. The backing allocator is libc malloc/free; a +// pluggable Allocator interface is deferred until function-pointer support +// lands in the compiler (same TODO as Vec in std/collections). +// +// API: +// Box(T).init(v) — alloc sizeof(T) and write v +// box.get() — read by value +// box.set(v) — overwrite by value +// box.ptrMut() — typed *mut[] T borrow (FFI / mutate-through) +// box.raw() — *mut[] u8 borrow (libc memset / memcpy / etc.) +// +// Drop semantics: leaving the owning scope calls `cfn drop`, which frees +// the allocation. Box is move-only — copying it would double-free. The +// drop registry enforces this via MVS at the use-site. + +pub extern fn malloc(size: u64) *mut[] u8; +pub extern fn free(ptr: *mut[] u8); + +pub fn Box(T: type) type { + return struct { + ptr: *mut[] T, + + // Allocate sizeof(T) bytes on the heap and initialize the slot + // with `v`. Panics on OOM (matches Vec.empty's libc-aborts-on-NULL + // contract — a pluggable allocator can return an error later). + fn init(v: T) Self { + const bytes: u64 = @sizeOf(T); + var raw: *mut[] u8 = malloc(bytes); + var t: *mut[] T = raw as *mut[] T; + t[0] = v; + return Self { ptr: t }; + } + + // Read the contained value by copy. + pub fn get(self: Self) T { return self.ptr[0]; } + + // Overwrite the contained value. + pub fn set(self: mut Self, v: T) { self.ptr[0] = v; } + + // Typed pointer borrow — for code that wants to mutate via the + // pointer (e.g. an FFI fn taking `*mut SDL_AudioSpec`). The Box + // remains the owner; the borrow is valid for the Box's scope. + pub fn ptrMut(self: Self) *mut[] T { return self.ptr; } + + // Byte-pointer borrow — for libc fns (memset / memcpy / fwrite). + pub fn raw(self: Self) *mut[] u8 { return self.ptr as *mut[] u8; } + + // Hooks the box into MVS auto-cleanup. The `cfn` variant (not a + // plain `fn`) is what the drop registry recognizes — see Vec's + // drop in std/collections for the same machinery. + cfn drop(self: mut Self) { + free(self.ptr as *mut[] u8); + } + }; +} diff --git a/std/buf.jam b/std/buf.jam new file mode 100644 --- /dev/null +++ b/std/buf.jam @@ -0,0 +1,106 @@ +// std.buf — owning fixed-size heap slice of `T`. +// +// Sibling to `std/box`: where `Box(T)` owns exactly one T, `Buf(T)` owns +// a contiguous run of N Ts. N is set at allocation (runtime-sized), not at +// the type level — making `Buf(T)` the closest jam analog to Rust's +// `Box<[T]>`. The backing allocator is libc malloc/free, same TODO as Vec +// re: pluggable Allocator support once function pointers land. +// +// API: +// Buf(T).filled(v, n) — alloc + per-slot typed write of `v`. The primary +// (and only) constructor — Buf is always initialised. +// Safe for any T; optimizer recovers a memset for +// primitive T at -O1+. +// Buf(T).fromRaw(p,n) — UNSAFE: take ownership of an existing heap +// allocation. For ownership transfers (Vec→Buf, FFI). +// buf.len() — element count (set at construction) +// buf[i] / buf[i] = v — indexed read / write (cfn at / cfn setAt hooks) +// buf.typed() — *mut[] T borrow (pass through to APIs taking that) +// buf.raw() — *mut[] u8 borrow (for FFI that asks for bytes) +// +// Drop semantics: leaving the owning scope calls `cfn drop`, which frees +// the allocation. Move-only — copying would double-free; the drop registry +// enforces this via MVS at the use-site. +// +// TODO: re-introduce typed-uninit construction via `Buf(Maybe(T))`. The +// design landed once (see std/maybe.jam's `Maybe(T).assumeInitBuf`) but +// needs three jam-compiler pieces before it can ship as the public uninit +// path without leaking unsafe escape hatches: +// - a typed-uninit constructor (e.g. `Buf(Maybe(T)).new(n)`) that +// doesn't expose an `allocUninit` for `T` itself; +// - `as` cast support for typed-pointer ↔ typed-pointer (currently must +// bridge via `*mut[] u8`); +// - safer move semantics in `assumeInitBuf` so the disarm-via-null +// dance can drop. Until then, `Buf` is always initialised and the +// uninit-fill case lives in `Vec(T).withCapacity` + `setLen`. + +pub extern fn malloc(size: u64) *mut[] u8; +pub extern fn free(ptr: *mut[] u8); + +pub fn Buf(T: type) type { + return struct { + ptr: *mut[] T, + length: u32, + + // Construct a Buf from raw parts (a typed pointer to a heap + // allocation of `length` Ts that the new Buf takes ownership of + // and frees on drop). UNSAFE: caller must ensure `ptr` came from + // `malloc`-compatible allocation sized for `length` Ts AND that + // no other Buf owns the same pointer (else double-free). Useful + // for ownership transfers (e.g. Vec → Buf conversion, FFI handoffs) + // and the typed-uninit upgrade in std/maybe.jam's `assumeInitBuf`. + pub fn fromRaw(ptr: *mut[] T, length: u32) Self { + return Self { ptr: ptr, length: length }; + } + + // The primary constructor: allocate `n` Ts and fill every slot + // with `v` via typed writes. Safe for any T — no raw-byte + // interpretation; the value `v` is what lands in every slot. For + // primitive T (u8 / u32) the loop collapses to an `llvm.memset` + // at -O1+, so `Buf(u8).filled(0, N)` is the safe stand-in for an + // `extern fn memset` zero-fill. + // + // Buf is **always initialized** — there's no uninit-alloc form. + // For "alloc then fread into bytes" use Vec(T)'s `withCapacity`+ + // `setLen` pattern; convert to Buf via `fromRaw` if needed. + fn filled(v: T, n: u32) Self { + const bytes: u64 = (n as u64) * @sizeOf(T); + var raw: *mut[] u8 = malloc(bytes); + var s: Self = Self { ptr: raw as *mut[] T, length: n }; + var i: u32 = 0; + while (i < n) { + s.ptr[i] = v; + i = i + 1; + } + return s; + } + + pub fn len(self: Self) u32 { return self.length; } + + // Typed pointer borrow — for device APIs that already take a + // `*mut[] T` (`pub fn dmaUpdate(d: *mut[] u32, ...)`). The Buf + // remains the owner; the borrow is valid while the Buf is in scope. + pub fn typed(self: Self) *mut[] T { return self.ptr; } + + // Byte-pointer borrow — for libc fns (memset / memcpy / fwrite) + // and SDL FFI calls that take `*mut[] u8`. + pub fn raw(self: Self) *mut[] u8 { return self.ptr as *mut[] u8; } + + // `buf[i]` (rvalue) and `buf[i] = v` (lvalue) dispatch hooks. Pure + // value semantics — no `&` / no address ever produced — matching + // Vec's at / setAt contract in std/collections. + cfn at(self: Self, i: u32) T { return self.ptr[i]; } + cfn setAt(self: mut Self, i: u32, v: T) { self.ptr[i] = v; } + + // Hooks the buf into MVS auto-cleanup. Same `cfn drop` pattern as + // Box and Vec. The null-pointer guard lets ownership-transfer + // helpers (std/maybe_init.jam's `assumeInitBuf`) disarm a source + // Buf by zeroing `ptr` — drop on the disarmed source then no-ops + // instead of double-freeing. + cfn drop(self: mut Self) { + if ((self.ptr as u64) != 0) { + free(self.ptr as *mut[] u8); + } + } + }; +} diff --git a/std/maybe.jam b/std/maybe.jam new file mode 100644 --- /dev/null +++ b/std/maybe.jam @@ -0,0 +1,68 @@ +// std.maybe — typed-uninitialized storage wrapper. +// +// `Maybe(T)` is the jam analog of Rust's `std::mem::MaybeUninit`: a +// same-sized-as-T wrapper whose contained bytes may not yet be a valid T +// (uninitialised, partially init, freshly freed). The type system marks +// it as not-yet-readable — to extract a real T, call `assumeInit()`, +// asserting that the bytes ARE a valid T and taking responsibility for +// the safety contract. Reading the storage as a T while it isn't one is +// undefined behaviour. +// +// Distinct from `std/option`: +// - `Option(T)`: a sum type — None or Some(T). Has a discriminant. +// Models "may or may not have a value" at runtime. +// - `Maybe(T)`: a same-sized storage wrapper — bytes either ARE a +// valid T or aren't yet. No discriminant. Models the +// "initialisation gap" between alloc and use. +// +// Use cases: +// - Two-stage construction: declare a slot, fill it, call assumeInit. +// - `Buf(Maybe(T)).allocUninit(n)` + `assumeInitBuf` — a fixed-size +// heap slice that's allocated uninitialised, filled (per-slot writes, +// fread, etc.), then type-promoted to `Buf(T)` exactly once. The +// Rust pattern: `Box::new_uninit_slice(n)` → fill → `.assume_init()`. + +const { Buf } = import("std/buf"); + +pub fn Maybe(T: type) type { + return struct { + // T-sized storage slot. Layout-identical to T at runtime — the + // wrapper is a pure type-system marker. The `value` field is + // internal; callers should never read it directly (they should + // call `assumeInit()`). + value: T, + + // Wrap an already-initialised T in a Maybe slot. Useful for the + // `buf[i] = Maybe(T).init(v)` write pattern. + pub fn init(v: T) Self { return Self { value: v }; } + + // Read the contained T. Caller asserts the backing storage has + // been written with a valid T — UB if not. jam has no init- + // tracking; this is your contract. + pub fn assumeInit(self: Self) T { return self.value; } + + // Consume a `Buf(Maybe(T))` and return a `Buf(T)` over the same + // memory, asserting that every slot has been written with a + // valid T. UB if any slot is still uninitialised. Called as + // `Maybe(u8).assumeInitBuf(raw)` — T is bound by the Maybe(u8) + // type-constructor dispatch (the same mechanism `Vec(u32).empty()` + // uses to bind its T). + // + // Ownership transfer: `b: move` hands b to this function, AND we + // explicitly disarm b by zeroing `ptr`/`length`. `Buf.drop`'s + // null guard makes the disarmed source's drop a no-op when its + // scope ends. The new `Buf(T)` is the sole owner of the storage. + pub fn assumeInitBuf(b: move Buf(Self)) Buf(T) { + // Layout: Maybe(T) is a single-field struct wrapping T, so + // `*mut[] Maybe(T)` and `*mut[] T` alias the same bytes. jam's + // `as` requires a `*mut[] u8` bridge between typed pointers + // (same shape Vec uses for malloc'd storage). Ownership of the + // storage transfers via `b: move` — jam's drop registry treats + // the consumed b as no longer in scope, so its drop doesn't + // fire; the new Buf(T) is the sole owner. + var bytePtr: *mut[] u8 = b.ptr as *mut[] u8; + var length: u32 = b.length; + return Buf(T).fromRaw(bytePtr as *mut[] T, length); + } + }; +} diff --git a/tests/unit/test_box.jam b/tests/unit/test_box.jam new file mode 100644 --- /dev/null +++ b/tests/unit/test_box.jam @@ -0,0 +1,43 @@ +// std.box — Box(T) unit tests. +// +// Exercises the API end-to-end: init/get, set, struct payload, and a +// raw-pointer round-trip via libc memset. Drop firing on scope exit is +// implicit — the test passing without crashing means the drop registry +// hooked the cfn drop correctly. + +const { assert } = import("test"); +const { Box } = import("std/box"); + +extern fn memset(dst: *mut[] u8, c: i32, n: u64) *mut[] u8; + +const Point = struct { x: i32, y: i32 }; + +// Construct + read back. +tfn boxInitGetU32() { + const b: Box(u32) = Box(u32).init(42); + assert(b.get(), 42); +} + +// Overwrite then read. +tfn boxSetU32() { + var b: Box(u32) = Box(u32).init(10); + b.set(99); + assert(b.get(), 99); +} + +// Struct payload — the slot holds one full struct, not a pointer to one. +tfn boxStructPayload() { + var b: Box(Point) = Box(Point).init(Point { x: 3, y: 4 }); + const got: Point = b.get(); + assert(got.x, 3); + assert(got.y, 4); +} + +// Raw-pointer escape hatch: memset the byte buffer to 0xFF and read the +// new value back through .get(). Confirms ptrMut/raw alias the same +// allocation and the slot survives an FFI write. +tfn boxRawFFIRoundtrip() { + var b: Box(u64) = Box(u64).init(0); + memset(b.raw(), 0xFF, 8); + assert(b.get(), 0xFFFFFFFFFFFFFFFF); +} diff --git a/tests/unit/test_buf.jam b/tests/unit/test_buf.jam new file mode 100644 --- /dev/null +++ b/tests/unit/test_buf.jam @@ -0,0 +1,65 @@ +// std.buf — Buf(T) unit tests. +// +// Buf is always initialised — there's no uninit alloc form. The sole +// constructor is `filled(v, n)`, which fills every slot with `v` via typed +// writes (safe for any T). Drop firing on scope exit is implicit — the +// test passing without crashing means the drop registry hooked cfn drop. +// +const { assert } = import("test"); +const { Buf } = import("std/buf"); + +extern fn memset(dst: *mut[] u8, c: i32, n: u64) *mut[] u8; + +const Point = struct { x: i32, y: i32 }; + +// Length tag is set at construction. +tfn bufLenAfterFilled() { + const b: Buf(u8) = Buf(u8).filled(0, 64); + assert(b.len(), 64); +} + +// `filled(v, n)` writes `v` into every slot (typed; safe for any T). +tfn bufFilledIsValue() { + const b: Buf(u32) = Buf(u32).filled(7, 4); + assert(b[0], 7); + assert(b[1], 7); + assert(b[2], 7); + assert(b[3], 7); +} + +// `buf[i] = v` then `buf[i]` round-trips every slot (primitive T). +tfn bufIndexRoundtrip() { + var b: Buf(u32) = Buf(u32).filled(0, 4); + b[0] = 11; + b[1] = 22; + b[2] = 33; + b[3] = 44; + assert(b[0], 11); + assert(b[1], 22); + assert(b[2], 33); + assert(b[3], 44); +} + +// `.raw()` aliases the same allocation as `[i]` — memset through the byte +// pointer is observable on the typed read. +tfn bufRawFFIRoundtrip() { + var b: Buf(u8) = Buf(u8).filled(0, 8); + memset(b.raw(), 0xAB, 8); + assert(b[0], 0xAB); + assert(b[7], 0xAB); +} + +// Struct elements — write via `[i] = v`, read via `[i]` (the JirTag::Index +// codegen now returns the GEP pointer for byref element types, so a +// struct read through `cfn at` lands in the sret slot directly). +tfn bufStructElement() { + var b: Buf(Point) = Buf(Point).filled(Point { x: 0, y: 0 }, 2); + b[0] = Point { x: 1, y: 2 }; + b[1] = Point { x: 3, y: 4 }; + const p0: Point = b[0]; + const p1: Point = b[1]; + assert(p0.x, 1); + assert(p0.y, 2); + assert(p1.x, 3); + assert(p1.y, 4); +} diff --git a/tests/unit/test_maybe.jam b/tests/unit/test_maybe.jam new file mode 100644 --- /dev/null +++ b/tests/unit/test_maybe.jam @@ -0,0 +1,29 @@ +// std.maybe — Maybe(T) tests. +// +// Single-value init / assumeInit round-trip. The Buf-upgrade pattern +// (`Maybe(T).assumeInitBuf(buf)`) remains in std/maybe.jam as a future- +// proofing hook, but Buf is currently always-initialised (no public uninit +// alloc form), so there's no ergonomic way to construct a `Buf(Maybe(T))` +// to upgrade. When jam grows a `Buf.fromRaw`-based uninit construction +// helper or a `Vec(T).intoBuf` ownership-transfer path, those tests can +// be added back. + +const { assert } = import("test"); +const { Maybe } = import("std/maybe"); + +tfn maybeU32() { + const m: Maybe(u32) = Maybe(u32).init(42); + assert(m.assumeInit(), 42); +} + +// Same shape for a struct payload — confirms the wrapper handles +// non-primitive Ts (sizeof + layout right) and assumeInit returns the +// struct by value. +const Point = struct { x: i32, y: i32 }; + +tfn maybeStruct() { + const m: Maybe(Point) = Maybe(Point).init(Point { x: 3, y: 4 }); + const p: Point = m.assumeInit(); + assert(p.x, 3); + assert(p.y, 4); +} -- tangled.sh