diff --git a/Makefile b/Makefile
index dc4fb0a..d887d81 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build install uninstall clean cmake-build cmake-install cmake-uninstall test test-release test-unit test-unit-release docs format check-format
+.PHONY: build check-llvm install uninstall clean cmake-build cmake-install cmake-uninstall test test-release test-unit test-unit-release test-init test-abi test-codegen-errors test-jir test-diagnostics test-decl test-analyzer test-comptime test-print docs format check-format fmt info
.DEFAULT_GOAL := build
LLVM_CONFIG=$(shell which llvm-config 2>/dev/null || echo "llvm-config")
@@ -52,20 +52,52 @@ BINDIR ?= $(PREFIX)/bin
LIBDIR ?= $(PREFIX)/lib
STDDIR ?= $(LIBDIR)/jam/std
-build:
+# Incremental build: real per-object rules with compiler-generated
+# header dependencies (-MMD), so an unchanged file is never recompiled
+# and `make -j8` parallelizes the LLVM-header TUs. The old recipe was a
+# serial shell for-loop that rebuilt all 19 objects on every invocation
+# — ~14s of pure waste per `make test` with no changes.
+LLVM_CXXFLAGS := $(shell $(LLVM_CONFIG) --cxxflags 2>/dev/null)
+LLVM_LDFLAGS := $(shell $(LLVM_CONFIG) --ldflags --libs --libfiles --system-libs 2>/dev/null)
+
+# Version stamp: main.o bakes JAM_VERSION_SHA, which flips whenever the
+# worktree dirty-state changes. Touch the stamp file only when the SHA
+# actually differs so a flip rebuilds main.o alone (not all 19 TUs),
+# and an unchanged SHA rebuilds nothing.
+VERSION_STAMP := $(OUT)/.version_sha
+$(shell mkdir -p $(OUT))
+$(shell [ "`cat $(VERSION_STAMP) 2>/dev/null`" = "$(JAM_VERSION_SHA)" ] || echo "$(JAM_VERSION_SHA)" > $(VERSION_STAMP))
+
+check-llvm:
@if ! command -v $(LLVM_CONFIG) >/dev/null 2>&1; then \
echo "error: llvm-config not found."; \
exit 1; \
fi
+
+$(OUT):
@mkdir -p $(OUT)
- @for name in $(SRC_NAMES); do \
- echo " CC: src/$$name.cpp -> $(OUT)/$$name.o"; \
- extra=""; \
- if [ "$$name" = "main" ]; then extra="$(VERSION_FLAGS)"; fi; \
- clang++ -c ./src/$$name.cpp -o $(OUT)/$$name.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS) $$extra || exit $$?; \
- done
+
+$(OUT)/%.o: src/%.cpp | $(OUT)
+ @echo " CC: $< -> $@"
+ @clang++ -c $< -o $@ $(LLVM_CXXFLAGS) -fexceptions $(OPTFLAGS) -MMD -MP $(EXTRA_CXXFLAGS)
+
+$(OUT)/main.o: EXTRA_CXXFLAGS := $(VERSION_FLAGS)
+$(OUT)/main.o: $(VERSION_STAMP)
+
+$(OUT)/jam.out: $(OBJS)
@echo " LD: $(OUT)/jam.out"
- @clang++ -o $(OUT)/jam.out $(OBJS) `$(LLVM_CONFIG) --ldflags --libs --libfiles --system-libs`
+ @clang++ -o $(OUT)/jam.out $(OBJS) $(LLVM_LDFLAGS)
+
+build: check-llvm $(OUT)/jam.out
+
+# C++ test objects share the pattern-rule + depfile treatment so
+# unchanged test TUs (each compiles LLVM headers at -O2, multiple
+# seconds) are skipped too.
+$(OUT)/test_%.o: tests/cpp/test_%.cpp | $(OUT)
+ @echo " CC: $< -> $@"
+ @clang++ -c $< -o $@ $(LLVM_CXXFLAGS) -fexceptions $(OPTFLAGS) -MMD -MP
+
+-include $(wildcard $(OUT)/*.d)
cmake-build:
@echo "Building with CMake..."
@@ -140,63 +172,89 @@ test-unit-release: build
@echo "Running Jam unit tests (release, -C opt-level=3)..."
$(OUT)/jam.out -C opt-level=3 test tests/unit
-define CXX_TEST_TARGET
- @echo ""
- @echo "Building and running $(1) C++ tests..."
- @clang++ -c ./tests/cpp/test_$(1).cpp -o $(OUT)/test_$(1).o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/$(1)_tests $(OUT)/test_$(1).o $(2) `$(LLVM_CONFIG) --ldflags --libs --libfiles --system-libs`
- @$(OUT)/$(1)_tests
-endef
+# Test binaries are real file targets: an unchanged test TU (each
+# compiles LLVM headers at -O2 — seconds apiece) is never recompiled,
+# and an unchanged binary is never relinked. The phony test-* targets
+# just run them.
+$(OUT)/init_analysis_tests: $(OUT)/test_init_analysis.o $(LIB_OBJS)
+ @echo " LD: $@"
+ @clang++ -o $@ $^ $(LLVM_LDFLAGS)
+
+$(OUT)/abi_tests: $(OUT)/test_abi.o $(LIB_OBJS)
+ @echo " LD: $@"
+ @clang++ -o $@ $^ $(LLVM_LDFLAGS)
+
+$(OUT)/analyzer_tests: $(OUT)/test_analyzer.o $(LIB_OBJS)
+ @echo " LD: $@"
+ @clang++ -o $@ $^ $(LLVM_LDFLAGS)
-test-init: build
- $(call CXX_TEST_TARGET,init_analysis,$(LIB_OBJS))
+$(OUT)/codegen_error_tests: $(OUT)/test_codegen_errors.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^
-test-abi: build
- $(call CXX_TEST_TARGET,abi,$(LIB_OBJS))
+$(OUT)/jir_tests: $(OUT)/test_jir_skeleton.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^
-test-codegen-errors: build
+$(OUT)/diagnostic_tests: $(OUT)/test_diagnostics.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^
+
+$(OUT)/decl_tests: $(OUT)/test_decl_table.o $(OUT)/decl.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^
+
+$(OUT)/comptime_tests: $(OUT)/test_comptime.o $(OUT)/comptime.o $(OUT)/diagnostics.o $(OUT)/jam_llvm.o $(OUT)/target.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^ $(LLVM_LDFLAGS)
+
+$(OUT)/print_tests: $(OUT)/test_print.o
+ @echo " LD: $@"
+ @clang++ -o $@ $^
+
+test-init: build $(OUT)/init_analysis_tests
+ @echo ""
+ @echo "Building and running init_analysis C++ tests..."
+ @$(OUT)/init_analysis_tests
+
+test-abi: build $(OUT)/abi_tests
+ @echo ""
+ @echo "Building and running abi C++ tests..."
+ @$(OUT)/abi_tests
+
+test-codegen-errors: build $(OUT)/codegen_error_tests
@echo ""
@echo "Building and running codegen-error C++ tests..."
- @clang++ -c ./tests/cpp/test_codegen_errors.cpp -o $(OUT)/test_codegen_errors.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/codegen_error_tests $(OUT)/test_codegen_errors.o
@$(OUT)/codegen_error_tests
-test-jir: build
+test-jir: build $(OUT)/jir_tests
@echo ""
@echo "Building and running JIR C++ tests..."
- @clang++ -c ./tests/cpp/test_jir_skeleton.cpp -o $(OUT)/test_jir_skeleton.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/jir_tests $(OUT)/test_jir_skeleton.o
@$(OUT)/jir_tests
-test-diagnostics: build
+test-diagnostics: build $(OUT)/diagnostic_tests
@echo ""
@echo "Building and running diagnostic-pipeline tests..."
- @clang++ -c ./tests/cpp/test_diagnostics.cpp -o $(OUT)/test_diagnostics.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/diagnostic_tests $(OUT)/test_diagnostics.o
@$(OUT)/diagnostic_tests
-test-decl: build
+test-decl: build $(OUT)/decl_tests
@echo ""
@echo "Building and running DeclTable C++ tests..."
- @clang++ -c ./tests/cpp/test_decl_table.cpp -o $(OUT)/test_decl_table.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/decl_tests $(OUT)/test_decl_table.o $(OUT)/decl.o
@$(OUT)/decl_tests
-test-analyzer: build
- $(call CXX_TEST_TARGET,analyzer,$(LIB_OBJS))
+test-analyzer: build $(OUT)/analyzer_tests
+ @echo ""
+ @echo "Building and running analyzer C++ tests..."
+ @$(OUT)/analyzer_tests
-test-comptime: build
+test-comptime: build $(OUT)/comptime_tests
@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)/jam_llvm.o $(OUT)/target.o `$(LLVM_CONFIG) --ldflags --libs --libfiles --system-libs`
@$(OUT)/comptime_tests
-test-print: build
+test-print: build $(OUT)/print_tests
@echo ""
@echo "Building and running @-emit cfn-print end-to-end tests..."
- @clang++ -c ./tests/cpp/test_print.cpp -o $(OUT)/test_print.o `$(LLVM_CONFIG) --cxxflags` -fexceptions $(OPTFLAGS)
- @clang++ -o $(OUT)/print_tests $(OUT)/test_print.o
@$(OUT)/print_tests
test: test-unit test-init test-abi test-codegen-errors test-jir test-diagnostics test-decl test-analyzer test-comptime test-print
diff --git a/src/ast.h b/src/ast.h
index b235ff9..186bdad 100644
--- a/src/ast.h
+++ b/src/ast.h
@@ -95,6 +95,12 @@ class FunctionAST {
// methods or free fns in different modules don't collide.
std::string modulePath;
+ // Memoized result of `mangledFunctionName` — otherwise the symbol
+ // string is rebuilt per call expression and per drop lookup. Its
+ // inputs (Name, parentStruct, modulePath, isTest, drop-receiver
+ // type) are all fixed before the first mangling.
+ mutable std::string mangledNameCache;
+
FunctionAST(std::string Name, std::vector Args, TypeIdx ReturnType,
std::vector Body, bool isExtern = false,
bool isExport = false, bool isPub = false, bool isTest = false,
diff --git a/src/astgen.cpp b/src/astgen.cpp
index 913c1f1..dccf8bb 100644
--- a/src/astgen.cpp
+++ b/src/astgen.cpp
@@ -382,6 +382,7 @@ static void emitDrops(AstGenCtx &gctx, const std::vector &bindings);
// emitDrops + astgenVarDecl can call them.
static void emitDropInPlace(AstGenCtx &gctx, JirRef ptrRef, TypeIdx pointeeTy);
bool typeNeedsDropInner(JamCodegenContext &ctx, TypeIdx ty);
+static bool typeNeedsDropResolved(JamCodegenContext &ctx, TypeIdx ty);
static void emitEnumPayloadDrops(AstGenCtx &gctx, JirRef ptrRef,
const JamCodegenContext::EnumInfo *einfo);
static void rejectDropBearingFieldExtract(AstGenCtx &gctx, NodeIdx exprIdx,
@@ -4653,7 +4654,26 @@ static TypeIdx resolveGenericIfAny(JamCodegenContext &ctx, TypeIdx ty) {
// auto-drop its pointee; if the user wants that, they own a Box/Vec
// whose own `cfn drop` handles deallocation).
bool typeNeedsDropInner(JamCodegenContext &ctx, TypeIdx ty) {
+ // Canonicalize, then memoize per TypeIdx: this is queried per
+ // expression (var inits, struct-literal captures, scope exits, the
+ // analyzer's per-return drop checks) and each uncached query runs
+ // the full string cascade (mangled-name build, registry finds,
+ // alias chase) plus a recursive field walk. Subst contexts bypass
+ // the memo — a `T` field's verdict is per-instantiation.
ty = resolveGenericIfAny(ctx, ty);
+ ty = ctx.requalifyType(ty, ctx.currentBodyModule());
+ bool cacheable = !ctx.hasActiveSubst();
+ if (cacheable) {
+ int8_t m = ctx.dropMemoLookup(ty);
+ if (m >= 0) return m != 0;
+ }
+ bool r = typeNeedsDropResolved(ctx, ty);
+ if (cacheable) ctx.dropMemoStore(ty, r);
+ return r;
+}
+
+// The uncached walk; `ty` is already generic-resolved and requalified.
+static bool typeNeedsDropResolved(JamCodegenContext &ctx, TypeIdx ty) {
if (!lookupDropFnLLVMName(ctx, ty).empty()) return true;
const TypeKey &tk = ctx.getTypePool().get(ty);
// A fixed-size array owns its elements: it needs drop iff the
diff --git a/src/codegen.cpp b/src/codegen.cpp
index 19f13e0..f494de5 100644
--- a/src/codegen.cpp
+++ b/src/codegen.cpp
@@ -338,11 +338,59 @@ void JamCodegenContext::registerTypeOwner(const std::string &ctxModule,
const std::string &typeName,
const std::string &ownerModule) {
typeModuleOf_[ctxModule][typeName] = ownerModule;
+ // Ownership feeds every memoized resolution below; registration
+ // only happens during the up-front pass, so these clears are
+ // effectively free.
+ requalifyMemo_.clear();
+ dropMemo_.clear();
+ sizeMemo_.clear();
+ alignMemo_.clear();
+}
+
+uint32_t JamCodegenContext::moduleIdOf(const std::string &m) const {
+ auto it = moduleIds_.find(m);
+ if (it != moduleIds_.end()) return it->second;
+ uint32_t id = static_cast(moduleIds_.size()) + 1;
+ moduleIds_.emplace(m, id);
+ return id;
}
TypeIdx JamCodegenContext::requalifyType(TypeIdx ty,
const std::string &ctxModule) const {
if (ty == kNoType) return ty;
+ // Kinds that can never carry a user-type name skip everything —
+ // the common case for primitive-typed expressions.
+ switch (typePool.get(ty).kind) {
+ case TypeKind::Named:
+ case TypeKind::Struct:
+ case TypeKind::Enum:
+ case TypeKind::Union:
+ case TypeKind::PtrSingle:
+ case TypeKind::PtrMany:
+ case TypeKind::Slice:
+ case TypeKind::Array:
+ case TypeKind::ArrayExpr:
+ case TypeKind::GenericCall:
+ break;
+ default:
+ return ty;
+ }
+ // Subst contexts change which names stay un-qualified — compute
+ // fresh there (bounded: only generic-instantiation lowering).
+ if (!currentSubst_.empty()) return requalifyTypeUncached(ty, ctxModule);
+ uint64_t key = (static_cast(moduleIdOf(ctxModule)) << 32) |
+ static_cast(ty);
+ auto it = requalifyMemo_.find(key);
+ if (it != requalifyMemo_.end()) return it->second;
+ TypeIdx r = requalifyTypeUncached(ty, ctxModule);
+ requalifyMemo_.emplace(key, r);
+ return r;
+}
+
+TypeIdx
+JamCodegenContext::requalifyTypeUncached(TypeIdx ty,
+ const std::string &ctxModule) const {
+ if (ty == kNoType) return ty;
// By value, not by reference: the recursive calls below intern new
// types, which can reallocate the TypePool's storage and dangle a
// reference into it.
@@ -363,8 +411,10 @@ TypeIdx JamCodegenContext::requalifyType(TypeIdx ty,
if (mIt == typeModuleOf_.end()) return ty;
auto tIt = mIt->second.find(name);
if (tIt == mIt->second.end()) return ty; // std / generic param / Self
+ // Entry-module owner: the qualified name IS the bare name —
+ // skip the string build entirely.
+ if (tIt->second.empty()) return ty;
std::string q = qualifyTypeName(tIt->second, name);
- if (q == name) return ty; // owner is the entry module — stays bare
TypeKey nk = k;
nk.a = static_cast(stringPool.intern(q));
return typePool.intern(nk);
@@ -407,12 +457,10 @@ TypeIdx JamCodegenContext::requalifyType(TypeIdx ty,
auto mIt = typeModuleOf_.find(ctxModule);
if (mIt != typeModuleOf_.end()) {
auto tIt = mIt->second.find(callee);
- if (tIt != mIt->second.end()) {
- std::string q = qualifyTypeName(tIt->second, callee);
- if (q != callee) {
- nameId = stringPool.intern(q);
- changed = true;
- }
+ if (tIt != mIt->second.end() && !tIt->second.empty()) {
+ nameId = stringPool.intern(
+ qualifyTypeName(tIt->second, callee));
+ changed = true;
}
}
}
@@ -575,6 +623,8 @@ void JamCodegenContext::registerModuleConst(const std::string &name,
bool isComp, std::string file) {
moduleConsts[name] = ModuleConstInfo{
init, declared, isComp, std::move(file), bareName, owner};
+ // The seeded-scope cache derives from the const set.
+ comptimeSeedCache_.clear();
}
void JamCodegenContext::validateCompConsts() {
@@ -817,6 +867,21 @@ inline uint64_t alignUp(uint64_t off, uint64_t align) {
uint64_t JamCodegenContext::typeSize(TypeIdx ty) const {
ty = requalifyType(ty, currentBodyModule());
+ // Layout never changes for a concrete requalified type, and the
+ // recursive aggregate walk below re-asks for every field on every
+ // query — memoize per TypeIdx. Subst contexts bypass (a `T` field
+ // sizes differently per instantiation).
+ bool cacheable = currentSubst_.empty();
+ if (cacheable) {
+ auto it = sizeMemo_.find(ty);
+ if (it != sizeMemo_.end()) return it->second;
+ }
+ uint64_t r = typeSizeImpl(ty);
+ if (cacheable) sizeMemo_.emplace(ty, r);
+ return r;
+}
+
+uint64_t JamCodegenContext::typeSizeImpl(TypeIdx ty) const {
const TypeKey &k = typePool.get(ty);
switch (k.kind) {
case TypeKind::Invalid:
@@ -942,6 +1007,17 @@ uint64_t JamCodegenContext::typeSize(TypeIdx ty) const {
// max of the constituent alignments.
uint64_t JamCodegenContext::typeAlign(TypeIdx ty) const {
ty = requalifyType(ty, currentBodyModule());
+ bool cacheable = currentSubst_.empty();
+ if (cacheable) {
+ auto it = alignMemo_.find(ty);
+ if (it != alignMemo_.end()) return it->second;
+ }
+ uint64_t r = typeAlignImpl(ty);
+ if (cacheable) alignMemo_.emplace(ty, r);
+ return r;
+}
+
+uint64_t JamCodegenContext::typeAlignImpl(TypeIdx ty) const {
const TypeKey &k = typePool.get(ty);
switch (k.kind) {
case TypeKind::Invalid:
@@ -1346,31 +1422,43 @@ void JamCodegenContext::seedComptimeScope(jam::ComptimeScope &scope) const {
// consts whose dependencies folded in an earlier pass. Consts that
// never fold (runtime-only inits) simply stay unbound — an
// expression referencing one comes back None.
- jam::ComptimeEvaluator ev(nodeStore, stringPool, typePool);
+ //
+ // The fixpoint re-evaluates const initializer ASTs and used to run
+ // for EVERY function body and every comptime fold — cache the
+ // seeded scope per module (consts are immutable once registered;
+ // registerModuleConst clears the cache).
const std::string &cur = currentBodyModule();
- auto ownIt = typeModuleOf_.find(cur);
- auto visible = [&](const ModuleConstInfo &info) -> bool {
- if (ownIt != typeModuleOf_.end()) {
- auto oIt = ownIt->second.find(info.bareName);
- if (oIt != ownIt->second.end()) return oIt->second == info.owner;
- }
- // Entry bodies additionally see entry consts that predate the
- // ownership map (bare-registered, owner "").
- return cur.empty() && info.owner.empty();
- };
- bool progress = true;
- while (progress) {
- progress = false;
- for (const auto &kv : moduleConsts) {
- if (!visible(kv.second)) continue;
- if (scope.lookup(kv.second.bareName) != nullptr) continue;
- jam::ComptimeValue v = ev.eval(kv.second.initExpr, scope);
- if (!v.isNone()) {
- scope.bind(kv.second.bareName, v);
- progress = true;
+ auto cacheIt = comptimeSeedCache_.find(cur);
+ if (cacheIt == comptimeSeedCache_.end()) {
+ jam::ComptimeScope seeded;
+ jam::ComptimeEvaluator ev(nodeStore, stringPool, typePool);
+ auto ownIt = typeModuleOf_.find(cur);
+ auto visible = [&](const ModuleConstInfo &info) -> bool {
+ if (ownIt != typeModuleOf_.end()) {
+ auto oIt = ownIt->second.find(info.bareName);
+ if (oIt != ownIt->second.end())
+ return oIt->second == info.owner;
+ }
+ // Entry bodies additionally see entry consts that predate
+ // the ownership map (bare-registered, owner "").
+ return cur.empty() && info.owner.empty();
+ };
+ bool progress = true;
+ while (progress) {
+ progress = false;
+ for (const auto &kv : moduleConsts) {
+ if (!visible(kv.second)) continue;
+ if (seeded.lookup(kv.second.bareName) != nullptr) continue;
+ jam::ComptimeValue v = ev.eval(kv.second.initExpr, seeded);
+ if (!v.isNone()) {
+ seeded.bind(kv.second.bareName, v);
+ progress = true;
+ }
}
}
+ cacheIt = comptimeSeedCache_.emplace(cur, std::move(seeded)).first;
}
+ cacheIt->second.copyBindingsInto(scope);
// Active comp-param substitutions shadow same-named module consts —
// the same precedence astgenVariable applies (comp subst is checked
// before getModuleConst). Bound last so they overwrite.
diff --git a/src/codegen.h b/src/codegen.h
index 67ec3bc..f985364 100644
--- a/src/codegen.h
+++ b/src/codegen.h
@@ -118,8 +118,33 @@ class JamCodegenContext {
// known owner (std, generic params, `Self`, builtins) are left as-is.
// This is what gives same-named types in different modules distinct
// TypeIdxs, which the per-TypeIdx LLVM-type cache relies on.
+ //
+ // Memoized per (module, TypeIdx): this runs on every resolution
+ // chokepoint (getLLVMType, lookupStruct, typeSize/typeAlign per
+ // field per nesting level), so the string walk must happen once per
+ // type per module, not once per query. Bypassed while a generic
+ // substitution context is active — subst names change the outcome.
TypeIdx requalifyType(TypeIdx ty, const std::string &ctxModule) const;
+ // True while a generic instantiation's substitution map is active.
+ // Resolution results computed under a subst are context-dependent,
+ // so the memo layers (requalify, drop, size/align) bypass
+ // themselves while this holds.
+ bool hasActiveSubst() const { return !currentSubst_.empty(); }
+
+ // typeNeedsDrop memo, keyed by the requalified TypeIdx. Returns -1
+ // unknown, 0 no-drop, 1 drop-bearing. A concrete type's verdict
+ // never changes once computed (drop registries only gain entries
+ // at instantiation, before the first query for that type).
+ int8_t dropMemoLookup(TypeIdx ty) const {
+ auto it = dropMemo_.find(ty);
+ return it == dropMemo_.end() ? int8_t{-1}
+ : static_cast(it->second);
+ }
+ void dropMemoStore(TypeIdx ty, bool needsDrop) const {
+ dropMemo_[ty] = needsDrop ? 1 : 0;
+ }
+
// Union registry. Untagged unions: every field shares the same
// address. UnionInfo carries the union's LLVM storage type plus the
// list of (fieldName, fieldType) pairs for member-access lookup.
@@ -368,6 +393,23 @@ class JamCodegenContext {
std::unordered_map>
typeModuleOf_;
+ // Memo layers for the per-expression resolution chokepoints, keyed
+ // by (interned-module-id << 32 | TypeIdx) or by requalified
+ // TypeIdx. Cleared whenever their inputs change (registerTypeOwner
+ // / registerModuleConst — both run only during up-front
+ // registration in main.cpp, before any body lowers).
+ mutable std::unordered_map moduleIds_;
+ mutable std::unordered_map requalifyMemo_;
+ mutable std::unordered_map dropMemo_;
+ mutable std::unordered_map sizeMemo_;
+ mutable std::unordered_map alignMemo_;
+ mutable std::unordered_map
+ comptimeSeedCache_;
+ uint32_t moduleIdOf(const std::string &m) const;
+ TypeIdx requalifyTypeUncached(TypeIdx ty,
+ const std::string &ctxModule) const;
+ uint64_t typeSizeImpl(TypeIdx ty) const;
+ uint64_t typeAlignImpl(TypeIdx ty) const;
std::map moduleConsts;
// (FunctionAST*, IfNode NodeIdx) -> comp-if condition verdict. See
// recordCompIfVerdict / lookupCompIfVerdict above.
diff --git a/src/comptime.h b/src/comptime.h
index ff77d24..9697895 100644
--- a/src/comptime.h
+++ b/src/comptime.h
@@ -119,6 +119,13 @@ class ComptimeScope {
// Returns nullptr if `name` isn't bound here OR in any ancestor.
const ComptimeValue *lookup(const std::string &name) const;
+ // Copy every binding in THIS scope (ancestors excluded) into `dst`.
+ // Lets the per-module seed cache replay a computed const fixpoint
+ // into a fresh scope without re-evaluating the initializers.
+ void copyBindingsInto(ComptimeScope &dst) const {
+ for (const auto &kv : bindings_) dst.bind(kv.first, kv.second);
+ }
+
private:
ComptimeScope *parent_ = nullptr;
std::unordered_map bindings_;
diff --git a/src/init_analysis.cpp b/src/init_analysis.cpp
index 4cb4bfa..583a64d 100644
--- a/src/init_analysis.cpp
+++ b/src/init_analysis.cpp
@@ -186,6 +186,10 @@ class Analyzer {
// Pointer to the current function's parameter list. Set in run(),
// Cleared on exit. Lifetime bounded by the run() activation that set it.
const std::vector *args_ = nullptr;
+ // Param name -> mode, prebuilt in run(): checkDropBearingLocalsInit
+ // consults this per in-scope binding per exit point, where a linear
+ // rescan of the param vector goes quadratic on big functions.
+ std::unordered_map paramModes_;
// The FunctionAST being analyzed — keys the comp-if verdict lookup
// (astgen records verdicts per FunctionAST so generic clones, which
@@ -252,6 +256,8 @@ class Analyzer {
std::vector Analyzer::run(const FunctionAST &fn) {
args_ = &fn.Args;
+ paramModes_.clear();
+ for (const Param &p : fn.Args) paramModes_[p.Name] = p.Mode;
fnAst_ = &fn;
varTypes_.clear();
declDepth_.clear();
@@ -1267,13 +1273,8 @@ void Analyzer::checkDropBearingLocalsInit(const NameMap &state,
// parameters are owned by the callee and DO drop at exit, so
// they go through the same classification as locals.
bool isBorrowedParam = false;
- if (args_) {
- for (const Param &p : *args_) {
- if (p.Name == name) {
- isBorrowedParam = (p.Mode != ParamMode::Move);
- break;
- }
- }
+ if (auto pIt = paramModes_.find(name); pIt != paramModes_.end()) {
+ isBorrowedParam = (pIt->second != ParamMode::Move);
}
if (isBorrowedParam) continue;
diff --git a/src/main.cpp b/src/main.cpp
index 871dfb4..c289702 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -18,6 +18,12 @@
#include
#include
#ifndef _WIN32
+#include
+#include
+#include
+extern char **environ;
+#endif
+#ifndef _WIN32
#include
#endif
@@ -76,6 +82,90 @@ class ProgressGuard {
}
};
+#ifndef _WIN32
+// Spawn `argv` directly via posix_spawnp (no /bin/sh hop — system()
+// costs an extra shell fork per link and per test run) and wait.
+// Returns the raw waitpid status, or -1 when the spawn failed. When
+// `outFd` >= 0 the child's stdout+stderr are redirected to it.
+static int spawnAndWait(const std::vector &argv, int outFd = -1) {
+ std::vector cargv;
+ cargv.reserve(argv.size() + 1);
+ for (const auto &a : argv) cargv.push_back(const_cast(a.c_str()));
+ cargv.push_back(nullptr);
+ posix_spawn_file_actions_t fa;
+ posix_spawn_file_actions_init(&fa);
+ if (outFd >= 0) {
+ posix_spawn_file_actions_adddup2(&fa, outFd, 1);
+ posix_spawn_file_actions_adddup2(&fa, outFd, 2);
+ }
+ pid_t pid = 0;
+ int rc = posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), environ);
+ posix_spawn_file_actions_destroy(&fa);
+ if (rc != 0) return -1;
+ int status = 0;
+ if (waitpid(pid, &status, 0) < 0) return -1;
+ return status;
+}
+
+// Async variant for the parallel test runner: spawn `argv` with its
+// stdout+stderr redirected into a fresh pipe, return the pid and the
+// (nonblocking) read end via `outFd`. Returns -1 on failure.
+static pid_t spawnAsync(const std::vector &argv, int &outFd) {
+ int fds[2];
+ if (pipe(fds) != 0) return -1;
+ std::vector cargv;
+ cargv.reserve(argv.size() + 1);
+ for (const auto &a : argv) cargv.push_back(const_cast(a.c_str()));
+ cargv.push_back(nullptr);
+ posix_spawn_file_actions_t fa;
+ posix_spawn_file_actions_init(&fa);
+ posix_spawn_file_actions_adddup2(&fa, fds[1], 1);
+ posix_spawn_file_actions_adddup2(&fa, fds[1], 2);
+ posix_spawn_file_actions_addclose(&fa, fds[0]);
+ posix_spawn_file_actions_addclose(&fa, fds[1]);
+ pid_t pid = 0;
+ int rc = posix_spawnp(&pid, cargv[0], &fa, nullptr, cargv.data(), environ);
+ posix_spawn_file_actions_destroy(&fa);
+ close(fds[1]);
+ if (rc != 0) {
+ close(fds[0]);
+ return -1;
+ }
+ fcntl(fds[0], F_SETFL, O_NONBLOCK);
+ outFd = fds[0];
+ return pid;
+}
+
+// FNV-1a over a file's bytes. Keys the linked-test-binary cache: macOS
+// assesses every fresh executable inode on first exec (60-290ms), so
+// re-running a byte-identical test binary from a cached inode instead
+// of relinking a new one is the difference between ~1ms and ~100ms per
+// test file.
+static bool hashFileFNV(const std::string &path, uint64_t &out) {
+ std::ifstream f(path, std::ios::binary);
+ if (!f.is_open()) return false;
+ uint64_t h = 1469598103934665603ull;
+ char buf[1 << 16];
+ while (f) {
+ f.read(buf, sizeof(buf));
+ std::streamsize n = f.gcount();
+ for (std::streamsize i = 0; i < n; i++) {
+ h ^= static_cast(buf[i]);
+ h *= 1099511628211ull;
+ }
+ }
+ out = h;
+ return true;
+}
+
+static void hashMixString(uint64_t &h, const std::string &s) {
+ for (unsigned char c : s) {
+ h ^= c;
+ h *= 1099511628211ull;
+ }
+}
+#endif
+
static int compileAndRun(const std::string &filename,
const std::string &outputName, bool runFlag,
bool emitIR, bool testMode, JamOptLevel optLevel,
@@ -1492,10 +1582,11 @@ static int compileAndRun(const std::string &filename,
// extern fns from system libraries resolve. When LTO is on, hand the
// bitcode to clang with `-flto=` so its driver picks the right linker
// plugin (lld for ELF, ld64 for Mach-O, both with LTO support).
- std::string linkCmd = "clang " + intermediate + " -o " + outputName;
- if (lto == JAM_LTO_THIN) linkCmd += " -flto=thin";
- else if (lto == JAM_LTO_FAT) linkCmd += " -flto=full";
- for (const auto &lib : linkLibs) { linkCmd += " -l" + lib; }
+ std::vector linkArgs = {"clang", intermediate, "-o",
+ outputName};
+ if (lto == JAM_LTO_THIN) linkArgs.push_back("-flto=thin");
+ else if (lto == JAM_LTO_FAT) linkArgs.push_back("-flto=full");
+ for (const auto &lib : linkLibs) { linkArgs.push_back("-l" + lib); }
jam::Target host = jam::Target::getHostTarget();
@@ -1514,7 +1605,7 @@ static int compileAndRun(const std::string &filename,
if (linkLibc &&
(host.os == jam::OS::Linux || host.os == jam::OS::FreeBSD) &&
host.abi != jam::ABI::Musl) {
- linkCmd += " -lm";
+ linkArgs.push_back("-lm");
}
// Strip unreferenced functions/data at link time. Pairs with
@@ -1525,11 +1616,11 @@ static int compileAndRun(const std::string &filename,
if (optLevel != JAM_OPT_NONE) {
switch (host.os) {
case jam::OS::MacOS:
- linkCmd += " -Wl,-dead_strip";
+ linkArgs.push_back("-Wl,-dead_strip");
break;
case jam::OS::Linux:
case jam::OS::FreeBSD:
- linkCmd += " -Wl,--gc-sections";
+ linkArgs.push_back("-Wl,--gc-sections");
break;
case jam::OS::Windows:
case jam::OS::Unknown:
@@ -1547,20 +1638,86 @@ static int compileAndRun(const std::string &filename,
if (strip != JAM_STRIP_NONE) {
switch (host.os) {
case jam::OS::MacOS:
- linkCmd += " -Wl,-S";
- if (strip == JAM_STRIP_SYMBOLS) linkCmd += " -Wl,-x";
+ linkArgs.push_back("-Wl,-S");
+ if (strip == JAM_STRIP_SYMBOLS) linkArgs.push_back("-Wl,-x");
break;
case jam::OS::Linux:
case jam::OS::FreeBSD:
- linkCmd +=
- (strip == JAM_STRIP_SYMBOLS) ? " -Wl,-s" : " -Wl,--strip-debug";
+ linkArgs.push_back((strip == JAM_STRIP_SYMBOLS)
+ ? "-Wl,-s"
+ : "-Wl,--strip-debug");
break;
case jam::OS::Windows:
case jam::OS::Unknown:
break;
}
}
+#ifndef _WIN32
+ // Decode a waitpid status into a shell-convention exit code. A
+ // signal-killed child (segfault, abort, …) has no exit status;
+ // WEXITSTATUS on it reads garbage bits that decode to 0, which
+ // would report a crashed test binary as "passed" — and since the
+ // crash also loses the child's unflushed stdout, the failure would
+ // be completely silent. Follow the shell convention: 128 + signal.
+ auto decodeRunStatus = [](int status, const std::string &what) -> int {
+ if (status == -1) {
+ std::cerr << "Failed to run " << what << std::endl;
+ return 1;
+ }
+ if (WIFSIGNALED(status)) {
+ int sig = WTERMSIG(status);
+ std::cerr << what << " terminated by signal " << sig << " ("
+ << strsignal(sig) << ")" << std::endl;
+ return 128 + sig;
+ }
+ return WEXITSTATUS(status);
+ };
+
+ // Test-mode fast path: cache the linked binary keyed by the object
+ // bytes + link flags, and re-exec the CACHED INODE when nothing
+ // changed. macOS assesses every fresh executable inode on first
+ // exec (60-290ms of kernel-side content scanning — ~70% of a warm
+ // suite run's wall time); a previously-executed inode starts in
+ // ~1ms. The cached binary is deliberately NOT removed after the
+ // run, and the link is skipped entirely on a cache hit.
+ if (testMode) {
+ uint64_t h = 0;
+ if (hashFileFNV(intermediate, h)) {
+ for (size_t i = 4; i < linkArgs.size(); i++) {
+ hashMixString(h, linkArgs[i]);
+ }
+ char hex[24];
+ std::snprintf(hex, sizeof(hex), "t%016llx",
+ static_cast(h));
+ std::error_code ec;
+ std::filesystem::path cacheDir =
+ std::filesystem::path("output") / "testcache";
+ std::filesystem::create_directories(cacheDir, ec);
+ std::string cachePath = (cacheDir / hex).string();
+ if (!std::filesystem::exists(cachePath, ec)) {
+ std::vector cacheLink = linkArgs;
+ cacheLink[3] = cachePath;
+ if (spawnAndWait(cacheLink) != 0) {
+ std::cerr << "Linking failed" << std::endl;
+ std::remove(intermediate.c_str());
+ return 1;
+ }
+ }
+ std::remove(intermediate.c_str());
+ progress.stop();
+ return decodeRunStatus(spawnAndWait({cachePath}), cachePath);
+ }
+ }
+
+ int linkResult = spawnAndWait(linkArgs);
+#else
+ std::string linkCmd;
+ for (const auto &a : linkArgs) {
+ if (!linkCmd.empty()) linkCmd += ' ';
+ linkCmd += a;
+ }
int linkResult = system(linkCmd.c_str());
+#endif
if (linkResult != 0) {
std::cerr << "Linking failed" << std::endl;
return 1;
@@ -1575,33 +1732,16 @@ static int compileAndRun(const std::string &filename,
progress.stop();
if (testMode || runFlag) {
- std::string runCmd = "./" + outputName;
- int exitCode = system(runCmd.c_str());
-
- // Clean up executable after running
- std::remove(outputName.c_str());
-
-// Extract actual exit code (system() returns encoded status)
+ std::string runPath = "./" + outputName;
#ifdef _WIN32
+ int exitCode = system(runPath.c_str());
+ std::remove(outputName.c_str());
return exitCode;
#else
- if (exitCode == -1) {
- std::cerr << "Failed to run " << outputName << std::endl;
- return 1;
- }
- // A signal-killed child (segfault, abort, …) has no exit status;
- // WEXITSTATUS on it reads garbage bits that decode to 0, which
- // would report a crashed test binary as "passed" — and since the
- // crash also loses the child's unflushed stdout, the failure
- // would be completely silent. Follow the shell convention:
- // 128 + signal number.
- if (WIFSIGNALED(exitCode)) {
- int sig = WTERMSIG(exitCode);
- std::cerr << outputName << " terminated by signal " << sig << " ("
- << strsignal(sig) << ")" << std::endl;
- return 128 + sig;
- }
- return WEXITSTATUS(exitCode);
+ int status = spawnAndWait({runPath});
+ // Clean up executable after running
+ std::remove(outputName.c_str());
+ return decodeRunStatus(status, outputName);
#endif
}
@@ -1971,18 +2111,134 @@ int main(int argc, char *argv[]) {
int passed = 0;
int failed = 0;
int skipped = 0;
+ std::vector runnable;
for (const auto &f : files) {
- if (!fileHasTests(f)) {
- skipped++;
- continue;
+ if (fileHasTests(f)) runnable.push_back(f);
+ else skipped++;
+ }
+
+#ifndef _WIN32
+ // Per-file work is ~98% blocked subprocess waits (clang link +
+ // first-exec assessment of the freshly linked test binary), so
+ // a small pool of worker processes — each a `jam test `
+ // re-invocation of this binary — gives near-linear speedup.
+ // Each worker's output is buffered through a pipe and printed
+ // as a coherent block on completion. JAM_TEST_JOBS=1 forces the
+ // serial in-process path (useful under a debugger).
+ long hw = sysconf(_SC_NPROCESSORS_ONLN);
+ unsigned jobs =
+ static_cast(hw > 1 ? (hw > 8 ? 8 : hw) : 1);
+ if (const char *env = std::getenv("JAM_TEST_JOBS")) {
+ int v = std::atoi(env);
+ if (v >= 1) jobs = static_cast(v);
+ }
+ if (jobs > 1 && runnable.size() > 1) {
+ auto optName = [](JamOptLevel o) -> const char * {
+ switch (o) {
+ case JAM_OPT_LESS:
+ return "1";
+ case JAM_OPT_DEFAULT:
+ return "2";
+ case JAM_OPT_AGGRESSIVE:
+ return "3";
+ case JAM_OPT_SIZE:
+ return "s";
+ case JAM_OPT_SMALL:
+ return "z";
+ default:
+ return "0";
+ }
+ };
+ struct Job {
+ pid_t pid;
+ int fd;
+ std::string file;
+ std::string out;
+ };
+ std::vector active;
+ size_t next = 0;
+ while (next < runnable.size() || !active.empty()) {
+ while (active.size() < jobs && next < runnable.size()) {
+ const std::string &f = runnable[next++];
+ std::filesystem::path p(f);
+ std::vector childArgv = {
+ argv[0], "test", f, "-o",
+ "jam_test_" + p.stem().string()};
+ if (optLevel != JAM_OPT_NONE) {
+ childArgv.push_back("-C");
+ childArgv.push_back(std::string("opt-level=") +
+ optName(optLevel));
+ }
+ for (const auto &lib : linkLibs) {
+ childArgv.push_back("-l" + lib);
+ }
+ int fd = -1;
+ pid_t pid = spawnAsync(childArgv, fd);
+ if (pid < 0) {
+ std::cout << std::endl
+ << "@" << f << std::endl
+ << "error: failed to spawn test worker"
+ << std::endl;
+ failed++;
+ continue;
+ }
+ active.push_back(Job{pid, fd, f, std::string()});
+ }
+ // Drain whatever the running workers have produced so a
+ // chatty child never blocks on a full pipe.
+ bool sawData = false;
+ char buf[4096];
+ for (auto &j : active) {
+ ssize_t n;
+ while ((n = read(j.fd, buf, sizeof(buf))) > 0) {
+ j.out.append(buf, static_cast(n));
+ sawData = true;
+ }
+ }
+ // Reap finished workers; print each one's buffered
+ // output as a block, in completion order.
+ bool reaped = false;
+ for (size_t i = 0; i < active.size();) {
+ int st = 0;
+ pid_t r = waitpid(active[i].pid, &st, WNOHANG);
+ if (r != active[i].pid) {
+ i++;
+ continue;
+ }
+ // The child (and everything it spawned) is gone, so
+ // the write end is closed — read to EOF.
+ ssize_t n;
+ while ((n = read(active[i].fd, buf, sizeof(buf))) > 0) {
+ active[i].out.append(buf, static_cast(n));
+ }
+ close(active[i].fd);
+ int rc = 1;
+ if (WIFEXITED(st)) rc = WEXITSTATUS(st);
+ else if (WIFSIGNALED(st)) rc = 128 + WTERMSIG(st);
+ std::cout << std::endl
+ << "@" << active[i].file << std::endl
+ << active[i].out;
+ if (rc != 0) failed++;
+ else passed++;
+ active.erase(active.begin() +
+ static_cast(i));
+ reaped = true;
+ }
+ if (!sawData && !reaped && !active.empty()) usleep(2000);
+ }
+ } else
+#endif
+ {
+ for (const auto &f : runnable) {
+ std::cout << std::endl << "@" << f << std::endl;
+ std::filesystem::path p(f);
+ std::string perFileOutput = "jam_test_" + p.stem().string();
+ int rc =
+ compileAndRun(f, perFileOutput, runFlag, emitIR, testMode,
+ optLevel, lto, strip, linkLibs);
+ if (rc != 0) failed++;
+ else passed++;
}
- std::cout << std::endl << "@" << f << std::endl;
- std::filesystem::path p(f);
- std::string perFileOutput = "jam_test_" + p.stem().string();
- int rc = compileAndRun(f, perFileOutput, runFlag, emitIR, testMode,
- optLevel, lto, strip, linkLibs);
- if (rc != 0) failed++;
- else passed++;
}
std::cout << std::endl;
@@ -1993,6 +2249,17 @@ int main(int argc, char *argv[]) {
return failed == 0 ? 0 : 1;
}
+ // The default output name `output` collides with a same-named
+ // directory (this repo has one): the link fails with a cryptic
+ // `ld: errno=21`. Fall back to a usable name instead.
+ if (std::filesystem::is_directory(outputName)) {
+ std::string fallback = outputName + ".bin";
+ std::cerr << "note: output name '" << outputName
+ << "' is a directory; writing '" << fallback << "'"
+ << std::endl;
+ outputName = fallback;
+ }
+
// Catch compile-time exceptions cleanly so the user sees a single-line
// error instead of a stack trace + abort. Exceptions reach here from
// codegen paths that detect impossible inputs (e.g. a generic
diff --git a/src/mangling.h b/src/mangling.h
index 1fc3e36..d9239a3 100644
--- a/src/mangling.h
+++ b/src/mangling.h
@@ -38,10 +38,17 @@
// `codegen.cpp`) reads the same rules — duplicating the mangling table
// per call site is exactly how "unknown callee" miscompiles creep in
// when one site gains a new case and the others don't.
-inline std::string mangledFunctionName(const FunctionAST &fn,
- const TypePool &types,
- const StringPool &strings) {
- if (fn.isTest) return "__test_" + fn.Name;
+// Memoized on `fn.mangledNameCache`: the symbol is requested per call
+// expression and per drop lookup, and every input is fixed before the
+// first request.
+inline const std::string &mangledFunctionName(const FunctionAST &fn,
+ const TypePool &types,
+ const StringPool &strings) {
+ if (!fn.mangledNameCache.empty()) return fn.mangledNameCache;
+ if (fn.isTest) {
+ fn.mangledNameCache = "__test_" + fn.Name;
+ return fn.mangledNameCache;
+ }
// 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
@@ -49,10 +56,13 @@ inline std::string mangledFunctionName(const FunctionAST &fn,
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);
+ 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 strings.get(ni) + ".drop"; }
+ if (ni != kNoString) {
+ fn.mangledNameCache = strings.get(ni) + ".drop";
+ return fn.mangledNameCache;
+ }
}
}
}
@@ -66,7 +76,8 @@ inline std::string mangledFunctionName(const FunctionAST &fn,
out += '.';
}
out += fn.Name;
- return out;
+ fn.mangledNameCache = std::move(out);
+ return fn.mangledNameCache;
}
#endif // JAM_MANGLING_H
diff --git a/src/module_resolver.cpp b/src/module_resolver.cpp
index 6abd2de..ab377f7 100644
--- a/src/module_resolver.cpp
+++ b/src/module_resolver.cpp
@@ -98,9 +98,26 @@ void setStdPathOverride(const std::string &path) {
ModuleResolver::ModuleResolver(const std::string &baseDir, TypePool &typePool_,
StringPool &stringPool_, NodeStore &nodeStore_)
: baseDir(baseDir), typePool(&typePool_), stringPool(&stringPool_),
- nodeStore(&nodeStore_) {}
+ nodeStore(&nodeStore_) {
+ std::error_code ec;
+ baseAbs_ = fs::canonical(baseDir, ec);
+ baseAbsOk_ = !ec;
+}
std::string ModuleResolver::resolve(const std::string &importPath) const {
+ // Memoized per spelling: each uncached resolve runs up to five
+ // exists/canonical probes, and the same spelling resolves several
+ // times per import edge (canonicalKey, getOrLoadModule, chain
+ // walks). The filesystem doesn't change mid-compile.
+ auto cached = resolveCache_.find(importPath);
+ if (cached != resolveCache_.end()) return cached->second;
+ std::string r = resolveUncached(importPath);
+ resolveCache_.emplace(importPath, r);
+ return r;
+}
+
+std::string
+ModuleResolver::resolveUncached(const std::string &importPath) const {
// `test` stays a compiler-builtin namespace (provides `assert`).
// `std` used to short-circuit too, but now resolves to a real
// `std/std.jam` file that re-exports the standard-library modules.
@@ -210,24 +227,35 @@ ModuleResolver::moduleIdentity(const std::string &resolvedFile) const {
// 3. Anywhere else: the canonical absolute path, `.jam` stripped.
// Out-of-tree imports still converge per file. (The reference
// compiler rejects these outright; jam permits them.)
- std::error_code ec;
- fs::path baseAbs = fs::canonical(baseDir, ec);
- if (!ec) {
- std::string id = identityUnder(resolvedFile, baseAbs);
+ // The roots are loop-invariant: the base dir canonicalizes once in
+ // the ctor, and the std roots once per process (the installed root
+ // is a static; the dev fallback hangs off the process CWD). Without
+ // this, every import edge re-walked all three with realpath.
+ if (baseAbsOk_) {
+ std::string id = identityUnder(resolvedFile, baseAbs_);
if (!id.empty()) return id;
}
- if (const auto &root = stdRoot(); root) {
- fs::path stdAbs = fs::canonical(*root, ec);
- if (!ec) {
- std::string id = identityUnder(resolvedFile, stdAbs);
- if (!id.empty()) return "std/" + id;
+ static const std::pair stdAbsOnce = [] {
+ std::error_code e;
+ if (const auto &root = stdRoot(); root) {
+ fs::path p = fs::canonical(*root, e);
+ if (!e) return std::make_pair(true, p);
}
+ return std::make_pair(false, fs::path());
+ }();
+ if (stdAbsOnce.first) {
+ std::string id = identityUnder(resolvedFile, stdAbsOnce.second);
+ if (!id.empty()) return "std/" + id;
}
// Dev fallback root (`/std`), mirroring resolve()'s last
// lookup tier.
- fs::path devAbs = fs::canonical("std", ec);
- if (!ec) {
- std::string id = identityUnder(resolvedFile, devAbs);
+ static const std::pair devAbsOnce = [] {
+ std::error_code e;
+ fs::path p = fs::canonical("std", e);
+ return std::make_pair(!e, e ? fs::path() : p);
+ }();
+ if (devAbsOnce.first) {
+ std::string id = identityUnder(resolvedFile, devAbsOnce.second);
if (!id.empty()) return "std/" + id;
}
fs::path abs = fs::path(resolvedFile);
@@ -339,11 +367,13 @@ ModuleAST *ModuleResolver::loadModuleAt(const std::string &key,
// types twice — colliding in the global by-name type registry that
// `main.cpp` builds. Collapsing to one identity dedupes the module
// and keeps `modulePath` (the mangling prefix) stable.
+ // One nested resolver per MODULE (not per import edge): its ctor
+ // canonicalizes the module dir, and its resolve cache serves every
+ // edge this module declares.
+ ModuleResolver nestedResolver(fs::path(resolvedPath).parent_path().string(),
+ *typePool, *stringPool, *nodeStore);
auto loadNested = [&](std::string &importPath) {
if (importPath == "test") return;
- fs::path moduleDir = fs::path(resolvedPath).parent_path();
- ModuleResolver nestedResolver(moduleDir.string(), *typePool,
- *stringPool, *nodeStore);
std::string nestedResolved = nestedResolver.resolve(importPath);
if (nestedResolved.empty() || nestedResolved == "test") return;
std::string id = moduleIdentity(nestedResolved);
diff --git a/src/module_resolver.h b/src/module_resolver.h
index a65ea29..e3ba832 100644
--- a/src/module_resolver.h
+++ b/src/module_resolver.h
@@ -10,6 +10,7 @@
#include "ast.h"
#include "ast_flat.h"
+#include
#include
#include
#include
@@ -52,6 +53,14 @@ class ModuleResolver {
private:
std::string baseDir;
+ // fs::canonical(baseDir), computed once in the ctor — moduleIdentity
+ // runs per import edge, and realpath walks every path component.
+ std::filesystem::path baseAbs_;
+ bool baseAbsOk_ = false;
+ // resolve() results per import spelling: up to five exists/canonical
+ // probes each, and the same spelling resolves repeatedly
+ // (canonicalKey, then getOrLoadModule, then chain walks).
+ mutable std::unordered_map resolveCache_;
TypePool *typePool;
StringPool *stringPool;
NodeStore *nodeStore;
@@ -59,6 +68,8 @@ class ModuleResolver {
std::vector> *sharedAnonEnums_ = nullptr;
std::unordered_map> loadedModules;
+ std::string resolveUncached(const std::string &importPath) const;
+
// Canonical identity for a resolved module file, used as the cache
// key and `modulePath` prefix: entry-relative for in-tree files,
// `std/` for standard-library files, the absolute path for