From b94139b67ced79a0248dea6cae7a91c474f7f9ea Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 2 Oct 2026 20:03:22 +0200 Subject: [PATCH] feat: project.cpp declares its own dependencies with #pragma crafter dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project.cpp could only import std and Crafter.Build, so anything shared between build descriptions had to be a header every consumer copies. Now #pragma crafter dependency "" [branch "" | commit ""] [project ""] names a sibling crafter-build project that project.cpp itself imports a module from. LoadProject scans for the directive, resolves it through GitProject (a URL) or LocalProject (a path relative to the project file), forces the result into host-tooling mode (the host triple LoadProject's own cache is keyed on, native march, no debug, -fPIC so the archive links into a shared object), builds it, and puts its BMIs and archive on the project.so command line. The dependency's own project.cpp goes through the same loader, so its directives resolve recursively; a cycle between project files is an error instead of unbounded recursion. The dependency is built on every load, fresh or not: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the module depfile — which Module::Check already reads — is what notices the host PCM cache was regenerated. Its artifact mtime is an input to project.so's staleness check, so editing the dependency recompiles project.cpp without touching it. The Linux link gains -fuse-ld=lld because release archives hold ThinLTO bitcode. ProjectDependency(name) hands the same Configuration* back from inside CrafterBuildProject, by configuration name or exported module name, so a test that links the module needn't spell the source twice. It reads a thread_local frame stack that an RAII guard keeps for the whole of LoadProject, which also provides the cycle check. ExpectedOutput() is the link gate's artifact-path lambda hoisted into the :Clang partition, so the staleness check and the -r runner share the one rule. Windows branches are wired identically but unverified. Co-Authored-By: Claude Fable 5.1 --- README.md | 32 ++ implementations/Crafter.Build-Clang.cpp | 48 +-- implementations/Crafter.Build-Platform.cpp | 320 +++++++++++++++++- interfaces/Crafter.Build-Clang.cppm | 7 + interfaces/Crafter.Build-Platform.cppm | 8 + project.cpp | 2 + .../ProjectDependency/fixture/app/project.cpp | 20 ++ .../fixture/bad-both/project.cpp | 8 + .../fixture/bad-nosource/project.cpp | 8 + .../fixture/cycle-a/project.cpp | 12 + .../fixture/cycle-b/project.cpp | 12 + .../interfaces/Greeter-changed.cppm.in | 5 + .../fixture/greeter/interfaces/Greeter.cppm | 7 + .../fixture/greeter/project.cpp | 23 ++ tests/ProjectDependency/main.cpp | 151 +++++++++ 15 files changed, 618 insertions(+), 45 deletions(-) create mode 100644 tests/ProjectDependency/fixture/app/project.cpp create mode 100644 tests/ProjectDependency/fixture/bad-both/project.cpp create mode 100644 tests/ProjectDependency/fixture/bad-nosource/project.cpp create mode 100644 tests/ProjectDependency/fixture/cycle-a/project.cpp create mode 100644 tests/ProjectDependency/fixture/cycle-b/project.cpp create mode 100644 tests/ProjectDependency/fixture/greeter/interfaces/Greeter-changed.cppm.in create mode 100644 tests/ProjectDependency/fixture/greeter/interfaces/Greeter.cppm create mode 100644 tests/ProjectDependency/fixture/greeter/project.cpp create mode 100644 tests/ProjectDependency/main.cpp diff --git a/README.md b/README.md index 8a777ce..a48f038 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,38 @@ Clones live at `~/.cache/crafter.build/external/-/`, keyed by `(url, **Header-only git deps** — same as cmake deps but `builder = ExternalBuilder::None`. Just clones and propagates `-I` flags. +**Sibling crafter-build projects** — `GitProject` / `LocalProject` load another project's `project.cpp` in-process and return a `Configuration*` for `cfg.dependencies`: + +```cpp +Configuration* math = GitProject({ + .source = { .url = "https://forgejo.catcrafts.net/Catcrafts/Crafter.Math.git" }, // .branch or .commit to pin + .args = depArgs, // forwarded to its CrafterBuildProject +}); +Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project.cpp", .args = depArgs }); +cfg.dependencies = { math }; +``` + +Clones share the external cache and identical specs share one `Configuration`. An unpinned source is `git pull`ed on every invocation; set `.commit` for reproducible, offline-after-first-fetch builds. + +### Dependencies of project.cpp itself + +`project.cpp` can import modules from another crafter-build project. The file has to compile before it can say anything, so the dependency is a directive next to the `import` it enables: + +```cpp +import std; +import Crafter.Build; +#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.LintRules.git" +import Crafter.LintRules; +``` + +``` +#pragma crafter dependency "" [branch "" | commit ""] [project ""] +``` + +A source containing `://` (or starting with `git@`) is cloned like `GitProject`; anything else is a path relative to the project file, like `LocalProject` (a directory means its `project.cpp`; `project` names another file in a repo). Before `project.cpp` is compiled, each dependency is fetched, built for the host, and put on the module path and link line. Its own `project.cpp` goes through the same loader, so its directives resolve recursively, and a rebuilt dependency recompiles `project.cpp` without anyone touching it. Inside `CrafterBuildProject`, `ProjectDependency("")` returns the same `Configuration*`, for a test that links the module too. + +Pin with `commit` for the same reason as above. `crafter-build clean` leaves the dependency's build alone (it lives with the clone). The directive is read textually, so one inside a block comment still counts. A cycle between project files is an error. Windows hosts are wired the same way but unverified. + ## Install layout ``` diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index cd929bc..0f0440e 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -265,6 +265,21 @@ fs::path Crafter::StdPcmDir(const Configuration& config) { return GetCacheDir()/stdPcmKey; } +fs::path Crafter::ExpectedOutput(const Configuration& config) { + const fs::path dir = config.BinDir(); + const bool windowsTarget = config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc"; + if (config.type == ConfigurationType::Executable) { + if (config.target.starts_with("wasm32")) return dir / std::format("{}.wasm", config.outputName); + return config.target == "x86_64-w64-mingw32" ? dir / std::format("{}.exe", config.outputName) : dir / config.outputName; + } + if (config.type == ConfigurationType::LibraryStatic) { + return windowsTarget ? dir / std::format("{}.lib", config.outputName) : dir / std::format("lib{}.a", config.outputName); + } + // LibraryDynamic — the .dll on Windows targets so an mtime check sees the + // freshly linked library; on Unix the .so is that file. + return windowsTarget ? dir / std::format("{}.dll", config.outputName) : dir / std::format("lib{}.so", config.outputName); +} + CompileCommand Crafter::GetCompileCommand(const Configuration& config) { CompileCommand out; out.stdPcmDir = StdPcmDir(config); @@ -1042,34 +1057,13 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map fs::path { - fs::path dir = c.BinDir(); - if (c.type == ConfigurationType::Executable) { - if (c.target.starts_with("wasm32")) return dir / (std::format("{}.wasm", c.outputName)); - return c.target == "x86_64-w64-mingw32" - ? dir / (std::format("{}.exe", c.outputName)) - : dir / c.outputName; - } - if (c.type == ConfigurationType::LibraryStatic) { - return c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc" - ? dir / std::format("{}.lib", c.outputName) - : dir / std::format("lib{}.a", c.outputName); - } - // LibraryDynamic — point at the .dll on Windows targets so the - // mtime check sees the newly-built DLL; on Unix the .so suffices. - if (c.target == "x86_64-w64-mingw32" || c.target == "x86_64-pc-windows-msvc") { - return dir / std::format("{}.dll", c.outputName); - } - return dir / std::format("lib{}.so", c.outputName); - }; - - fs::path expected = expectedOutputFor(config); + fs::path expected = ExpectedOutput(config); if (!fs::exists(expected)) { buildResult.repack = true; } else { auto consumerMtime = fs::last_write_time(expected); for (Configuration* dep : config.dependencies) { - fs::path depArtifact = expectedOutputFor(*dep); + fs::path depArtifact = ExpectedOutput(*dep); if (fs::exists(depArtifact) && fs::last_write_time(depArtifact) > consumerMtime) { buildResult.repack = true; break; @@ -1786,13 +1780,7 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { return 1; } fs::path dir = config.BinDir(); - fs::path artifact = dir / config.outputName; - if (config.target.starts_with("wasm32")) { - artifact += ".wasm"; - } else if (config.target == "x86_64-w64-mingw32" || config.target == "x86_64-pc-windows-msvc") { - artifact += ".exe"; - } - artifact = fs::absolute(artifact); + fs::path artifact = fs::absolute(ExpectedOutput(config)); fs::path absDir = fs::absolute(dir); // wasm targets need either a wasm runtime (wasi-cli) or an HTTP diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 4e4196b..af7d176 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -20,6 +20,8 @@ module Crafter.Build:Platform_impl; import std; import :Platform; import :Clang; +import :Interface; +import :External; import :Progress; namespace fs = std::filesystem; using namespace Crafter; @@ -286,6 +288,270 @@ std::string Crafter::RunBuildCommand(std::string_view cmd) { return r.output; } +// --- Dependencies of project.cpp itself ------------------------------------- +// +// #pragma crafter dependency "" [branch "" | commit ""] [project ""] +// +// names a sibling crafter-build project that project.cpp imports a module +// from. Anything a file needs before it can compile cannot be C++ inside that +// file, so this is a directive next to the `import` it enables — where +// `#include` lives. Each one is fetched, built for the host, and put on +// project.so's module path and link line before project.cpp is compiled. The +// dependency's own project.cpp goes through LoadProject, so its pragmas +// resolve recursively. +namespace { + struct PragmaDependency { + std::string source; + std::string branch; + std::string commit; + std::string project; + std::size_t line = 0; + }; + + [[noreturn]] void PragmaError(const fs::path& file, std::size_t line, std::string_view message) { + throw std::runtime_error(std::format("{}:{}: {}", file.string(), line, message)); + } + + // The tokens of one directive: bare words and double-quoted strings (no + // escapes — a path or URL never needs one). Reading stops at `//`. + class PragmaCursor { + public: + PragmaCursor(std::string_view text, const fs::path& file, std::size_t line) : text_(text), file_(file), line_(line) {} + + bool AtEnd() { + SkipSpace(); + return pos_ >= text_.size() || text_.substr(pos_).starts_with("//"); + } + + bool Take(char c) { + SkipSpace(); + if (pos_ >= text_.size() || text_[pos_] != c) return false; + ++pos_; + return true; + } + + std::string_view Word() { + SkipSpace(); + const std::size_t start = pos_; + while (pos_ < text_.size() && IsWordChar(text_[pos_])) ++pos_; + return text_.substr(start, pos_ - start); + } + + std::optional Quoted() { + SkipSpace(); + if (pos_ >= text_.size() || text_[pos_] != '"') return std::nullopt; + const std::size_t close = text_.find('"', pos_ + 1); + if (close == std::string_view::npos) Fail("unterminated string"); + const std::string_view inner = text_.substr(pos_ + 1, close - pos_ - 1); + pos_ = close + 1; + return inner; + } + + [[noreturn]] void Fail(std::string_view message) const { PragmaError(file_, line_, message); } + + private: + static bool IsWordChar(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; } + + void SkipSpace() { + while (pos_ < text_.size() && (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\r')) ++pos_; + } + + std::string_view text_; + const fs::path& file_; + std::size_t line_; + std::size_t pos_ = 0; + }; + + // One directive per line, anywhere in the file. Comments aren't parsed: + // a directive inside a block comment is honoured, and `//` lines fall out + // because they don't start with `#`. + std::vector ScanProjectFilePragmas(const fs::path& absProject) { + std::vector found; + std::ifstream in(absProject); + std::string raw; + for (std::size_t lineNo = 1; std::getline(in, raw); ++lineNo) { + PragmaCursor cur(raw, absProject, lineNo); + if (!cur.Take('#') || cur.Word() != "pragma" || cur.Word() != "crafter") continue; + const std::string_view verb = cur.Word(); + if (verb != "dependency") cur.Fail(std::format("unknown crafter pragma '{}'", verb)); + + PragmaDependency dep; + dep.line = lineNo; + const std::optional source = cur.Quoted(); + if (!source) cur.Fail("expected a quoted source after 'dependency'"); + dep.source = *source; + while (!cur.AtEnd()) { + const std::string_view key = cur.Word(); + std::string* field = key == "branch" ? &dep.branch : key == "commit" ? &dep.commit : key == "project" ? &dep.project : nullptr; + if (!field) cur.Fail(key.empty() ? "unexpected text after the source" : std::format("unknown keyword '{}' (expected branch, commit or project)", key)); + if (!field->empty()) cur.Fail(std::format("'{}' given twice", key)); + const std::optional value = cur.Quoted(); + if (!value || value->empty()) cur.Fail(std::format("expected a quoted value after '{}'", key)); + *field = *value; + } + if (dep.source.empty()) cur.Fail("the source is empty"); + if (!dep.branch.empty() && !dep.commit.empty()) cur.Fail("'branch' and 'commit' are mutually exclusive"); + found.push_back(std::move(dep)); + } + return found; + } + + struct ProjectFileFrame { + fs::path project; + std::vector dependencies; + }; + + // LoadProject nests: a dependency's project.cpp loads inside its + // consumer's pre-pass, and GitProject inside CrafterBuildProject nests + // again. Several threads may also load at once (ConcurrentCacheRace), so + // the stack is per thread. ProjectDependency() reads the top frame. + thread_local std::vector LoadingFrames; + + // Held for the whole of one LoadProject call. Pushing a file that is + // already on the stack is a cycle, which used to recurse forever. + class ProjectFileGuard { + public: + explicit ProjectFileGuard(const fs::path& absProject) { + for (const ProjectFileFrame& frame : LoadingFrames) { + if (frame.project != absProject) continue; + std::string chain; + for (const ProjectFileFrame& f : LoadingFrames) chain += std::format("{} -> ", f.project.string()); + throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string())); + } + index_ = LoadingFrames.size(); + LoadingFrames.push_back({ absProject, {} }); + } + ~ProjectFileGuard() { LoadingFrames.pop_back(); } + ProjectFileGuard(const ProjectFileGuard&) = delete; + ProjectFileGuard& operator=(const ProjectFileGuard&) = delete; + + ProjectFileFrame& Frame() { return LoadingFrames[index_]; } + + private: + std::size_t index_ = 0; + }; + + // A pre-dependency is host tooling: project.so is loaded into the running + // crafter-build, so the dependency is built for the host and against the + // same PCM cache LoadProject populates — the literal host triple, not + // HostTarget(), which can spell the host differently and would land the + // dependency's std/Crafter.Build PCMs in another cache dir. Recursive, + // because the dependency's project.cpp forwards no arguments but still + // reads CRAFTER_BUILD_MARCH from the environment for its own deps. + void ForceHostTooling(Configuration& cfg, const Configuration& host, std::unordered_set& seen) { + if (!seen.insert(&cfg).second) return; + cfg.target = host.target; + cfg.march = host.march; + cfg.mtune = host.mtune; + cfg.debug = false; + // project.so is a shared object, so the archive it links has to be + // position-independent; static libraries aren't by default. Part of + // VariantId, so this build never shares a bin dir with a normal one. + // Checked before pushing: the same Configuration comes back from the + // project cache for every consumer in this process. + if (host.target == "x86_64-pc-linux-gnu" && !std::ranges::contains(cfg.compileFlags, "-fPIC")) { + cfg.compileFlags.emplace_back("-fPIC"); + } + for (Configuration* dep : cfg.dependencies) ForceHostTooling(*dep, host, seen); + } + + struct ProjectFileDependencies { + std::vector configs; + std::string compileFlags; // " -fprebuilt-module-path=…" for each dependency and its transitive deps + std::string linkFlags; // every dependency's BuildResult.libs, verbatim + fs::file_time_type newestArtifact = fs::file_time_type::min(); + }; + + bool IsGitSource(std::string_view source) { + return source.contains("://") || source.starts_with("git@"); + } + + ProjectFileDependencies ResolveProjectFileDependencies(const fs::path& absProject, const Configuration& host) { + ProjectFileDependencies out; + const std::vector pragmas = ScanProjectFilePragmas(absProject); + if (pragmas.empty()) return out; + + std::unordered_map> depResults; + std::mutex depMutex; + std::unordered_set forced; + std::unordered_set walked; + std::unordered_set libs; + + for (const PragmaDependency& pragma : pragmas) { + Configuration* cfg = nullptr; + if (IsGitSource(pragma.source)) { + GitProjectSpec spec; + spec.source = { pragma.source, pragma.branch, pragma.commit }; + if (!pragma.project.empty()) spec.projectFile = pragma.project; + cfg = GitProject(spec); + } else { + if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source"); + // Relative to the project file, not the cwd: LoadProject runs + // from wherever the consumer was invoked. + fs::path local = (absProject.parent_path() / pragma.source).lexically_normal(); + if (fs::is_directory(local)) { + local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project); + } else if (!pragma.project.empty()) { + PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); + } + if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); + cfg = LocalProject({ .projectFile = local }); + } + if (std::ranges::contains(out.configs, cfg)) continue; + + ForceHostTooling(*cfg, host, forced); + BuildResult built; + { + Progress::Task task(std::format("Building project dependency {}", cfg->name)); + built = Build(*cfg, depResults, depMutex); + } + if (!built.result.empty()) PragmaError(absProject, pragma.line, std::format("project dependency '{}' failed to build: {}", cfg->name, built.result)); + out.configs.push_back(cfg); + libs.insert(built.libs.begin(), built.libs.end()); + + // The module path needs the dependency and everything it depends + // on, same as GetCompileCommand does for a consumer; the newest + // artifact over the same set is what project.so is stale against. + std::function walk = [&](Configuration* c) { + if (!walked.insert(c).second) return; + out.compileFlags += std::format(" -fprebuilt-module-path={}", c->PcmDir().string()); + std::error_code ec; + const fs::file_time_type t = fs::last_write_time(ExpectedOutput(*c), ec); + if (!ec && t > out.newestArtifact) out.newestArtifact = t; + for (Configuration* sub : c->dependencies) walk(sub); + }; + walk(cfg); + } + for (const std::string& lib : libs) out.linkFlags += std::format(" {}", lib); + return out; + } + + // project.so predates its source, the crafter-build that will load it, or + // a pre-dependency artifact — any of them means recompiling. + bool ProjectFileStale(const fs::path& artifact, const fs::path& project, const fs::path& hostExe, const ProjectFileDependencies& deps) { + if (!fs::exists(artifact)) return true; + const fs::file_time_type built = fs::last_write_time(artifact); + return built < fs::last_write_time(project) || built < fs::last_write_time(hostExe) || built < deps.newestArtifact; + } +} + +Configuration* Crafter::ProjectDependency(std::string_view name) { + if (LoadingFrames.empty()) throw std::runtime_error(std::format("ProjectDependency('{}'): called outside a project file", name)); + const ProjectFileFrame& frame = LoadingFrames.back(); + for (Configuration* cfg : frame.dependencies) { + if (cfg->name == name) return cfg; + for (const std::unique_ptr& iface : cfg->interfaces) { + if (iface->name == name) return cfg; + } + } + std::string declared; + for (Configuration* cfg : frame.dependencies) { + if (!declared.empty()) declared += ", "; + declared += cfg->name; + } + throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared)); +} + #if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32) CommandResult Crafter::RunCommandChecked(std::string_view cmd) { std::array buffer; @@ -482,6 +748,7 @@ namespace { Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { fs::path absProject = fs::canonical(projectFile); + ProjectFileGuard guard(absProject); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) { fs::create_directories(buildDir); @@ -511,7 +778,10 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { fs::path absProject = fs::canonical(projectFile); + ProjectFileGuard guard(absProject); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) fs::create_directories(buildDir); fs::path dllPath = buildDir / std::format("{}.dll", absProject.stem().string()); @@ -713,7 +985,10 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { fs::path absProject = fs::canonical(projectFile); + ProjectFileGuard guard(absProject); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) { fs::create_directories(buildDir); @@ -1012,17 +1291,27 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span.a` / `.lib`, or the shared + // library / DLL. The one rule every "is the consumer older than its + // dependency" check reads, so a staleness test and the link step can't + // disagree about the file name. + CRAFTER_API fs::path ExpectedOutput(const Configuration& config); + // The clang invocation every C++ translation unit in this Configuration is // compiled with, before anything that depends on work having happened // (dependency public flags, external dependency flags — Build appends diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 098455a..c120cc0 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -33,6 +33,14 @@ namespace Crafter { export CRAFTER_API CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout); std::string GetBaseCommand(const Configuration& config); export CRAFTER_API Configuration LoadProject(const fs::path& projectFile, std::span args); + // A dependency the project file currently running its CrafterBuildProject + // declared with `#pragma crafter dependency`, so it can also be wired + // into the build graph (a test that links the same module, say) without + // spelling the source twice. `name` is the dependency's Configuration + // name or any module it exports — whatever the project file `import`s. + // Throws when no such dependency was declared, or when called from + // anywhere other than inside LoadProject. + export CRAFTER_API Configuration* ProjectDependency(std::string_view name); // Resolves the directory holding distributed runtime assets (Crafter.Build // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // derives /share/crafter-build from the running executable's path. diff --git a/project.cpp b/project.cpp index 19e4f59..646740d 100644 --- a/project.cpp +++ b/project.cpp @@ -128,6 +128,8 @@ extern "C" Configuration CrafterBuildProject(std::span a // Same LoadProject wiring as ConcurrentCacheRace above. cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("ProjectDependency").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/ProjectDependency/fixture/app/project.cpp b/tests/ProjectDependency/fixture/app/project.cpp new file mode 100644 index 0000000..329f662 --- /dev/null +++ b/tests/ProjectDependency/fixture/app/project.cpp @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../greeter" +import Greeter; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = Greet(); + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + // Once by the module it exports, once by its configuration name; the + // test expects the same object back for both. + cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") }; + return cfg; +} diff --git a/tests/ProjectDependency/fixture/bad-both/project.cpp b/tests/ProjectDependency/fixture/bad-both/project.cpp new file mode 100644 index 0000000..ca15700 --- /dev/null +++ b/tests/ProjectDependency/fixture/bad-both/project.cpp @@ -0,0 +1,8 @@ +import std; +import Crafter.Build; +#pragma crafter dependency "../greeter" branch "main" commit "0123abc" +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + return Configuration{}; +} diff --git a/tests/ProjectDependency/fixture/bad-nosource/project.cpp b/tests/ProjectDependency/fixture/bad-nosource/project.cpp new file mode 100644 index 0000000..02a9be4 --- /dev/null +++ b/tests/ProjectDependency/fixture/bad-nosource/project.cpp @@ -0,0 +1,8 @@ +import std; +import Crafter.Build; +#pragma crafter dependency +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + return Configuration{}; +} diff --git a/tests/ProjectDependency/fixture/cycle-a/project.cpp b/tests/ProjectDependency/fixture/cycle-a/project.cpp new file mode 100644 index 0000000..c47d270 --- /dev/null +++ b/tests/ProjectDependency/fixture/cycle-a/project.cpp @@ -0,0 +1,12 @@ +import std; +import Crafter.Build; +#pragma crafter dependency "../cycle-b" +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "cycle-a"; + cfg.outputName = "cycle-a"; + return cfg; +} diff --git a/tests/ProjectDependency/fixture/cycle-b/project.cpp b/tests/ProjectDependency/fixture/cycle-b/project.cpp new file mode 100644 index 0000000..c6b1709 --- /dev/null +++ b/tests/ProjectDependency/fixture/cycle-b/project.cpp @@ -0,0 +1,12 @@ +import std; +import Crafter.Build; +#pragma crafter dependency "../cycle-a" +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "cycle-b"; + cfg.outputName = "cycle-b"; + return cfg; +} diff --git a/tests/ProjectDependency/fixture/greeter/interfaces/Greeter-changed.cppm.in b/tests/ProjectDependency/fixture/greeter/interfaces/Greeter-changed.cppm.in new file mode 100644 index 0000000..a046dbc --- /dev/null +++ b/tests/ProjectDependency/fixture/greeter/interfaces/Greeter-changed.cppm.in @@ -0,0 +1,5 @@ +export module Greeter; +import std; +export std::string Greet() { + return "hello again"; +} diff --git a/tests/ProjectDependency/fixture/greeter/interfaces/Greeter.cppm b/tests/ProjectDependency/fixture/greeter/interfaces/Greeter.cppm new file mode 100644 index 0000000..604047e --- /dev/null +++ b/tests/ProjectDependency/fixture/greeter/interfaces/Greeter.cppm @@ -0,0 +1,7 @@ +export module Greeter; +import std; +// Not inline: the string has to come out of libgreeter.a, so a consumer that +// only found the BMI and never linked the archive fails to load. +export std::string Greet() { + return "hello from greeter"; +} diff --git a/tests/ProjectDependency/fixture/greeter/project.cpp b/tests/ProjectDependency/fixture/greeter/project.cpp new file mode 100644 index 0000000..82eb6c1 --- /dev/null +++ b/tests/ProjectDependency/fixture/greeter/project.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// The sibling project the consumer's project.cpp imports a module from. +// ApplyStandardArgs on purpose: it reads CRAFTER_BUILD_MARCH from the +// environment, and the pre-pass has to override whatever it picked. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "greeter"; + cfg.outputName = "greeter"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + std::array ifaces = { "interfaces/Greeter" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/ProjectDependency/main.cpp b/tests/ProjectDependency/main.cpp new file mode 100644 index 0000000..9410585 --- /dev/null +++ b/tests/ProjectDependency/main.cpp @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#include +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// `#pragma crafter dependency`: a project.cpp that imports a module from a +// sibling crafter-build project. LoadProject has to fetch and build that +// project before project.cpp compiles, put its BMI and archive on the +// project.so command line, and treat the archive as an input to the +// staleness check. The fixture is copied to a scratch dir because one case +// edits a source and every build drops bin/ and build/ trees next to it. +// +// A recompiled project.so can't be observed from the process that already +// dlopened the old one — the loader hands back the mapping it has for that +// path — so the "dependency changed" cases load through a child process: +// this binary re-run with `--load ` prints the name the project +// file produced. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } + + // Expect LoadProject to throw; the message, or "" when it didn't. + std::string LoadFails(const fs::path& projectFile) { + std::array noArgs = {}; + try { + (void)LoadProject(projectFile, noArgs); + } catch (const std::exception& e) { + return e.what(); + } + return ""; + } + + // LoadProject in a fresh process; returns what it printed, trimmed. + std::string LoadInChild(const fs::path& projectFile) { + const fs::path self = fs::read_symlink("/proc/self/exe"); + const auto r = RunCommandWithTimeout(std::format("{} --load {}", self.string(), projectFile.string()), std::chrono::seconds(300)); + std::string out = r.output; + while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back(); + if (r.exitCode != 0) return std::format(" {}", r.exitCode, out); + // The dependency build's progress lines share stdout; the name is the + // last line. + const std::size_t lastBreak = out.rfind('\n'); + return lastBreak == std::string::npos ? out : out.substr(lastBreak + 1); + } + + void ChangeGreeting(const fs::path& scratch) { + const fs::path iface = scratch / "greeter" / "interfaces" / "Greeter.cppm"; + fs::copy_file(scratch / "greeter" / "interfaces" / "Greeter-changed.cppm.in", iface, fs::copy_options::overwrite_existing); + // copy_file carries the source's mtime across, which would leave the + // rewritten interface looking older than the BMI built from it. + fs::last_write_time(iface, fs::file_time_type::clock::now()); + } +} + +int main(int argc, char** argv) { + if (argc == 3 && std::string_view(argv[1]) == "--load") { + std::array noArgs = {}; + Configuration cfg = LoadProject(argv[2], noArgs); + std::println("{}", cfg.name); + return 0; + } + + const fs::path installedHome = GetCrafterBuildHome(); + if (!fs::exists(installedHome / "Crafter.Build.cppm")) { + std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string()); + return 77; + } + + const fs::path fixture = fs::current_path() / "tests" / "ProjectDependency" / "fixture"; + const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-dependency"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch); + fs::copy(fixture, scratch, fs::copy_options::recursive); + + // The dependency's project.cpp applies the standard args, which read the + // march from the environment. project.so is compiled for the host, so + // the pre-pass must override it or the dependency's BMIs won't load. + setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1); + + const fs::path app = scratch / "app" / "project.cpp"; + const fs::path appSo = scratch / "app" / "build" / "project.so"; + std::array noArgs = {}; + + // The dependency is fetched, built and linked before project.cpp runs, + // and ProjectDependency() hands it back by either name. + try { + Configuration cfg = LoadProject(app, noArgs); + Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name)); + Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name"); + if (cfg.dependencies.size() == 2) { + Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency"); + const Configuration& dep = *cfg.dependencies[0]; + Check(dep.name == "greeter", "the dependency is the fixture's greeter project"); + Check(dep.target == "x86_64-pc-linux-gnu", "the dependency was built for the host triple LoadProject uses"); + Check(dep.march == "native", "the environment's march was overridden for host tooling"); + Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent"); + Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists"); + } + } catch (const std::exception& e) { + std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); + return 1; + } + Check(fs::exists(appSo), "project.so was produced"); + if (!fs::exists(appSo)) return 1; + const fs::file_time_type firstSo = fs::last_write_time(appSo); + + // Editing the dependency rebuilds it and recompiles project.so against + // it — without touching project.cpp. + ChangeGreeting(scratch); + const std::string changed = LoadInChild(app); + Check(changed == "hello again", std::format("a changed dependency reaches project.cpp (got '{}')", changed)); + Check(fs::last_write_time(appSo) > firstSo, "project.so was recompiled after the dependency changed"); + const fs::file_time_type secondSo = fs::last_write_time(appSo); + + // And an unchanged dependency doesn't: "always stale" would be invisible + // to every other case here. + const std::string unchanged = LoadInChild(app); + Check(unchanged == "hello again", std::format("an unchanged dependency still loads (got '{}')", unchanged)); + Check(fs::last_write_time(appSo) == secondSo, "project.so is left alone when nothing changed"); + + // Directive errors name the file and line. + const std::string noSource = LoadFails(scratch / "bad-nosource" / "project.cpp"); + Check(noSource.contains("project.cpp:3:") && noSource.contains("expected a quoted source"), std::format("a directive without a source is rejected with its location (got '{}')", noSource)); + const std::string both = LoadFails(scratch / "bad-both" / "project.cpp"); + Check(both.contains("project.cpp:3:") && both.contains("mutually exclusive"), std::format("branch and commit together are rejected (got '{}')", both)); + + // Two project files that depend on each other stop instead of recursing. + const std::string cycle = LoadFails(scratch / "cycle-a" / "project.cpp"); + Check(cycle.contains("circular project dependency"), std::format("a dependency cycle is reported (got '{}')", cycle)); + + // The accessor is only meaningful while a project file is running. + try { + (void)ProjectDependency("Greeter"); + Check(false, "ProjectDependency outside a project file throws"); + } catch (const std::exception& e) { + Check(std::string_view(e.what()).contains("outside a project file"), std::format("ProjectDependency outside a project file explains itself (got '{}')", e.what())); + } + + return Failures > 0 ? 1 : 0; +}