Crafter.Build/tests/ProjectDependency/main.cpp
Jorijn van der Graaf e62d33c7f0
test: sibling projects inherit the loading project's args
ProjectArgs loads a chain app -> lib -> leaf of LocalProjects with --debug
and a project flag. lib, loaded without .args, has to see both, and so
does its own sibling, which takes the default one level further down.
Copies loaded with `.args = {}` and with an explicit list get nothing and
exactly that list. The GitProjectSpec default is checked on the spec
itself, since a GitProject would need a repository to clone. Without the
defaults, five of its checks fail.

ProjectDependency now also loads its consumer with a project flag: the
pragma dependency must still come back with no args. Without the explicit
`.args = {}` in the pragma path it gets the consumer's flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:56:15 +01:00

170 lines
8.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;
}
// Host tooling gets none of the consumer's args, although a LocalProject
// spec defaults to them: the consumer's flags would split the tool's
// variant from every other consumer's, and its --target= would have the
// tool's project.cpp choose for the wrong triple.
try {
std::array<std::string_view, 1> flavored = { "--flavor=x" };
Configuration cfg = LoadProject(app, flavored);
const bool found = !cfg.dependencies.empty();
Check(found, "a consumer loaded with args still finds its pragma dependency");
if (found) Check(cfg.dependencies[0]->projectArgs.empty(), std::format("a pragma dependency is loaded without the consumer's args (got {})", cfg.dependencies[0]->projectArgs));
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject with args 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;
}