Crafter.Build/tests/CompilerWarning/main.cpp

163 lines
6.7 KiB
C++
Raw Permalink Normal View History

// 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;
// A compiler warning must not fail the build. Success used to be inferred from
// whether the command printed anything, and a warning is printing something —
// so a translation unit that warns failed the build the moment it was compiled
// and passed on every run after that, because warnings are only emitted when
// something actually recompiles. Same source, opposite verdicts, decided by
// whether an object file happened to be up to date: it read as a flaky test,
// and a cold CI checkout turned every latent warning in a project into a wall
// of unrelated failures.
//
// The fixture puts a #warning in each kind of source the build knows how to
// compile — module interface, module implementation, C, consumer — and a
// linker warning on top, then asserts the build succeeds cold, succeeds when
// there is nothing to do, and succeeds again after each source is touched back
// into staleness. The last pass introduces a real error, because "never fails"
// would satisfy everything above just as well.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// The fixture is mutated during the run, and every assertion here depends
// on units compiling for the first time, so work on a fresh copy outside
// the repo rather than against whatever an earlier run left behind.
fs::path StageFixture() {
fs::path source = fs::current_path() / "tests" / "CompilerWarning" / "fixture";
fs::path staged = fs::temp_directory_path() / "crafter-build-compiler-warning";
fs::remove_all(staged);
fs::copy(source, staged, fs::copy_options::recursive);
return staged;
}
std::unique_ptr<Configuration> MakeLib(const fs::path& staged) {
auto lib = std::make_unique<Configuration>();
lib->path = staged / "lib";
lib->name = "noisy-widget";
lib->outputName = "noisy-widget";
lib->target = HostTarget();
lib->type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "Widget" };
std::array<fs::path, 1> impls = { "Widget" };
lib->GetInterfacesAndImplementations(ifaces, impls);
// cFiles are resolved against the cwd at build time, so spell it out.
lib->cFiles = { staged / "lib" / "counter" };
return lib;
}
Configuration MakeApp(const fs::path& staged, Configuration* lib) {
Configuration app;
app.path = staged;
app.name = "noisy-app";
app.outputName = "noisy-app";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
app.dependencies = { lib };
// The link step reads a command's output the same way a compile does,
// so cover it too: an unknown -z value makes ld.lld warn and exit 0.
// GNU-style only — lld-link spells its options differently — so the
// link case rides along on hosts that use it and is skipped elsewhere.
if (!app.target.contains("windows")) {
app.linkFlags = { "-Wl,-z,crafter-build-nonexistent-z-value" };
}
return app;
}
BuildResult BuildOnce(Configuration& app) {
// A fresh depResults per pass: the map memoizes each Configuration's
// build for the duration of one pass, so reusing it would skip the
// library's rebuild entirely.
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
return Build(app, depResults, depMutex);
}
bool BuildOk(Configuration& app, std::string_view label) {
BuildResult r = BuildOnce(app);
if (!r.result.empty()) {
std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result);
++Failures;
return false;
}
return true;
}
void CheckRun(const fs::path& binary, std::string_view label) {
auto r = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(r.exitCode == 0 && !r.crashed && !r.timedOut && r.output == "42 7", std::format("{}: expected '42 7', got '{}' (exit={})", label, r.output, r.exitCode));
}
void Touch(const fs::path& source) {
fs::last_write_time(source, fs::file_time_type::clock::now());
}
}
int main() {
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app = MakeApp(staged, lib.get());
fs::path binary = app.BinDir() / "noisy-app";
// Cold: every unit compiles, so every #warning in the fixture is emitted
// and the linker warns as well. This is the pass that used to fail.
if (!BuildOk(app, "cold")) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
CheckRun(binary, "cold");
// Nothing to recompile, so nothing warns. Same sources as the pass above,
// and the verdict has to match it.
if (BuildOk(app, "idle")) {
CheckRun(binary, "idle");
}
// Back into staleness one source at a time: whichever compile path a
// warning comes out of, it stays non-fatal.
for (auto [source, label] : std::initializer_list<std::pair<fs::path, std::string_view>>{
{ staged / "lib" / "Widget.cppm", "interface unit" },
{ staged / "lib" / "Widget.cpp", "implementation unit" },
{ staged / "lib" / "counter.c", "C source" },
{ staged / "main.cpp", "consumer" },
}) {
Touch(source);
if (BuildOk(app, label)) {
CheckRun(binary, label);
}
}
// The other direction, last because it leaves the fixture broken: a real
// error still has to fail, and still has to say what went wrong.
{
std::ofstream broken(staged / "main.cpp", std::ios::binary | std::ios::trunc);
broken << "int main() { return NotDeclaredAnywhere(); }\n";
broken.close();
Touch(staged / "main.cpp");
BuildResult r = BuildOnce(app);
Check(!r.result.empty(), "a source that fails to compile fails the build");
Check(r.result.contains("error:"), std::format("the failure reports the compiler's diagnostic, got '{}'", r.result));
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}