diff --git a/Makefile b/Makefile
index 993d3ec..7a970f9 100644
--- a/Makefile
+++ b/Makefile
@@ -13,7 +13,7 @@ OUT := output
SRC_NAMES := jam_llvm main lexer parser codegen target cabi \
module_resolver symbol_table number_literal \
init_analysis drop_registry abi diagnostics decl \
- analyzer astgen jir_codegen jir_verify
+ analyzer comptime astgen jir_codegen jir_verify
OBJS := $(addprefix $(OUT)/, $(addsuffix .o, $(SRC_NAMES)))
# Check if we're on macOS or Linux
@@ -164,7 +164,14 @@ test-decl: build
test-analyzer: build
$(call CXX_TEST_TARGET,analyzer,$(OBJS))
-test: test-unit test-init test-abi test-codegen-errors test-jir test-diagnostics test-decl test-analyzer
+test-comptime: build
+ @echo ""
+ @echo "Building and running Comptime C++ tests..."
+ @clang++ -c ./tests/cpp/test_comptime.cpp -o $(OUT)/test_comptime.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
+ @clang++ -o $(OUT)/comptime_tests $(OUT)/test_comptime.o $(OUT)/comptime.o $(OUT)/diagnostics.o
+ @$(OUT)/comptime_tests
+
+test: test-unit test-init test-abi test-codegen-errors test-jir test-diagnostics test-decl test-analyzer test-comptime
test-release: test test-unit-release
fmt: format
diff --git a/src/ast.h b/src/ast.h
index c907803..e1f25da 100644
--- a/src/ast.h
+++ b/src/ast.h
@@ -63,6 +63,28 @@ class FunctionAST {
// regular `fn` shaped like one of those names is just a method;
// `cfn` is what wires it to the compiler's hooks.
bool isCfn;
+ // Name of the enclosing struct, if this is a method declared inside
+ // a `const T = struct { fn name(...) ... }` body. Empty for free
+ // functions and for clones of generic-instantiated methods (those
+ // already carry a qualified Name like `Vec__i32.push`, so the
+ // mangler keeps that as the LLVM symbol).
+ //
+ // The mangler reads this to emit unique LLVM symbols for methods
+ // across structs — without it, two structs that both define
+ // `fn init()` would collapse to the same `_init` symbol and the
+ // linker would silently pick one definition for both call sites.
+ std::string parentStruct;
+
+ // Path of the module this function was declared in (e.g. "timer"
+ // for code in timer.jam). Empty for the entry module (matching
+ // Zig's behavior where the root file scope is the unqualified
+ // namespace) and for generic clones that already carry qualified
+ // names. ModuleResolver stamps this when a module is loaded.
+ //
+ // Combined with parentStruct, the mangler can emit Zig-style
+ // dotted LLVM symbols (`timer.Timer.read32`) so same-named
+ // methods or free fns in different modules don't collide.
+ std::string modulePath;
FunctionAST(std::string Name, std::vector Args, TypeIdx ReturnType,
std::vector Body, bool isExtern = false,
diff --git a/src/astgen.cpp b/src/astgen.cpp
index b883a44..f099416 100644
--- a/src/astgen.cpp
+++ b/src/astgen.cpp
@@ -47,7 +47,7 @@ struct DropTrack {
std::string varName;
JirRef slot; // alloca for the variable
TypeIdx type; // source-level type
- std::string llvmFnName; // canonical drop fn (legacy mangles to `__drop_T`)
+ std::string llvmFnName; // canonical drop fn — FQN like `T.drop` / `m.T.drop`
};
struct AstGenCtx {
@@ -483,13 +483,12 @@ static void astgenReturn(AstGenCtx &gctx, const AstNode &n) {
}
// Resolve the canonical drop function name for a Jam type. Returns
-// empty when the type has no drop fn registered. For struct/named
-// types, legacy `mangledFunctionName` produces `__drop_`
-// at the LLVM level; we mirror that here so the call resolves at
-// codegen time even though the FunctionAST::Name is bare "drop".
-// Falls back to the codegen context's instantiated-drops table so
-// generic struct/enum instantiations (Vec(i32), Holder(i32), ...)
-// fire drops too.
+// empty when the type has no drop fn registered. Routes through
+// `mangledFunctionName` so the LLVM symbol picked here is the same
+// one the drop fn's definition gets at codegen time (FQN like
+// `T.drop` or `m.T.drop`). Falls back to the codegen context's
+// instantiated-drops table so generic struct/enum instantiations
+// (Vec(i32), Holder(i32), ...) fire drops too.
static std::string lookupDropFnLLVMName(AstGenCtx &gctx, TypeIdx ty) {
const TypeKey &k = gctx.ctx.getTypePool().get(ty);
std::string typeName;
@@ -3408,6 +3407,29 @@ static JirRef astgenCall(AstGenCtx &gctx, const AstNode &n) {
// not as a regular function call.
if (callee == "assert") { return astgenAssertCall(gctx, n); }
+ // Multi-dot qualified call: `handle.Struct.method(args)`. Mirrors
+ // Zig's `container_ty.getNamespace().lookupInNamespace(name)`
+ // (Sema.zig:5295) — methods on imported structs live under the
+ // importer's namespace handle, not in a flat global table. The
+ // registration site in main.cpp puts these under the key
+ // `handle.Struct.method`; we look them up directly here.
+ if (callee.find('.') != callee.rfind('.')) {
+ if (const FunctionAST *method = gctx.ctx.getFunctionAST(callee)) {
+ std::vector argRefs;
+ argRefs.reserve(argCount);
+ for (uint32_t i = 0; i < argCount; i++) {
+ NodeIdx argIdx =
+ static_cast(ns.getExtra(argsExtra + 1 + i));
+ if (i < method->Args.size()) {
+ argRefs.push_back(lowerArg(gctx, argIdx, method->Args[i]));
+ } else {
+ argRefs.push_back(astgenExpr(gctx, argIdx, kNoType));
+ }
+ }
+ return emitCall(gctx, method, argRefs);
+ }
+ }
+
// Single-dot qualified call: try in order
// 1. `inst.method(args)` — instance dispatch on a local variable
// 2. `Type.method(args)` — static method, possibly via type alias
diff --git a/src/comptime.cpp b/src/comptime.cpp
new file mode 100644
index 0000000..b589160
--- /dev/null
+++ b/src/comptime.cpp
@@ -0,0 +1,384 @@
+/*
+ * Copyright (c) 2026-present Raphael Amorim
+ *
+ * This file is part of jam.
+ * Licensed under the Apache License, Version 2.0 with LLVM Exceptions.
+ */
+
+#include "comptime.h"
+
+namespace jam {
+
+// ─── ComptimeValue constructors ──────────────────────────────────
+
+ComptimeValue ComptimeValue::makeNone() {
+ ComptimeValue v;
+ v.kind = Kind::None;
+ return v;
+}
+
+ComptimeValue ComptimeValue::makeInt(uint64_t bits, uint16_t width,
+ bool isSigned) {
+ ComptimeValue v;
+ v.kind = Kind::Int;
+ v.intVal.bits = bits;
+ v.intVal.width = width;
+ v.intVal.isSigned = isSigned;
+ return v;
+}
+
+ComptimeValue ComptimeValue::makeFloat(double value, uint16_t width) {
+ ComptimeValue v;
+ v.kind = Kind::Float;
+ v.floatVal.value = value;
+ v.floatVal.width = width;
+ return v;
+}
+
+ComptimeValue ComptimeValue::makeBool(bool b) {
+ ComptimeValue v;
+ v.kind = Kind::Bool;
+ v.boolVal = b;
+ return v;
+}
+
+ComptimeValue ComptimeValue::makeStr(StringIdx s) {
+ ComptimeValue v;
+ v.kind = Kind::Str;
+ v.strVal = s;
+ return v;
+}
+
+ComptimeValue ComptimeValue::makeType(TypeIdx t) {
+ ComptimeValue v;
+ v.kind = Kind::Type;
+ v.typeVal = t;
+ return v;
+}
+
+ComptimeValue
+ComptimeValue::makeAggregate(std::vector fields) {
+ ComptimeValue v;
+ v.kind = Kind::Aggregate;
+ v.aggFields = std::move(fields);
+ return v;
+}
+
+int64_t ComptimeValue::asI64() const {
+ if (kind != Kind::Int) return 0;
+ // Sign-extend from the value's bit-width so `i8(-1)` returns -1
+ // (not 255). Unsigned values are returned as-is up to 63 bits.
+ uint64_t b = intVal.bits;
+ if (intVal.isSigned && intVal.width < 64) {
+ uint64_t signBit = 1ULL << (intVal.width - 1);
+ if (b & signBit) {
+ uint64_t mask = ~((1ULL << intVal.width) - 1);
+ b |= mask;
+ }
+ }
+ return static_cast(b);
+}
+
+uint64_t ComptimeValue::asU64() const {
+ if (kind != Kind::Int) return 0;
+ // Mask to the value's bit-width so over-wide bit patterns can't
+ // leak. `i8(-1)` returns 0xFF here, `u8(255)` also returns 0xFF.
+ if (intVal.width >= 64) return intVal.bits;
+ return intVal.bits & ((1ULL << intVal.width) - 1);
+}
+
+// ─── ComptimeScope ──────────────────────────────────────────────
+
+void ComptimeScope::bind(const std::string &name, ComptimeValue value) {
+ bindings_[name] = std::move(value);
+}
+
+const ComptimeValue *ComptimeScope::lookup(const std::string &name) const {
+ auto it = bindings_.find(name);
+ if (it != bindings_.end()) return &it->second;
+ if (parent_ != nullptr) return parent_->lookup(name);
+ return nullptr;
+}
+
+// ─── Evaluator ──────────────────────────────────────────────────
+
+ComptimeEvaluator::ComptimeEvaluator(const NodeStore &nodes,
+ const StringPool &strings,
+ const TypePool &types)
+ : nodes_(nodes), strings_(strings), types_(types) {}
+
+ComptimeValue ComptimeEvaluator::eval(NodeIdx expr,
+ const ComptimeScope &scope) const {
+ if (expr == kNoNode) return ComptimeValue::makeNone();
+ const AstNode &n = nodes_.get(expr);
+ switch (n.tag) {
+ case AstTag::NumberLit:
+ return evalNumberLit(n);
+ case AstTag::BoolLit:
+ return evalBoolLit(n);
+ case AstTag::StringLit:
+ return evalStringLit(n);
+ case AstTag::Variable:
+ return evalVariable(n, scope);
+ case AstTag::UnaryOp:
+ return evalUnaryOp(n, scope);
+ case AstTag::BinaryOp:
+ return evalBinaryOp(n, scope);
+ case AstTag::Index:
+ return evalIndex(n, scope);
+ default:
+ // Operator / construct we don't fold yet. Returning None keeps
+ // optional-fold callers (peephole constant folding) silent;
+ // `evalRequired` will turn it into a diagnostic.
+ return ComptimeValue::makeNone();
+ }
+}
+
+ComptimeValue ComptimeEvaluator::evalRequired(NodeIdx expr,
+ const ComptimeScope &scope,
+ Diagnostics &diags,
+ SrcLoc loc) const {
+ ComptimeValue v = eval(expr, scope);
+ if (v.isNone()) {
+ diags.error(std::move(loc),
+ "expression cannot be evaluated at compile time");
+ }
+ return v;
+}
+
+ComptimeValue ComptimeEvaluator::evalNumberLit(const AstNode &n) const {
+ uint64_t bits = static_cast(n.lhs) |
+ (static_cast(n.rhs) << 32);
+ bool isNeg = (n.flags & 1) != 0;
+ bool isFloat = (n.flags & 2) != 0;
+ if (isFloat) {
+ double v;
+ __builtin_memcpy(&v, &bits, sizeof(v));
+ if (isNeg) v = -v;
+ return ComptimeValue::makeFloat(v, 64);
+ }
+ // Default integer width: u64 (or i64 if negative). Callers can
+ // narrow via the surrounding type context, but at this evaluator
+ // layer we keep the literal at full width to preserve precision
+ // during folding.
+ if (isNeg) {
+ uint64_t magnitude = bits;
+ uint64_t signedBits = static_cast(
+ -static_cast(magnitude));
+ return ComptimeValue::makeInt(signedBits, 64, /*isSigned=*/true);
+ }
+ return ComptimeValue::makeInt(bits, 64, /*isSigned=*/false);
+}
+
+ComptimeValue ComptimeEvaluator::evalBoolLit(const AstNode &n) const {
+ return ComptimeValue::makeBool(n.lhs != 0);
+}
+
+ComptimeValue ComptimeEvaluator::evalStringLit(const AstNode &n) const {
+ return ComptimeValue::makeStr(static_cast(n.lhs));
+}
+
+ComptimeValue
+ComptimeEvaluator::evalVariable(const AstNode &n,
+ const ComptimeScope &scope) const {
+ const std::string &name = strings_.get(static_cast(n.lhs));
+ const ComptimeValue *v = scope.lookup(name);
+ if (v == nullptr) return ComptimeValue::makeNone();
+ return *v;
+}
+
+ComptimeValue
+ComptimeEvaluator::evalUnaryOp(const AstNode &n,
+ const ComptimeScope &scope) const {
+ NodeIdx operand = static_cast(n.lhs);
+ ComptimeValue v = eval(operand, scope);
+ if (v.isNone()) return v;
+ UnaryOp op = static_cast(n.op);
+ switch (op) {
+ case UnaryOp::Neg:
+ if (v.kind == ComptimeValue::Kind::Int) {
+ uint64_t neg = static_cast(-static_cast(v.asU64()));
+ return ComptimeValue::makeInt(neg, v.intVal.width, true);
+ }
+ if (v.kind == ComptimeValue::Kind::Float) {
+ return ComptimeValue::makeFloat(-v.floatVal.value, v.floatVal.width);
+ }
+ return ComptimeValue::makeNone();
+ case UnaryOp::LogNot:
+ if (v.kind != ComptimeValue::Kind::Bool) return ComptimeValue::makeNone();
+ return ComptimeValue::makeBool(!v.boolVal);
+ case UnaryOp::BitNot:
+ if (v.kind != ComptimeValue::Kind::Int) return ComptimeValue::makeNone();
+ return ComptimeValue::makeInt(~v.intVal.bits, v.intVal.width,
+ v.intVal.isSigned);
+ default:
+ return ComptimeValue::makeNone();
+ }
+}
+
+ComptimeValue
+ComptimeEvaluator::evalBinaryOp(const AstNode &n,
+ const ComptimeScope &scope) const {
+ NodeIdx lhsIdx = static_cast(n.lhs);
+ NodeIdx rhsIdx = static_cast(n.rhs);
+ BinOp op = static_cast(n.op);
+
+ // Short-circuit eval for LogAnd/LogOr — match the runtime semantics
+ // and avoid evaluating the RHS when the LHS settles the answer.
+ if (op == BinOp::LogAnd || op == BinOp::LogOr) {
+ ComptimeValue l = eval(lhsIdx, scope);
+ if (l.kind != ComptimeValue::Kind::Bool) return ComptimeValue::makeNone();
+ bool lb = l.boolVal;
+ if (op == BinOp::LogAnd && !lb) return ComptimeValue::makeBool(false);
+ if (op == BinOp::LogOr && lb) return ComptimeValue::makeBool(true);
+ ComptimeValue r = eval(rhsIdx, scope);
+ if (r.kind != ComptimeValue::Kind::Bool) return ComptimeValue::makeNone();
+ return ComptimeValue::makeBool(r.boolVal);
+ }
+
+ ComptimeValue l = eval(lhsIdx, scope);
+ if (l.isNone()) return l;
+ ComptimeValue r = eval(rhsIdx, scope);
+ if (r.isNone()) return r;
+
+ // Integer arithmetic + bitwise — both operands must be Int, and
+ // for now we require matching width/signedness. Mixed-width is a
+ // codegen-level concern; the comp evaluator stays strict.
+ if (l.kind == ComptimeValue::Kind::Int &&
+ r.kind == ComptimeValue::Kind::Int) {
+ if (l.intVal.width != r.intVal.width ||
+ l.intVal.isSigned != r.intVal.isSigned) {
+ return ComptimeValue::makeNone();
+ }
+ uint16_t w = l.intVal.width;
+ bool sgn = l.intVal.isSigned;
+ uint64_t a = l.intVal.bits;
+ uint64_t b = r.intVal.bits;
+ switch (op) {
+ case BinOp::Add:
+ return ComptimeValue::makeInt(a + b, w, sgn);
+ case BinOp::Sub:
+ return ComptimeValue::makeInt(a - b, w, sgn);
+ case BinOp::Mul:
+ return ComptimeValue::makeInt(a * b, w, sgn);
+ case BinOp::Div:
+ if (b == 0) return ComptimeValue::makeNone();
+ if (sgn) {
+ return ComptimeValue::makeInt(
+ static_cast(static_cast(a) /
+ static_cast(b)),
+ w, sgn);
+ }
+ return ComptimeValue::makeInt(a / b, w, sgn);
+ case BinOp::Mod:
+ if (b == 0) return ComptimeValue::makeNone();
+ if (sgn) {
+ return ComptimeValue::makeInt(
+ static_cast(static_cast(a) %
+ static_cast(b)),
+ w, sgn);
+ }
+ return ComptimeValue::makeInt(a % b, w, sgn);
+ case BinOp::BitAnd:
+ return ComptimeValue::makeInt(a & b, w, sgn);
+ case BinOp::BitOr:
+ return ComptimeValue::makeInt(a | b, w, sgn);
+ case BinOp::BitXor:
+ return ComptimeValue::makeInt(a ^ b, w, sgn);
+ case BinOp::Shl:
+ return ComptimeValue::makeInt(a << b, w, sgn);
+ case BinOp::Shr:
+ if (sgn) {
+ return ComptimeValue::makeInt(
+ static_cast(static_cast(a) >> b), w, sgn);
+ }
+ return ComptimeValue::makeInt(a >> b, w, sgn);
+ case BinOp::Eq:
+ return ComptimeValue::makeBool(a == b);
+ case BinOp::Ne:
+ return ComptimeValue::makeBool(a != b);
+ case BinOp::Lt:
+ return sgn
+ ? ComptimeValue::makeBool(static_cast(a) <
+ static_cast(b))
+ : ComptimeValue::makeBool(a < b);
+ case BinOp::Le:
+ return sgn
+ ? ComptimeValue::makeBool(static_cast(a) <=
+ static_cast(b))
+ : ComptimeValue::makeBool(a <= b);
+ case BinOp::Gt:
+ return sgn
+ ? ComptimeValue::makeBool(static_cast(a) >
+ static_cast(b))
+ : ComptimeValue::makeBool(a > b);
+ case BinOp::Ge:
+ return sgn
+ ? ComptimeValue::makeBool(static_cast(a) >=
+ static_cast(b))
+ : ComptimeValue::makeBool(a >= b);
+ default:
+ return ComptimeValue::makeNone();
+ }
+ }
+
+ // String equality — used by format-string parsing (matching
+ // field names in the args tuple by the parsed `{name}` slice).
+ if (l.kind == ComptimeValue::Kind::Str &&
+ r.kind == ComptimeValue::Kind::Str) {
+ bool eq = l.strVal == r.strVal;
+ if (op == BinOp::Eq) return ComptimeValue::makeBool(eq);
+ if (op == BinOp::Ne) return ComptimeValue::makeBool(!eq);
+ return ComptimeValue::makeNone();
+ }
+
+ // Bool equality — `comp const flag = true; comp if (flag == false) ...`
+ if (l.kind == ComptimeValue::Kind::Bool &&
+ r.kind == ComptimeValue::Kind::Bool) {
+ if (op == BinOp::Eq)
+ return ComptimeValue::makeBool(l.boolVal == r.boolVal);
+ if (op == BinOp::Ne)
+ return ComptimeValue::makeBool(l.boolVal != r.boolVal);
+ return ComptimeValue::makeNone();
+ }
+
+ // Type equality — the linchpin of `comp if (@TypeOf(arg) == i32)`.
+ if (l.kind == ComptimeValue::Kind::Type &&
+ r.kind == ComptimeValue::Kind::Type) {
+ bool eq = l.typeVal == r.typeVal;
+ if (op == BinOp::Eq) return ComptimeValue::makeBool(eq);
+ if (op == BinOp::Ne) return ComptimeValue::makeBool(!eq);
+ return ComptimeValue::makeNone();
+ }
+
+ return ComptimeValue::makeNone();
+}
+
+ComptimeValue
+ComptimeEvaluator::evalIndex(const AstNode &n,
+ const ComptimeScope &scope) const {
+ NodeIdx baseIdx = static_cast(n.lhs);
+ NodeIdx idxIdx = static_cast(n.rhs);
+ ComptimeValue base = eval(baseIdx, scope);
+ if (base.isNone()) return base;
+ ComptimeValue idx = eval(idxIdx, scope);
+ if (idx.isNone() || idx.kind != ComptimeValue::Kind::Int) {
+ return ComptimeValue::makeNone();
+ }
+ uint64_t i = idx.asU64();
+ if (base.kind == ComptimeValue::Kind::Str) {
+ const std::string &s = strings_.get(base.strVal);
+ if (i >= s.length()) return ComptimeValue::makeNone();
+ // Result is the byte value as a u8.
+ return ComptimeValue::makeInt(static_cast(
+ static_cast(s[i])),
+ 8, /*isSigned=*/false);
+ }
+ if (base.kind == ComptimeValue::Kind::Aggregate) {
+ if (i >= base.aggFields.size()) return ComptimeValue::makeNone();
+ return base.aggFields[i];
+ }
+ return ComptimeValue::makeNone();
+}
+
+} // namespace jam
diff --git a/src/comptime.h b/src/comptime.h
new file mode 100644
index 0000000..64f04ad
--- /dev/null
+++ b/src/comptime.h
@@ -0,0 +1,157 @@
+/*
+ * Copyright (c) 2026-present Raphael Amorim
+ *
+ * This file is part of jam.
+ * Licensed under the Apache License, Version 2.0 with LLVM Exceptions.
+ */
+
+#ifndef JAM_COMPTIME_H
+#define JAM_COMPTIME_H
+
+#include "ast_flat.h"
+#include "diagnostics.h"
+
+#include
+#include
+#include
+#include
+
+namespace jam {
+
+// A value known at compile time. Carries one of the primitive kinds the
+// comp evaluator can produce. Aggregate is for tuple / struct values
+// constructed at compile time (`.{1, 2, 3}` literal). Type is a value
+// of the meta-type (`@TypeOf(x)`, generic-parameter args).
+//
+// The payload is split: trivially-copyable scalars live in a union; non-
+// trivial members (strings, vectors) live alongside. None is the failure
+// / "not foldable" state — every operation that can fail returns it
+// rather than throwing.
+//
+// Source-level keyword for the feature is `comp` (e.g. `comp const N =
+// 42;`, `comp if (cond) ...`); the compiler-internal naming uses the
+// older "comptime" term to refer to the broader compile-time-evaluation
+// concept.
+struct ComptimeValue {
+ enum class Kind : uint8_t {
+ None,
+ Int,
+ Float,
+ Bool,
+ Str,
+ Type,
+ Aggregate,
+ };
+
+ Kind kind = Kind::None;
+
+ // Scalar payload (active iff kind is Int / Float / Bool).
+ union {
+ struct {
+ uint64_t bits;
+ uint16_t width;
+ bool isSigned;
+ } intVal;
+ struct {
+ double value;
+ uint16_t width;
+ } floatVal;
+ bool boolVal;
+ uint8_t _pad; // ensure trivially-default-constructible union
+ };
+
+ // Non-trivial payload (active per matching kind).
+ StringIdx strVal = 0;
+ TypeIdx typeVal = kNoType;
+ std::vector aggFields;
+
+ ComptimeValue() : kind(Kind::None), _pad(0) {}
+
+ bool isNone() const { return kind == Kind::None; }
+ bool isInt() const { return kind == Kind::Int; }
+ bool isBool() const { return kind == Kind::Bool; }
+ bool isStr() const { return kind == Kind::Str; }
+ bool isType() const { return kind == Kind::Type; }
+
+ // Construction helpers — clearer than direct field stomping.
+ static ComptimeValue makeNone();
+ static ComptimeValue makeInt(uint64_t bits, uint16_t width, bool isSigned);
+ static ComptimeValue makeFloat(double v, uint16_t width);
+ static ComptimeValue makeBool(bool b);
+ static ComptimeValue makeStr(StringIdx s);
+ static ComptimeValue makeType(TypeIdx t);
+ static ComptimeValue makeAggregate(std::vector fields);
+
+ // Convert an Int payload to int64 / uint64 for arithmetic. Both
+ // helpers truncate to the value's width to keep the bit pattern
+ // faithful — e.g. `i8(-1)` reads back as 0xFF.
+ int64_t asI64() const;
+ uint64_t asU64() const;
+};
+
+// A lexically-scoped map of name → ComptimeValue. The evaluator reads
+// from it when it encounters a Variable node. Bindings can be pushed
+// and popped to model nested scopes during inline-for unrolling, where
+// the loop variable lives in a transient frame.
+class ComptimeScope {
+ public:
+ ComptimeScope() = default;
+ explicit ComptimeScope(const ComptimeScope *parent) : parent_(parent) {}
+
+ void bind(const std::string &name, ComptimeValue value);
+
+ // Returns nullptr if `name` isn't bound here OR in any ancestor.
+ const ComptimeValue *lookup(const std::string &name) const;
+
+ private:
+ const ComptimeScope *parent_ = nullptr;
+ std::unordered_map bindings_;
+};
+
+// Folds AST expression nodes to compile-time values. Failure modes
+// (depends on runtime value, unsupported operator, type mismatch) all
+// surface as ComptimeValue::None — the evaluator never throws. Callers
+// that *require* a fold (e.g. `comp expr`, `inline for` cond) call
+// `evalRequired` which pushes a diagnostic on failure.
+//
+// The evaluator is stateless across calls: it captures references to
+// the node/string/type pools at construction and reads bindings from a
+// caller-supplied scope per `eval` invocation. Constructing one per
+// call is cheap.
+class ComptimeEvaluator {
+ public:
+ ComptimeEvaluator(const NodeStore &nodes, const StringPool &strings,
+ const TypePool &types);
+
+ // Try to fold `expr` to a value. Returns None on any failure.
+ ComptimeValue eval(NodeIdx expr, const ComptimeScope &scope) const;
+
+ // Same but pushes a diagnostic + returns None when the expression
+ // can't be folded. Used by `comp expr` (which the user has
+ // explicitly marked as requiring fold) and by control-flow primitives
+ // where a non-fold is a hard error.
+ ComptimeValue evalRequired(NodeIdx expr, const ComptimeScope &scope,
+ Diagnostics &diags, SrcLoc loc) const;
+
+ private:
+ const NodeStore &nodes_;
+ const StringPool &strings_;
+ const TypePool &types_;
+
+ // Per-tag handlers. Each returns None on failure; callers compose.
+ ComptimeValue evalNumberLit(const AstNode &n) const;
+ ComptimeValue evalBoolLit(const AstNode &n) const;
+ ComptimeValue evalStringLit(const AstNode &n) const;
+ ComptimeValue evalVariable(const AstNode &n,
+ const ComptimeScope &scope) const;
+ ComptimeValue evalUnaryOp(const AstNode &n,
+ const ComptimeScope &scope) const;
+ ComptimeValue evalBinaryOp(const AstNode &n,
+ const ComptimeScope &scope) const;
+ ComptimeValue evalIndex(const AstNode &n,
+ const ComptimeScope &scope) const;
+};
+
+} // namespace jam
+
+#endif // JAM_COMPTIME_H
diff --git a/src/drop_registry.cpp b/src/drop_registry.cpp
index 9c5923a..d5b4f4c 100644
--- a/src/drop_registry.cpp
+++ b/src/drop_registry.cpp
@@ -20,7 +20,10 @@ namespace drops {
// `cfn` (not plain `fn`) is required: only methods opted into the
// compiler-synthesized-call set get auto-fired at scope exit. A
// plain `fn drop(self)` is treated as an ordinary method the user
-// invokes explicitly — no implicit destructor call.
+// invokes explicitly — no implicit destructor call. This matters
+// because a user-callable `fn drop` plus an auto-fire would
+// double-drop; the `cfn` opt-in is the signal that the user is
+// handing control of the call to the compiler.
static void considerDropCandidate(const FunctionAST *fn, const TypePool &types,
const StringPool &strings,
DropRegistry ®istry) {
diff --git a/src/jir.h b/src/jir.h
index feda6b2..28e54aa 100644
--- a/src/jir.h
+++ b/src/jir.h
@@ -222,8 +222,8 @@ enum class JirTag : uint8_t {
// Explicit destructor call for a tracked binding. Emitted by
// AstGen at scope-exit points (return / break / continue /
// fall-through). `a` = the binding's alloca JirRef; `b` =
- // StringIdx of the LLVM symbol to call (e.g. `__drop_T` for
- // top-level drop fns, `T.drop` for instantiated methods).
+ // StringIdx of the LLVM symbol to call (e.g. `T.drop` for a
+ // top-level drop fn, `Vec__i32.drop` for instantiated methods).
// Codegen unconditionally lowers to `call void (ptr)`
// — no metadata-driven emission, no scope-tracking outside
// AstGen. ty is kNoType (void).
diff --git a/src/jir_codegen.cpp b/src/jir_codegen.cpp
index f472f79..f8b32b7 100644
--- a/src/jir_codegen.cpp
+++ b/src/jir_codegen.cpp
@@ -597,8 +597,8 @@ static JamValueRef emitInstImpl(JirCodegenCtx &lctx, JirRef r) {
case JirTag::Call: {
// `inst.a` is the StringIdx of the LLVM symbol name —
// astgen has already done the mangling (test functions get
- // `__test_`; `fn drop(self: mut T)` becomes `__drop_T`;
- // instantiated cloned methods keep their qualified
+ // `__test_`; methods get dotted FQNs like `T.drop` or
+ // `m.T.drop`; instantiated cloned methods keep their
// `Vec__i32.push` form). Codegen does a single lookup.
StringIdx calleeId = static_cast(inst.a);
const std::string &symbol = lctx.ctx.getStringPool().get(calleeId);
diff --git a/src/main.cpp b/src/main.cpp
index 0d870d3..7d8a80d 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -324,8 +324,25 @@ static int compileAndRun(const std::string &filename,
}
}
for (auto &s : importedModule->Structs) {
- if (s->isPub) aliasNamed(s->Name);
- else codegenCtx.registerPrivateName(handle, s->Name);
+ if (s->isPub) {
+ aliasNamed(s->Name);
+ // Also register each pub method under the namespace-
+ // qualified key `handle.Struct.method` so astgen's
+ // multi-dot callee lookup can find it. Mirrors the
+ // free-fn dual registration above (bare + handle.X).
+ // Inspired by Zig's namespace-scoped Decl lookup
+ // (Sema.zig:5295) where every type carries a back-
+ // pointer to its namespace and method resolution walks
+ // `container_ty.getNamespace().lookupInNamespace(name)`.
+ for (auto &m : s->Methods) {
+ if (m->isPub) {
+ codegenCtx.registerFunctionAST(
+ handle + "." + s->Name + "." + m->Name, m.get());
+ }
+ }
+ } else {
+ codegenCtx.registerPrivateName(handle, s->Name);
+ }
}
for (auto &e : importedModule->Enums) {
if (e->isPub) aliasNamed(e->Name);
@@ -672,6 +689,19 @@ static int compileAndRun(const std::string &filename,
}
}
}
+ for (auto &s : importedModule->Structs) {
+ for (auto &m : s->Methods) {
+ if (!m->isPub) continue;
+ try {
+ JirFunction jfn = astgenFunction(*m, codegenCtx);
+ jfn.name = mangledFunctionName(*m, codegenCtx.getTypePool(),
+ codegenCtx.getStringPool());
+ jirFunctions.push_back(std::move(jfn));
+ } catch (const AstGenAnalysisFail &) {
+ // diagnostic already pushed
+ }
+ }
+ }
}
// Astgen accumulated every per-decl diagnostic onto the
diff --git a/src/mangling.h b/src/mangling.h
index 2a000a5..ca6c5b2 100644
--- a/src/mangling.h
+++ b/src/mangling.h
@@ -14,15 +14,24 @@
#include
// Translate a FunctionAST into the LLVM-level symbol the linker sees.
+// Modeled on Zig's `Decl.getFullyQualifiedName` (Module.zig:713) —
+// dot-separated, walking up the namespace chain. LLVM accepts `.` in
+// symbol names, so no further escaping is needed.
+//
// The rules:
// - `tfn t()` → `__test_t` (the harness in main.cpp calls these by
// prefixed name).
-// - `fn drop(self: mut T)` → `__drop_T` (the legacy mangling, used
-// so callers don't have to spell out the full struct name every
-// time they `T.drop(&x)`).
// - Cloned instantiated methods carry their qualified name already
// (`Vec__i32.push`) — we keep it.
-// - Everything else → bare `fn.Name`.
+// - Free-function `cfn drop(self: mut T)` → `T.drop`. The receiver
+// type qualifies it so two top-level drops for different types
+// don't collide on the bare name `drop`. Matches what an
+// equivalent in-struct `cfn drop` would mangle to.
+// - Otherwise: `[modulePath.][parentStruct.]Name`
+// free fn in entry module → `name`
+// free fn in module `m` → `m.name`
+// method on `T` in entry module → `T.name`
+// method on `T` in module `m` → `m.T.name`
//
// Centralised so every site that picks a function's LLVM symbol
// (`jirDeclarePrototype`, `astgen::emitCall`, generic instantiation in
@@ -33,17 +42,32 @@ inline std::string mangledFunctionName(const FunctionAST &fn,
const TypePool &types,
const StringPool &strings) {
if (fn.isTest) return "__test_" + fn.Name;
- if (fn.Name == "drop" && fn.Args.size() == 1) {
+ // Free-function drop: qualify by receiver type so `cfn drop(self:
+ // mut A)` and `cfn drop(self: mut B)` get distinct symbols. Only
+ // kicks in when parentStruct is empty — in-struct `cfn drop` goes
+ // through the standard FQN path below.
+ if (fn.parentStruct.empty() && fn.Name == "drop" &&
+ fn.Args.size() == 1) {
const Param &p = fn.Args[0];
if (p.Name == "self" && p.Mode == ParamMode::Mut) {
const TypeKey &k = types.get(p.Type);
if (k.kind == TypeKind::Struct || k.kind == TypeKind::Named) {
StringIdx ni = static_cast(k.a);
- if (ni != kNoString) { return "__drop_" + strings.get(ni); }
+ if (ni != kNoString) { return strings.get(ni) + ".drop"; }
}
}
}
- return fn.Name;
+ std::string out;
+ if (!fn.modulePath.empty()) {
+ out += fn.modulePath;
+ out += '.';
+ }
+ if (!fn.parentStruct.empty()) {
+ out += fn.parentStruct;
+ out += '.';
+ }
+ out += fn.Name;
+ return out;
}
#endif // JAM_MANGLING_H
diff --git a/src/module_resolver.cpp b/src/module_resolver.cpp
index 55aefa6..0984027 100644
--- a/src/module_resolver.cpp
+++ b/src/module_resolver.cpp
@@ -225,6 +225,27 @@ ModuleAST *ModuleResolver::getOrLoadModule(const std::string &importPath) {
loadNested(destImport->Path);
}
+ // Stamp the import path on every function/method so the mangler
+ // can build Zig-style FQNs (`timer.Timer.read32`). Without this,
+ // two modules that both define `pub fn helper()` or `pub const
+ // Counter = struct { pub fn init() }` would emit the same LLVM
+ // symbol and the linker would silently merge them.
+ //
+ // Extern fns are skipped — they reference libc / external C
+ // 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) {
+ if (fn->isExtern || fn->isExport) continue;
+ fn->modulePath = importPath;
+ }
+ for (auto &s : module->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();
diff --git a/src/parser.cpp b/src/parser.cpp
index 68c4de1..9aaacd4 100644
--- a/src/parser.cpp
+++ b/src/parser.cpp
@@ -1400,7 +1400,16 @@ void Parser::parseStructBody(
// consumes the leading keyword(s) itself.
if (check(TOK_FN) || check(TOK_CFN) || check(TOK_PUB) ||
check(TOK_EXTERN) || check(TOK_EXPORT) || check(TOK_TFN)) {
- methods.push_back(parseFunction());
+ auto methodFn = parseFunction();
+ // Stamp the enclosing struct so the mangler can give the
+ // method a unique LLVM symbol (e.g. `Counter__init`),
+ // avoiding collisions with same-named methods on other
+ // structs. `structContextStack.back()` is the struct
+ // currently being parsed.
+ if (!structContextStack.empty()) {
+ methodFn->parentStruct = structContextStack.back();
+ }
+ methods.push_back(std::move(methodFn));
match(TOK_COMMA); // optional trailing comma after a method
continue;
}
diff --git a/std/collections.jam b/std/collections.jam
index 81184ff..e42ecd6 100644
--- a/std/collections.jam
+++ b/std/collections.jam
@@ -170,7 +170,7 @@ pub fn Vec(T: type) type {
}
// `cfn drop` is what hooks Vec into MVS auto-cleanup. A
- // plain `fn drop` would not auto-fire at scope exit; only
+ // plain `cfn drop` would not auto-fire at scope exit; only
// the cfn variant is registered with the drop registry.
cfn drop(self: mut Self) {
free(self.ptr as *mut[] u8);
diff --git a/tests/cpp/test_comptime.cpp b/tests/cpp/test_comptime.cpp
new file mode 100644
index 0000000..b3eb104
--- /dev/null
+++ b/tests/cpp/test_comptime.cpp
@@ -0,0 +1,413 @@
+// Unit tests for src/comptime.h — the comp-time expression evaluator.
+//
+// Each test builds a small AST by hand (NodeStore + StringPool +
+// TypePool), invokes ComptimeEvaluator::eval, and asserts on the
+// returned ComptimeValue. No subprocess fork; the evaluator runs in
+// the test's address space.
+
+#include "../../src/ast_flat.h"
+#include "../../src/comptime.h"
+#include "test_framework.h"
+
+#include
+
+namespace {
+
+// Helper: emit a NumberLit AstNode for `n`.
+NodeIdx mkInt(NodeStore &ns, uint64_t n, bool isNeg = false) {
+ AstNode node{};
+ node.tag = AstTag::NumberLit;
+ node.lhs = static_cast(n & 0xFFFFFFFFu);
+ node.rhs = static_cast(n >> 32);
+ if (isNeg) node.flags |= 1;
+ return ns.addNode(node);
+}
+
+NodeIdx mkBool(NodeStore &ns, bool b) {
+ AstNode node{};
+ node.tag = AstTag::BoolLit;
+ node.lhs = b ? 1u : 0u;
+ return ns.addNode(node);
+}
+
+NodeIdx mkStr(NodeStore &ns, StringPool &sp, const std::string &s) {
+ AstNode node{};
+ node.tag = AstTag::StringLit;
+ node.lhs = static_cast(sp.intern(s));
+ return ns.addNode(node);
+}
+
+NodeIdx mkVar(NodeStore &ns, StringPool &sp, const std::string &name) {
+ AstNode node{};
+ node.tag = AstTag::Variable;
+ node.lhs = static_cast(sp.intern(name));
+ return ns.addNode(node);
+}
+
+NodeIdx mkBinOp(NodeStore &ns, BinOp op, NodeIdx l, NodeIdx r) {
+ AstNode node{};
+ node.tag = AstTag::BinaryOp;
+ node.op = static_cast(op);
+ node.lhs = static_cast(l);
+ node.rhs = static_cast(r);
+ return ns.addNode(node);
+}
+
+NodeIdx mkUnaryOp(NodeStore &ns, UnaryOp op, NodeIdx operand) {
+ AstNode node{};
+ node.tag = AstTag::UnaryOp;
+ node.op = static_cast(op);
+ node.lhs = static_cast(operand);
+ return ns.addNode(node);
+}
+
+NodeIdx mkIndex(NodeStore &ns, NodeIdx base, NodeIdx idx) {
+ AstNode node{};
+ node.tag = AstTag::Index;
+ node.lhs = static_cast(base);
+ node.rhs = static_cast(idx);
+ return ns.addNode(node);
+}
+
+// ── Literal folds ────────────────────────────────────────────────
+
+void testNumberLitFoldsToInt() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ NodeIdx n = mkInt(ns, 42);
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ jam::ComptimeValue v = e.eval(n, scope);
+ ASSERT_TRUE(v.isInt());
+ ASSERT_EQ(static_cast(42), v.asU64());
+}
+
+void testBoolLitFolds() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ NodeIdx t = mkBool(ns, true);
+ NodeIdx f = mkBool(ns, false);
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ jam::ComptimeValue tv = e.eval(t, scope);
+ jam::ComptimeValue fv = e.eval(f, scope);
+ ASSERT_TRUE(tv.isBool());
+ ASSERT_TRUE(tv.boolVal);
+ ASSERT_TRUE(fv.isBool());
+ ASSERT_FALSE(fv.boolVal);
+}
+
+void testStringLitFolds() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ NodeIdx s = mkStr(ns, sp, "hello");
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ jam::ComptimeValue v = e.eval(s, scope);
+ ASSERT_TRUE(v.isStr());
+ ASSERT_EQ(std::string("hello"), sp.get(v.strVal));
+}
+
+// ── Scope lookups ────────────────────────────────────────────────
+
+void testVariableLookupFromScope() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ NodeIdx v = mkVar(ns, sp, "x");
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ scope.bind("x", jam::ComptimeValue::makeInt(99, 32, true));
+ jam::ComptimeValue r = e.eval(v, scope);
+ ASSERT_TRUE(r.isInt());
+ ASSERT_EQ(static_cast(99), r.asU64());
+}
+
+void testVariableMissingReturnsNone() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ NodeIdx v = mkVar(ns, sp, "missing");
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ jam::ComptimeValue r = e.eval(v, scope);
+ ASSERT_TRUE(r.isNone());
+}
+
+void testNestedScopeFallsThroughToParent() {
+ jam::ComptimeScope parent;
+ parent.bind("x", jam::ComptimeValue::makeInt(7, 64, false));
+ jam::ComptimeScope child(&parent);
+ child.bind("y", jam::ComptimeValue::makeInt(11, 64, false));
+
+ const jam::ComptimeValue *x = child.lookup("x");
+ const jam::ComptimeValue *y = child.lookup("y");
+ const jam::ComptimeValue *z = child.lookup("z");
+ ASSERT_TRUE(x != nullptr);
+ ASSERT_EQ(static_cast(7), x->asU64());
+ ASSERT_TRUE(y != nullptr);
+ ASSERT_EQ(static_cast(11), y->asU64());
+ ASSERT_TRUE(z == nullptr);
+}
+
+// ── Arithmetic ───────────────────────────────────────────────────
+
+void testIntAddSubMul() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx a = mkInt(ns, 10);
+ NodeIdx b = mkInt(ns, 3);
+ ASSERT_EQ(static_cast(13),
+ e.eval(mkBinOp(ns, BinOp::Add, a, b), scope).asU64());
+ ASSERT_EQ(static_cast(7),
+ e.eval(mkBinOp(ns, BinOp::Sub, a, b), scope).asU64());
+ ASSERT_EQ(static_cast(30),
+ e.eval(mkBinOp(ns, BinOp::Mul, a, b), scope).asU64());
+}
+
+void testIntDivByZeroIsNone() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx a = mkInt(ns, 10);
+ NodeIdx b = mkInt(ns, 0);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Div, a, b), scope).isNone());
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Mod, a, b), scope).isNone());
+}
+
+void testIntComparisons() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx a = mkInt(ns, 5);
+ NodeIdx b = mkInt(ns, 7);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Lt, a, b), scope).boolVal);
+ ASSERT_FALSE(e.eval(mkBinOp(ns, BinOp::Gt, a, b), scope).boolVal);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Ne, a, b), scope).boolVal);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Eq, a, a), scope).boolVal);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Le, a, a), scope).boolVal);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Ge, b, a), scope).boolVal);
+}
+
+void testBitwise() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx a = mkInt(ns, 0b1100);
+ NodeIdx b = mkInt(ns, 0b1010);
+ ASSERT_EQ(static_cast(0b1000),
+ e.eval(mkBinOp(ns, BinOp::BitAnd, a, b), scope).asU64());
+ ASSERT_EQ(static_cast(0b1110),
+ e.eval(mkBinOp(ns, BinOp::BitOr, a, b), scope).asU64());
+ ASSERT_EQ(static_cast(0b0110),
+ e.eval(mkBinOp(ns, BinOp::BitXor, a, b), scope).asU64());
+}
+
+// ── Logical operators (short-circuit) ────────────────────────────
+
+void testLogAndShortCircuits() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx f = mkBool(ns, false);
+ // RHS references missing variable — would return None if eval'd.
+ NodeIdx missing = mkVar(ns, sp, "z");
+ jam::ComptimeValue v = e.eval(mkBinOp(ns, BinOp::LogAnd, f, missing), scope);
+ ASSERT_TRUE(v.isBool());
+ ASSERT_FALSE(v.boolVal); // LHS=false short-circuits, never touched RHS
+}
+
+void testLogOrShortCircuits() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx t = mkBool(ns, true);
+ NodeIdx missing = mkVar(ns, sp, "z");
+ jam::ComptimeValue v = e.eval(mkBinOp(ns, BinOp::LogOr, t, missing), scope);
+ ASSERT_TRUE(v.isBool());
+ ASSERT_TRUE(v.boolVal);
+}
+
+// ── Unary ────────────────────────────────────────────────────────
+
+void testUnaryNegateInt() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx five = mkInt(ns, 5);
+ jam::ComptimeValue v = e.eval(mkUnaryOp(ns, UnaryOp::Neg, five), scope);
+ ASSERT_TRUE(v.isInt());
+ // asI64 must sign-extend from the value's width.
+ ASSERT_EQ(static_cast(-5), v.asI64());
+}
+
+void testUnaryLogNotBool() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx t = mkBool(ns, true);
+ jam::ComptimeValue v = e.eval(mkUnaryOp(ns, UnaryOp::LogNot, t), scope);
+ ASSERT_TRUE(v.isBool());
+ ASSERT_FALSE(v.boolVal);
+}
+
+// ── String indexing ──────────────────────────────────────────────
+
+void testStringIndexReturnsByte() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx s = mkStr(ns, sp, "abc");
+ NodeIdx i = mkInt(ns, 1);
+ jam::ComptimeValue v = e.eval(mkIndex(ns, s, i), scope);
+ ASSERT_TRUE(v.isInt());
+ ASSERT_EQ(static_cast('b'), v.asU64());
+}
+
+void testStringIndexOutOfBoundsIsNone() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx s = mkStr(ns, sp, "abc");
+ NodeIdx i = mkInt(ns, 99);
+ jam::ComptimeValue v = e.eval(mkIndex(ns, s, i), scope);
+ ASSERT_TRUE(v.isNone());
+}
+
+// ── Type equality (the @TypeOf-comparison primitive) ────────────
+
+void testTypeEquality() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ scope.bind("T", jam::ComptimeValue::makeType(BuiltinType::I32));
+ scope.bind("U", jam::ComptimeValue::makeType(BuiltinType::I32));
+ scope.bind("V", jam::ComptimeValue::makeType(BuiltinType::F32));
+
+ NodeIdx t = mkVar(ns, sp, "T");
+ NodeIdx u = mkVar(ns, sp, "U");
+ NodeIdx v = mkVar(ns, sp, "V");
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Eq, t, u), scope).boolVal);
+ ASSERT_FALSE(e.eval(mkBinOp(ns, BinOp::Eq, t, v), scope).boolVal);
+ ASSERT_TRUE(e.eval(mkBinOp(ns, BinOp::Ne, t, v), scope).boolVal);
+}
+
+// ── Aggregate indexing (for tuples in Phase 4) ──────────────────
+
+void testAggregateIndexing() {
+ std::vector fields;
+ fields.push_back(jam::ComptimeValue::makeInt(10, 32, true));
+ fields.push_back(jam::ComptimeValue::makeInt(20, 32, true));
+ fields.push_back(jam::ComptimeValue::makeInt(30, 32, true));
+ jam::ComptimeValue agg = jam::ComptimeValue::makeAggregate(std::move(fields));
+
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ scope.bind("t", agg);
+
+ NodeIdx t = mkVar(ns, sp, "t");
+ NodeIdx i = mkInt(ns, 1);
+ jam::ComptimeValue v = e.eval(mkIndex(ns, t, i), scope);
+ ASSERT_TRUE(v.isInt());
+ ASSERT_EQ(static_cast(20), v.asU64());
+}
+
+// ── Required-fold diagnostics ───────────────────────────────────
+
+void testEvalRequiredPushesDiagnosticOnFail() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx miss = mkVar(ns, sp, "missing");
+ jam::Diagnostics diags;
+ jam::SrcLoc loc{"test.jam", 1};
+ jam::ComptimeValue v = e.evalRequired(miss, scope, diags, loc);
+ ASSERT_TRUE(v.isNone());
+ ASSERT_TRUE(diags.hasErrors());
+ ASSERT_EQ(static_cast(1), diags.errorCount());
+}
+
+void testEvalRequiredSilentOnSuccess() {
+ NodeStore ns;
+ StringPool sp;
+ TypePool tp;
+ jam::ComptimeEvaluator e(ns, sp, tp);
+ jam::ComptimeScope scope;
+ NodeIdx n = mkInt(ns, 42);
+ jam::Diagnostics diags;
+ jam::SrcLoc loc{"test.jam", 1};
+ jam::ComptimeValue v = e.evalRequired(n, scope, diags, loc);
+ ASSERT_TRUE(v.isInt());
+ ASSERT_FALSE(diags.hasErrors());
+}
+
+} // namespace
+
+int main() {
+ TestFramework framework;
+ framework.addTest("Comptime - NumberLit folds to Int", testNumberLitFoldsToInt);
+ framework.addTest("Comptime - BoolLit folds", testBoolLitFolds);
+ framework.addTest("Comptime - StringLit folds", testStringLitFolds);
+ framework.addTest("Comptime - Variable lookup from scope",
+ testVariableLookupFromScope);
+ framework.addTest("Comptime - missing variable returns None",
+ testVariableMissingReturnsNone);
+ framework.addTest("Comptime - nested scope falls through to parent",
+ testNestedScopeFallsThroughToParent);
+ framework.addTest("Comptime - int add/sub/mul", testIntAddSubMul);
+ framework.addTest("Comptime - int div-by-zero returns None",
+ testIntDivByZeroIsNone);
+ framework.addTest("Comptime - int comparisons", testIntComparisons);
+ framework.addTest("Comptime - bitwise and/or/xor", testBitwise);
+ framework.addTest("Comptime - logical and short-circuits",
+ testLogAndShortCircuits);
+ framework.addTest("Comptime - logical or short-circuits",
+ testLogOrShortCircuits);
+ framework.addTest("Comptime - unary negate int", testUnaryNegateInt);
+ framework.addTest("Comptime - unary log-not bool", testUnaryLogNotBool);
+ framework.addTest("Comptime - string index returns byte",
+ testStringIndexReturnsByte);
+ framework.addTest("Comptime - string index out-of-bounds None",
+ testStringIndexOutOfBoundsIsNone);
+ framework.addTest("Comptime - type equality", testTypeEquality);
+ framework.addTest("Comptime - aggregate indexing",
+ testAggregateIndexing);
+ framework.addTest(
+ "Comptime - evalRequired pushes diagnostic on fail",
+ testEvalRequiredPushesDiagnosticOnFail);
+ framework.addTest("Comptime - evalRequired silent on success",
+ testEvalRequiredSilentOnSuccess);
+ framework.runAll();
+ return framework.allPassed() ? 0 : 1;
+}
diff --git a/tests/unit/mod_cross_a.jam b/tests/unit/mod_cross_a.jam
new file mode 100644
index 0000000..c6d3426
--- /dev/null
+++ b/tests/unit/mod_cross_a.jam
@@ -0,0 +1,24 @@
+// Helper module for test_cross_module_mangling.jam — defines a
+// `Counter` struct with `init` and `get` and a free `helper` fn,
+// intentionally same-named with mod_cross_b.jam to exercise the
+// module-prefixed mangling (`mod_cross_a.Counter.init` vs
+// `mod_cross_b.Counter.init`). Standalone compile uses the no-op
+// `main` below so the test runner can pick this file up too.
+
+pub const Counter = struct {
+ value: u32,
+
+ pub fn init() Self {
+ return Self { value: 100 };
+ }
+
+ pub fn get(self: mut Self) u32 {
+ return self.value;
+ }
+};
+
+pub fn helper() u32 {
+ return 1;
+}
+
+fn main() u8 { return 0; }
diff --git a/tests/unit/mod_cross_b.jam b/tests/unit/mod_cross_b.jam
new file mode 100644
index 0000000..4c4579b
--- /dev/null
+++ b/tests/unit/mod_cross_b.jam
@@ -0,0 +1,22 @@
+// Sibling of mod_cross_a.jam — same surface API, different bodies.
+// The point is that the LLVM symbol for `Counter.init` from a and b
+// must NOT collide; module-prefixed mangling makes them
+// `mod_cross_a.Counter.init` vs `mod_cross_b.Counter.init`.
+
+pub const Counter = struct {
+ value: u32,
+
+ pub fn init() Self {
+ return Self { value: 200 };
+ }
+
+ pub fn get(self: mut Self) u32 {
+ return self.value;
+ }
+};
+
+pub fn helper() u32 {
+ return 2;
+}
+
+fn main() u8 { return 0; }
diff --git a/tests/unit/test_cross_module_mangling.jam b/tests/unit/test_cross_module_mangling.jam
new file mode 100644
index 0000000..e21333c
--- /dev/null
+++ b/tests/unit/test_cross_module_mangling.jam
@@ -0,0 +1,32 @@
+// Cross-module symbol uniqueness — imports two sibling modules
+// (mod_cross_a, mod_cross_b) that each define `pub fn helper()` and
+// `pub const Counter = struct { pub fn init() / pub fn get() }`. The
+// LLVM symbols must differ across modules; before the modulePath
+// stamping landed, both `helper`s and both `Counter.init`s collapsed
+// to the same symbol and the linker silently merged them.
+//
+// Lookup mirrors Zig's namespace-scoped resolution (Sema.zig:5295):
+// methods on imported structs live under the importing handle's
+// namespace, not in a flat global table — registered as
+// `handle.Struct.method` and resolved via the multi-dot path in
+// astgenCall.
+
+const { assert } = import("test");
+const a = import("mod_cross_a");
+const b = import("mod_cross_b");
+
+tfn freeFnsFromDifferentModulesNoClobber() {
+ assert(a.helper(), 1);
+ assert(b.helper(), 2);
+}
+
+tfn methodsOnSameNamedImportedStructsNoClobber() {
+ // Both modules export a `Counter` with `init()` returning a
+ // module-specific value. Resolution goes via the
+ // `handle.Struct.method` registry; mangling produces
+ // `mod_cross_a.Counter.init` vs `mod_cross_b.Counter.init`.
+ var ca: a.Counter = a.Counter.init();
+ var cb: b.Counter = b.Counter.init();
+ assert(ca.get(), 100);
+ assert(cb.get(), 200);
+}
diff --git a/tests/unit/test_drops.jam b/tests/unit/test_drops.jam
index f9d915f..9170fce 100644
--- a/tests/unit/test_drops.jam
+++ b/tests/unit/test_drops.jam
@@ -10,7 +10,7 @@ const Counter = struct {
sink: *mut u32,
};
-// `fn drop(self: mut Counter)` is the user-defined destructor. When a
+// `cfn drop(self: mut Counter)` is the user-defined destructor. When a
// `var c: Counter = ...;` goes out of scope, the codegen synthesizes a
// call to drop(&c). We bump the sink-pointed value so we can observe it
// from the test harness.
diff --git a/tests/unit/test_drops_mangling.jam b/tests/unit/test_drops_mangling.jam
index a18b5b0..eafb57f 100644
--- a/tests/unit/test_drops_mangling.jam
+++ b/tests/unit/test_drops_mangling.jam
@@ -1,6 +1,6 @@
const { assert } = import("test");
-// Two `fn drop(self: mut T)` for different types coexist
+// Two `cfn drop(self: mut T)` for different types coexist
// because the codegen mangles each to `__drop_` at the LLVM
// level. Each fires for its own type at scope exit.
diff --git a/tests/unit/test_struct_methods.jam b/tests/unit/test_struct_methods.jam
index 195c458..3aa1567 100644
--- a/tests/unit/test_struct_methods.jam
+++ b/tests/unit/test_struct_methods.jam
@@ -1,7 +1,7 @@
const { assert } = import("test");
// Drop functions can be declared inside the struct body. The form is a
-// pure synonym of the free-function `fn drop(self: mut T)`: the drop
+// pure synonym of the free-function `cfn drop(self: mut T)`: the drop
// registry picks it up the same way, codegen synthesizes the same
// `__drop_T` call at scope exit, the side-effect order is identical.
diff --git a/tests/unit/test_struct_pub_methods.jam b/tests/unit/test_struct_pub_methods.jam
new file mode 100644
index 0000000..b9a01db
--- /dev/null
+++ b/tests/unit/test_struct_pub_methods.jam
@@ -0,0 +1,99 @@
+const { assert } = import("test");
+
+// Struct methods can carry the same visibility modifiers as free
+// functions: `pub fn` exports the method across module boundaries,
+// plain `fn` keeps it private to the defining module.
+//
+// In a single-file unit test we can't directly exercise the
+// cross-module visibility check (no imports), but we *can* verify:
+// - `pub fn` parses inside a struct body
+// - `pub fn` methods are callable as instance methods and as
+// qualified static calls
+// - `pub fn init()` works as a constructor convention
+// - a struct may freely mix `pub fn`, plain `fn`, and `cfn` in any
+// order without confusing the parser
+
+const Counter = struct {
+ value: u32,
+
+ pub fn init() Self {
+ return Self { value: 0 };
+ }
+
+ pub fn bump(self: mut Self) {
+ self.value = self.value + 1;
+ }
+
+ pub fn get(self: mut Self) u32 {
+ return self.value;
+ }
+
+ // Non-pub method — should still be callable from within this
+ // module. (Visibility only gates cross-module callers.)
+ fn secret(self: mut Self) u32 {
+ return self.value * 2;
+ }
+};
+
+tfn pubFnInitReturnsFreshInstance() {
+ var c: Counter = Counter.init();
+ assert(c.value, 0);
+}
+
+tfn pubFnBumpMutatesReceiver() {
+ var c: Counter = Counter.init();
+ c.bump();
+ c.bump();
+ c.bump();
+ assert(c.value, 3);
+}
+
+tfn pubFnGetReturnsField() {
+ var c: Counter = Counter { value: 42 };
+ assert(c.get(), 42);
+}
+
+tfn nonPubFnStillCallableInModule() {
+ var c: Counter = Counter { value: 7 };
+ assert(c.secret(), 14);
+}
+
+// Mix of `pub fn`, plain `fn`, and `cfn` in one struct — proves the
+// parser accepts the modifiers in any order and codegen wires up the
+// right symbol for each.
+const Mixed = struct {
+ n: u32,
+ sink: *mut u32,
+
+ pub fn init(sink: *mut u32) Self {
+ return Self { n: 100, sink: sink };
+ }
+
+ pub fn add(self: mut Self, x: u32) {
+ self.n = self.n + x;
+ }
+
+ fn quiet(self: mut Self) u32 {
+ return self.n + 1;
+ }
+
+ cfn drop(self: mut Self) {
+ var p: *mut u32 = self.sink;
+ p.* = p.* + 1;
+ }
+};
+
+fn dropsOnce(sink: *mut u32) u32 {
+ var m: Mixed = Mixed.init(sink);
+ m.add(5);
+ return m.quiet();
+}
+
+tfn mixedModifiersCoexist() {
+ var hits: u32 = 0;
+ var n: u32 = dropsOnce(&hits);
+ // 100 + 5 = 105; quiet returns n+1 = 106.
+ assert(n, 106);
+ // Drop fired once at scope exit.
+ assert(hits, 1);
+}