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; +}