Crafter.Build/tests/ProjectDependency/main.cpp
Jorijn van der Graaf be88a194f4
fix: let a project file ask whether it is the top-level project
Crafter.LintRules declares a test that links crafter-build, fetched
with GitProject. crafter-build's own project.cpp loads Crafter.LintRules
as a `#pragma crafter dependency`, so the chain was: crafter-build loads
LintRules, LintRules' test wiring loads crafter-build's project.cpp from
the external cache, which loads LintRules again, and the cycle guard
threw. Every command that loads project.cpp in this repository has
failed since that test was added, including the last CI run on master.

A project file had no way to tell whether it was the one the user
pointed crafter-build at or a dependency being loaded for somebody
else, so it could not skip wiring that only its own build needs.
IsTopLevelProject() answers that from the loading-frame stack
ProjectDependency() already reads: one frame is the top-level project
file, a second is only ever pushed for a dependency. Outside any project
file it is true as well, which is what a test harness constructing
Configurations expects.

The ProjectDependency test records the answer from both the app and the
greeter project files and checks each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:02:40 +02:00

155 lines
8 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <stdlib.h>
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 <project.cpp>` 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<std::string_view, 0> 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("<exit {}> {}", 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<std::string_view, 0> 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<std::string_view, 0> noArgs = {};
Check(IsTopLevelProject(), "outside any project file counts as top level");
// 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(std::ranges::contains(cfg.compileFlags, "-DTOP_LEVEL=1"), "the project LoadProject was pointed at sees itself as top level");
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 == HostTarget(), "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(std::ranges::contains(dep.compileFlags, "-DTOP_LEVEL=0"), "a dependency's project file does not see itself as top level");
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;
}