// 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 shader's output is stale when anything that went into it changed, not // only its own source. Check() used to compare the .spv against the source's // mtime alone, so: // - editing a file the shader #includes left the .spv built against the old // contents, the GLSL twin of IncrementalHeaderChange; // - changing an entry's defines without touching the source did too. // Each pass changes one thing and checks both directions: the shader it // reaches is recompiled, to new contents, and the shader that includes // nothing is left alone. A build with nothing changed compiles nothing. 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 edited between passes, so work on a copy outside the repo. fs::path StageFixture() { fs::path source = fs::current_path() / "tests" / "ShaderIncludeChange" / "fixture"; fs::path staged = fs::temp_directory_path() / "crafter-build-shader-include-change"; fs::remove_all(staged); fs::copy(source, staged, fs::copy_options::recursive); return staged; } Configuration MakeApp(const fs::path& staged) { Configuration app; app.path = staged; app.name = "lit"; app.outputName = "lit"; app.target = HostTarget(); app.type = ConfigurationType::Executable; std::array ifaces = {}; std::array impls = { "main" }; app.GetInterfacesAndImplementations(ifaces, impls); app.shaders.emplace_back(staged / "lit.frag", std::string("main"), ShaderType::Fragment); app.shaders.emplace_back(staged / "plain.frag", std::string("main"), ShaderType::Fragment); // Puts include/ on the shaders' #include path. app.buildFiles = { staged / "include" / "palette.glsl" }; return app; } bool BuildOk(Configuration& app, std::string_view label) { // A fresh depResults per pass: the map memoizes a Configuration's // build for the duration of one pass. std::unordered_map> depResults; std::mutex depMutex; BuildResult r = Build(app, depResults, depMutex); Check(r.result.empty(), std::format("{} builds: {}", label, r.result)); return r.result.empty(); } std::string ReadAll(const fs::path& file) { std::ifstream stream(file, std::ios::binary); return std::string(std::istreambuf_iterator(stream), std::istreambuf_iterator()); } } int main() { const fs::path staged = StageFixture(); Configuration app = MakeApp(staged); const fs::path lit = app.BinDir() / "lit.spv"; const fs::path plain = app.BinDir() / "plain.spv"; const fs::path palette = staged / "include" / "palette.glsl"; // Pass 1: everything from scratch. if (!BuildOk(app, "the first pass")) return 1; Check(fs::exists(lit) && fs::exists(plain), "both shaders are in the bin dir"); const fs::file_time_type plainBuilt = fs::last_write_time(plain); const std::string green = ReadAll(lit); // Pass 2: nothing changed. fs::file_time_type litBuilt = fs::last_write_time(lit); BuildOk(app, "an unchanged project"); Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed, no shader is recompiled"); // Pass 3: the included file changes. Dated just after the output it must // outdate, so the rebuilt output is newer than it again. { std::ofstream out(palette, std::ios::trunc); out << "const vec4 kTint = vec4(1.0, 0.0, 0.0, 1.0);\n"; } fs::last_write_time(palette, litBuilt + std::chrono::milliseconds(1)); BuildOk(app, "an edited include"); const std::string red = ReadAll(lit); Check(fs::last_write_time(lit) > litBuilt && red != green, "editing an included file recompiles the shader that includes it, to new contents"); Check(fs::last_write_time(plain) == plainBuilt, "and leaves the shader that includes nothing alone"); // Pass 4: the entry's defines change, no file does. app.shaders[0].defines = { "LIT_DIM" }; litBuilt = fs::last_write_time(lit); BuildOk(app, "a new define"); const std::string dim = ReadAll(lit); Check(fs::last_write_time(lit) > litBuilt && dim != red, "a define added to the entry recompiles it, taking the #ifdef branch"); Check(fs::last_write_time(plain) == plainBuilt, "and leaves the other entry alone"); // Pass 5: and back. app.shaders[0].defines = {}; BuildOk(app, "the define taken out"); Check(ReadAll(lit) == red, "taking the define out again recompiles it as it was"); // Pass 6: nothing changed again; what the passes above recorded holds. litBuilt = fs::last_write_time(lit); BuildOk(app, "an unchanged project, again"); Check(fs::last_write_time(lit) == litBuilt && fs::last_write_time(plain) == plainBuilt, "with nothing changed since, no shader is recompiled"); std::error_code ec; fs::remove_all(staged, ec); if (failures > 0) { std::println(std::cerr, "{} assertions failed", failures); return 1; } return 0; }