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