163 lines
6.7 KiB
C++
163 lines
6.7 KiB
C++
|
|
// 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;
|
||
|
|
}
|