2026-07-23 01:24:42 +02:00
|
|
|
|
// SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
2026-04-27 07:04:42 +02:00
|
|
|
|
|
|
|
|
|
|
module Crafter.Build:External_impl;
|
|
|
|
|
|
import std;
|
|
|
|
|
|
import :External;
|
|
|
|
|
|
import :Platform;
|
2026-04-30 02:20:19 +02:00
|
|
|
|
import :Progress;
|
|
|
|
|
|
import :Clang;
|
2026-04-27 07:04:42 +02:00
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
|
|
using namespace Crafter;
|
|
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
|
|
std::string DeriveName(const GitSource& source) {
|
|
|
|
|
|
std::string url = source.url;
|
|
|
|
|
|
while (!url.empty() && (url.back() == '/' || url.back() == '\\')) url.pop_back();
|
|
|
|
|
|
if (url.ends_with(".git")) url.resize(url.size() - 4);
|
|
|
|
|
|
auto slash = url.find_last_of("/\\");
|
|
|
|
|
|
return slash == std::string::npos ? url : url.substr(slash + 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string ShellQuote(std::string_view s) {
|
2026-04-30 03:50:06 +02:00
|
|
|
|
// Windows cmd treats single quotes as literal characters — only double
|
|
|
|
|
|
// quotes work. Paths don't contain " in practice, so a simple wrap
|
|
|
|
|
|
// suffices; backslashes inside the path stay backslashes.
|
|
|
|
|
|
#ifdef _WIN32
|
|
|
|
|
|
std::string out = "\"";
|
|
|
|
|
|
for (char c : s) {
|
|
|
|
|
|
if (c == '"') out += "\\\"";
|
|
|
|
|
|
else out += c;
|
|
|
|
|
|
}
|
|
|
|
|
|
out += "\"";
|
|
|
|
|
|
return out;
|
|
|
|
|
|
#else
|
2026-04-27 07:04:42 +02:00
|
|
|
|
std::string out = "'";
|
|
|
|
|
|
for (char c : s) {
|
|
|
|
|
|
if (c == '\'') out += "'\\''";
|
|
|
|
|
|
else out += c;
|
|
|
|
|
|
}
|
|
|
|
|
|
out += "'";
|
|
|
|
|
|
return out;
|
2026-04-30 03:50:06 +02:00
|
|
|
|
#endif
|
2026-04-27 07:04:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string JoinOptions(std::span<const std::string> options) {
|
|
|
|
|
|
std::string out;
|
|
|
|
|
|
for (const std::string& opt : options) {
|
|
|
|
|
|
if (!out.empty()) out += ' ';
|
|
|
|
|
|
out += ShellQuote(opt);
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string FetchGit(const GitSource& source, const fs::path& cloneDir) {
|
|
|
|
|
|
auto runGit = [](std::string_view cmd) -> std::optional<std::string> {
|
|
|
|
|
|
CommandResult r = RunCommandChecked(cmd);
|
|
|
|
|
|
if (r.exitCode != 0) return std::format("`{}` failed (exit {}): {}", cmd, r.exitCode, r.output);
|
|
|
|
|
|
return std::nullopt;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (fs::exists(cloneDir)) {
|
|
|
|
|
|
CommandResult remote = RunCommandChecked(std::format("git -C {} remote get-url origin", ShellQuote(cloneDir.string())));
|
|
|
|
|
|
std::string currentUrl = remote.output;
|
|
|
|
|
|
while (!currentUrl.empty() && (currentUrl.back() == '\n' || currentUrl.back() == '\r')) currentUrl.pop_back();
|
|
|
|
|
|
if (remote.exitCode != 0 || currentUrl != source.url) {
|
|
|
|
|
|
fs::remove_all(cloneDir);
|
|
|
|
|
|
} else if (!source.commit.empty()) {
|
|
|
|
|
|
CommandResult head = RunCommandChecked(std::format("git -C {} rev-parse HEAD", ShellQuote(cloneDir.string())));
|
|
|
|
|
|
std::string currentHead = head.output;
|
|
|
|
|
|
while (!currentHead.empty() && (currentHead.back() == '\n' || currentHead.back() == '\r')) currentHead.pop_back();
|
|
|
|
|
|
if (head.exitCode == 0 && currentHead != source.commit) {
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} fetch origin", ShellQuote(cloneDir.string())))) return *err;
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} checkout {}", ShellQuote(cloneDir.string()), ShellQuote(source.commit)))) return *err;
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
} else {
|
2026-04-30 02:20:19 +02:00
|
|
|
|
// No commit pinned — pull latest. The user opted into "track latest"
|
|
|
|
|
|
// by not pinning; the cost is one network round-trip per build.
|
|
|
|
|
|
// Pin source.commit to a SHA for reproducible, offline-capable builds.
|
|
|
|
|
|
if (!source.branch.empty()) {
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} pull origin {}", ShellQuote(cloneDir.string()), ShellQuote(source.branch)))) return *err;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} pull", ShellQuote(cloneDir.string())))) return *err;
|
|
|
|
|
|
}
|
2026-04-27 07:04:42 +02:00
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 01:55:29 +00:00
|
|
|
|
// Random per-invocation tmp suffix so concurrent crafter-build processes
|
|
|
|
|
|
// resolving to the same cache key don't share an in-flight `.tmp`
|
|
|
|
|
|
// directory — git's pack tempfiles would get yanked when one process
|
|
|
|
|
|
// removes or renames the dir out from under the other.
|
|
|
|
|
|
std::random_device rd;
|
2026-04-27 07:04:42 +02:00
|
|
|
|
fs::path tmpDir = cloneDir;
|
2026-05-27 01:55:29 +00:00
|
|
|
|
tmpDir += std::format(".tmp.{:08x}{:08x}", rd(), rd());
|
2026-04-27 07:04:42 +02:00
|
|
|
|
|
|
|
|
|
|
if (auto err = runGit(std::format("git clone --recursive {} {}", ShellQuote(source.url), ShellQuote(tmpDir.string())))) {
|
|
|
|
|
|
if (fs::exists(tmpDir)) fs::remove_all(tmpDir);
|
|
|
|
|
|
return *err;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!source.branch.empty()) {
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} switch {}", ShellQuote(tmpDir.string()), ShellQuote(source.branch)))) {
|
|
|
|
|
|
fs::remove_all(tmpDir);
|
|
|
|
|
|
return *err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!source.commit.empty()) {
|
|
|
|
|
|
if (auto err = runGit(std::format("git -C {} checkout {}", ShellQuote(tmpDir.string()), ShellQuote(source.commit)))) {
|
|
|
|
|
|
fs::remove_all(tmpDir);
|
|
|
|
|
|
return *err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
fs::rename(tmpDir, cloneDir, ec);
|
|
|
|
|
|
if (ec) {
|
|
|
|
|
|
fs::remove_all(tmpDir);
|
2026-05-27 01:55:29 +00:00
|
|
|
|
// If a concurrent build won the race, cloneDir now exists with the
|
|
|
|
|
|
// same source revision (cache key includes url+branch+commit), so
|
|
|
|
|
|
// discard our clone and reuse theirs.
|
|
|
|
|
|
if (fs::exists(cloneDir)) return "";
|
2026-04-27 07:04:42 +02:00
|
|
|
|
return std::format("rename {} -> {} failed: {}", tmpDir.string(), cloneDir.string(), ec.message());
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
std::string BuildInjectedCMakeFlags(const fs::path& cmakeBuildDir, std::string_view target) {
|
2026-04-27 07:04:42 +02:00
|
|
|
|
std::string out;
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
// Without an explicit build type, some packages (glslang) append a 'd'
|
|
|
|
|
|
// suffix to library names assuming a debug build, breaking -l<name>
|
|
|
|
|
|
// linking. Pin to Release.
|
|
|
|
|
|
out += " -DCMAKE_BUILD_TYPE=Release";
|
2026-04-27 07:04:42 +02:00
|
|
|
|
out += " -DCMAKE_C_COMPILER=clang";
|
|
|
|
|
|
out += " -DCMAKE_CXX_COMPILER=clang++";
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
// Stdlib choice: libc++ for the Linux host build (matches how crafter-build
|
|
|
|
|
|
// itself is built with -stdlib=libc++); mingw cross-compile uses libstdc++
|
|
|
|
|
|
// shipped by the mingw-w64 toolchain so we leave the default; Windows
|
|
|
|
|
|
// native (msvc target) similarly uses libc++ via LIBCXX_DIR.
|
2026-04-27 07:04:42 +02:00
|
|
|
|
#ifdef CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_linux_gnu
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
if (target == "x86_64-pc-linux-gnu") {
|
|
|
|
|
|
out += " -DCMAKE_CXX_FLAGS=-stdlib=libc++";
|
|
|
|
|
|
out += " -DCMAKE_EXE_LINKER_FLAGS=-stdlib=libc++";
|
|
|
|
|
|
out += " -DCMAKE_SHARED_LINKER_FLAGS=-stdlib=libc++";
|
|
|
|
|
|
}
|
2026-04-27 07:04:42 +02:00
|
|
|
|
#endif
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
if (target == "x86_64-w64-mingw32") {
|
|
|
|
|
|
out += " -DCMAKE_SYSTEM_NAME=Windows";
|
|
|
|
|
|
out += std::format(" -DCMAKE_C_COMPILER_TARGET={}", target);
|
|
|
|
|
|
out += std::format(" -DCMAKE_CXX_COMPILER_TARGET={}", target);
|
|
|
|
|
|
}
|
2026-04-27 07:04:42 +02:00
|
|
|
|
fs::path absBuildDir = fs::absolute(cmakeBuildDir);
|
|
|
|
|
|
out += std::format(" -DCMAKE_ARCHIVE_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
|
|
|
|
|
out += std::format(" -DCMAKE_LIBRARY_OUTPUT_DIRECTORY={}", ShellQuote(absBuildDir.string()));
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
std::string ConfigureCMake(const fs::path& cloneDir, const fs::path& cmakeBuildDir, std::span<const std::string> options, std::string_view target) {
|
2026-04-27 07:04:42 +02:00
|
|
|
|
fs::path optionsFile = cmakeBuildDir / ".crafter-options";
|
|
|
|
|
|
std::string optionsKey = JoinOptions(options);
|
|
|
|
|
|
|
|
|
|
|
|
bool needsConfigure = !fs::exists(cmakeBuildDir / "CMakeCache.txt");
|
|
|
|
|
|
if (!needsConfigure) {
|
|
|
|
|
|
std::ifstream existing(optionsFile);
|
|
|
|
|
|
if (!existing) {
|
|
|
|
|
|
needsConfigure = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
std::stringstream buf;
|
|
|
|
|
|
buf << existing.rdbuf();
|
|
|
|
|
|
if (buf.str() != optionsKey) needsConfigure = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!needsConfigure) return "";
|
|
|
|
|
|
|
|
|
|
|
|
if (!fs::exists(cmakeBuildDir)) fs::create_directories(cmakeBuildDir);
|
|
|
|
|
|
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::string cmd = std::format("cmake -S {} -B {}{}", ShellQuote(fs::absolute(cloneDir).string()), ShellQuote(fs::absolute(cmakeBuildDir).string()), BuildInjectedCMakeFlags(cmakeBuildDir, target));
|
2026-04-27 07:04:42 +02:00
|
|
|
|
if (!options.empty()) {
|
|
|
|
|
|
cmd += " ";
|
|
|
|
|
|
cmd += optionsKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
CommandResult r = RunCommandChecked(cmd);
|
|
|
|
|
|
if (r.exitCode != 0) {
|
|
|
|
|
|
return std::format("cmake configure failed (exit {}): {}", r.exitCode, r.output);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::ofstream(optionsFile) << optionsKey;
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string BuildCMake(const fs::path& cmakeBuildDir) {
|
2026-06-01 20:38:00 +00:00
|
|
|
|
// Without --parallel, the Unix Makefiles generator builds one translation
|
|
|
|
|
|
// unit at a time, leaving every core but one idle. Pass an explicit job
|
|
|
|
|
|
// count (a bare --parallel maps to an unbounded `make -j` fork bomb on the
|
|
|
|
|
|
// Makefiles generator) so deps like DPP/msquic/glslang compile ~N× faster.
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::uint32_t jobs = std::max(1u, std::thread::hardware_concurrency());
|
|
|
|
|
|
std::string cmd = std::format("cmake --build {} --parallel {}", ShellQuote(fs::absolute(cmakeBuildDir).string()), jobs);
|
2026-04-27 07:04:42 +02:00
|
|
|
|
CommandResult r = RunCommandChecked(cmd);
|
|
|
|
|
|
if (r.exitCode != 0) {
|
|
|
|
|
|
return std::format("cmake --build failed (exit {}): {}", r.exitCode, r.output);
|
|
|
|
|
|
}
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
feat(lint): no-char-pointer reads the AST, retiring the interop denylist
The rule was `\bchar\s*\*` over the text minus a substring denylist — argv,
getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry
was a patch for one interop site, the list could only grow as libraries
arrived, and each entry disabled the rule for the whole LINE it appeared on.
It now walks declarations and asks the question the denylist was approximating:
whose header dictates this spelling? A declaration with C language linkage, or
one whose initialiser binds to an entity declared outside the project root, is
somebody else's API and keeps its spelling. getenv, c_str and friends are
exempt because of where they are declared, not because they are named here, so
a new external library needs no new entry.
Two bugs found while testing this, both of which had made the rule silently
pass over the entire repository:
clang_getCursorLanguage cannot be used to detect extern "C". Its default answer
is CXLanguage_C for a plain function, variable or parameter even in a C++
translation unit, so isExternC was true almost everywhere and exempted
everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk,
reading the extent text to tell extern "C" from extern "C++".
Attributing any foreign reference in a subtree to the enclosing declaration was
too broad: a function that merely touched libc++ somewhere in its body would
exempt its own signature. Narrowed to initialiser contexts — a variable, field
or parameter — which is where a binding to a foreign API actually occurs.
Also: functions now carry their RESULT type rather than the whole function
type, since the parameters arrive as their own declarations and would otherwise
be reported twice. main's parameters are exempt structurally, its signature
being fixed by the language rather than chosen here.
Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv
verbatim. That is two visible, reasoned suppressions in place of a denylist
that silently disabled the rule for every line mentioning one of eight tokens.
ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a
source that includes an external dependency's headers can be parsed without
running a build to discover where they are. BuildExternal derives its own
working directory through the same function, so the two cannot drift.
Crafter.Build-Shader.cpp needed this to parse at all.
A file with no compile command — project.cpp, which LoadProject builds with its
own flags — is not a translation unit of the build graph, so AST rules skip it
the way a rule self-filters by extension. That is distinct from a file that
should have parsed and did not, which stays an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
|
|
|
|
fs::path Crafter::ExternalCloneDir(const ExternalDependency& dep, std::string_view target) {
|
|
|
|
|
|
std::string name = dep.name.empty() ? DeriveName(dep.source) : dep.name;
|
|
|
|
|
|
if (name.empty()) return {};
|
|
|
|
|
|
std::string keyMaterial = std::format("{}|{}|{}|{}|{}", dep.source.url, dep.source.branch, dep.source.commit, JoinOptions(dep.options), target);
|
|
|
|
|
|
std::size_t key = std::hash<std::string>{}(keyMaterial);
|
|
|
|
|
|
return GetCacheDir() / "external" / std::format("{}-{:016x}", name, key);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<std::string> Crafter::ExternalIncludeFlags(const ExternalDependency& dep, std::string_view target) {
|
|
|
|
|
|
std::vector<std::string> flags;
|
|
|
|
|
|
fs::path cloneDir = ExternalCloneDir(dep, target);
|
|
|
|
|
|
if (cloneDir.empty()) return flags;
|
|
|
|
|
|
for (const fs::path& include : dep.includeDirs) {
|
|
|
|
|
|
fs::path full = include.empty() ? cloneDir : cloneDir / include;
|
|
|
|
|
|
flags.push_back(std::format("-I{}", fs::absolute(full).string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
return flags;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-23 01:24:42 +02:00
|
|
|
|
ExternalBuildResult Crafter::BuildExternal(const ExternalDependency& dep, std::string_view target, std::atomic<bool>& cancelled) {
|
2026-04-27 07:04:42 +02:00
|
|
|
|
ExternalBuildResult result;
|
|
|
|
|
|
|
|
|
|
|
|
if (cancelled.load(std::memory_order_relaxed)) return result;
|
|
|
|
|
|
|
|
|
|
|
|
std::string name = dep.name.empty() ? DeriveName(dep.source) : dep.name;
|
|
|
|
|
|
if (name.empty()) {
|
|
|
|
|
|
result.error = std::format("Could not derive name for external dependency from URL '{}'", dep.source.url);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fs::path externalRoot = GetCacheDir() / "external";
|
|
|
|
|
|
if (!fs::exists(externalRoot)) {
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
fs::create_directories(externalRoot, ec);
|
|
|
|
|
|
if (ec) {
|
|
|
|
|
|
result.error = std::format("Failed to create {}: {}", externalRoot.string(), ec.message());
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(lint): no-char-pointer reads the AST, retiring the interop denylist
The rule was `\bchar\s*\*` over the text minus a substring denylist — argv,
getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry
was a patch for one interop site, the list could only grow as libraries
arrived, and each entry disabled the rule for the whole LINE it appeared on.
It now walks declarations and asks the question the denylist was approximating:
whose header dictates this spelling? A declaration with C language linkage, or
one whose initialiser binds to an entity declared outside the project root, is
somebody else's API and keeps its spelling. getenv, c_str and friends are
exempt because of where they are declared, not because they are named here, so
a new external library needs no new entry.
Two bugs found while testing this, both of which had made the rule silently
pass over the entire repository:
clang_getCursorLanguage cannot be used to detect extern "C". Its default answer
is CXLanguage_C for a plain function, variable or parameter even in a C++
translation unit, so isExternC was true almost everywhere and exempted
everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk,
reading the extent text to tell extern "C" from extern "C++".
Attributing any foreign reference in a subtree to the enclosing declaration was
too broad: a function that merely touched libc++ somewhere in its body would
exempt its own signature. Narrowed to initialiser contexts — a variable, field
or parameter — which is where a binding to a foreign API actually occurs.
Also: functions now carry their RESULT type rather than the whole function
type, since the parameters arrive as their own declarations and would otherwise
be reported twice. main's parameters are exempt structurally, its signature
being fixed by the language rather than chosen here.
Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv
verbatim. That is two visible, reasoned suppressions in place of a denylist
that silently disabled the rule for every line mentioning one of eight tokens.
ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a
source that includes an external dependency's headers can be parsed without
running a build to discover where they are. BuildExternal derives its own
working directory through the same function, so the two cannot drift.
Crafter.Build-Shader.cpp needed this to parse at all.
A file with no compile command — project.cpp, which LoadProject builds with its
own flags — is not a translation unit of the build graph, so AST rules skip it
the way a rule self-filters by extension. That is distinct from a file that
should have parsed and did not, which stays an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
|
|
|
|
fs::path cloneDir = ExternalCloneDir(dep, target);
|
2026-04-27 07:04:42 +02:00
|
|
|
|
|
|
|
|
|
|
std::string fetchErr = FetchGit(dep.source, cloneDir);
|
|
|
|
|
|
if (!fetchErr.empty()) {
|
|
|
|
|
|
result.error = std::format("git fetch for '{}': {}", name, fetchErr);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cancelled.load(std::memory_order_relaxed)) return result;
|
|
|
|
|
|
|
|
|
|
|
|
fs::path cmakeBuildDir;
|
|
|
|
|
|
if (dep.builder == ExternalBuilder::CMake) {
|
|
|
|
|
|
cmakeBuildDir = cloneDir / "build";
|
V2: WASI, -r flag, CI pipeline, examples & tests cleanup
WASI / wasm32 target support
- Auto-detect /usr/share/wasi-sysroot on Linux when target starts_with("wasm32")
- Skip -march/-mtune for wasm (clang rejects them)
- Apply -fno-exceptions -fno-c++-static-destructors -mllvm -wasm-enable-sjlj
-D_WASI_EMULATED_SIGNAL to wasm builds (compile + std PCM, kept in sync)
- .wasm output extension in expectedOutputFor and link command
- EnableWasiBrowserRuntime(cfg): opt-in helper that drops index.html +
runtime.js next to the .wasm; runtime.js reads window.CRAFTER_WASM_URL
set in the templated index.html so a single shim handles any output name
-r run flag in the CLI: build then exec the artifact (host targets only;
rejects libraries; auto .exe/.wasm extension handling)
CI pipeline (.forgejo/workflows/ci.yaml)
- Triggers: PR/push to master + manual dispatch
- Single arch-latest container job: install deps, bootstrap, self-rebuild,
run tests, cross-compile mingw, package both archives, upload artifacts
- Rolling 'latest' release published only on push/dispatch to master
mingw cross-compile from Linux now works end-to-end:
- ExternalDependency cache key includes target so per-target glslang builds
don't collide; CMAKE_BUILD_TYPE=Release pinned (otherwise glslang appends
'd' to lib names and breaks linking); cross-compile cmake flags
(CMAKE_SYSTEM_NAME=Windows, CMAKE_*_COMPILER_TARGET=...)
- project.cpp accepts --target=<triple>; Linux-only -Wl,--export-dynamic
and -ldl are gated; mingw glslang skips the standalone exe (its libgcc_eh
link pulls pthread which mingw doesn't link by default)
- mingw compile uses -femulated-tls so std::__once_callable etc reference
the same emutls symbols libstdc++ provides
- mingw link auto-adds -lstdc++exp -lpthread
GetCrafterBuildHome() exposed from the Platform module; LoadProject (Linux
+ Windows) now both use it instead of duplicating the resolution.
Examples reorg: hello-world, library, with-module, wasi, tests — each with
its own README. Tests reorg: per-test directory with inner/ fixture, no
shared tests/fixtures/ tree. New Wasi test verifies .wasm magic bytes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 23:24:46 +02:00
|
|
|
|
if (std::string err = ConfigureCMake(cloneDir, cmakeBuildDir, dep.options, target); !err.empty()) {
|
2026-04-27 07:04:42 +02:00
|
|
|
|
result.error = std::format("cmake configure for '{}': {}", name, err);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cancelled.load(std::memory_order_relaxed)) return result;
|
|
|
|
|
|
if (std::string err = BuildCMake(cmakeBuildDir); !err.empty()) {
|
|
|
|
|
|
result.error = std::format("cmake build for '{}': {}", name, err);
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(lint): no-char-pointer reads the AST, retiring the interop denylist
The rule was `\bchar\s*\*` over the text minus a substring denylist — argv,
getenv, setenv, dlerror, c_str, .data(, reinterpret_cast, extern ". Every entry
was a patch for one interop site, the list could only grow as libraries
arrived, and each entry disabled the rule for the whole LINE it appeared on.
It now walks declarations and asks the question the denylist was approximating:
whose header dictates this spelling? A declaration with C language linkage, or
one whose initialiser binds to an entity declared outside the project root, is
somebody else's API and keeps its spelling. getenv, c_str and friends are
exempt because of where they are declared, not because they are named here, so
a new external library needs no new entry.
Two bugs found while testing this, both of which had made the rule silently
pass over the entire repository:
clang_getCursorLanguage cannot be used to detect extern "C". Its default answer
is CXLanguage_C for a plain function, variable or parameter even in a C++
translation unit, so isExternC was true almost everywhere and exempted
everything. Replaced by tracking CXCursor_LinkageSpec depth during the walk,
reading the extent text to tell extern "C" from extern "C++".
Attributing any foreign reference in a subtree to the enclosing declaration was
too broad: a function that merely touched libc++ somewhere in its body would
exempt its own signature. Narrowed to initialiser contexts — a variable, field
or parameter — which is where a binding to a foreign API actually occurs.
Also: functions now carry their RESULT type rather than the whole function
type, since the parameters arrive as their own declarations and would otherwise
be reported twice. main's parameters are exempt structurally, its signature
being fixed by the language rather than chosen here.
Two sites keep an explicit lint-disable, both Crafter::Run taking main's argv
verbatim. That is two visible, reasoned suppressions in place of a denylist
that silently disabled the rule for every line mentioning one of eight tokens.
ExternalCloneDir and ExternalIncludeFlags are now exposed from :External, so a
source that includes an external dependency's headers can be parsed without
running a build to discover where they are. BuildExternal derives its own
working directory through the same function, so the two cannot drift.
Crafter.Build-Shader.cpp needed this to parse at all.
A file with no compile command — project.cpp, which LoadProject builds with its
own flags — is not a translation unit of the build graph, so AST rules skip it
the way a rule self-filters by extension. That is distinct from a file that
should have parsed and did not, which stays an error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:32 +02:00
|
|
|
|
result.compileFlags = ExternalIncludeFlags(dep, target);
|
2026-04-27 07:04:42 +02:00
|
|
|
|
|
|
|
|
|
|
if (dep.builder == ExternalBuilder::CMake) {
|
2026-05-06 03:59:19 +02:00
|
|
|
|
// Each search path gets both a -L (link-time) and a -Wl,-rpath
|
|
|
|
|
|
// (runtime-loader). The rpath stays embedded in the produced
|
|
|
|
|
|
// binary so it picks up shared deps from the cache without any
|
|
|
|
|
|
// LD_LIBRARY_PATH gymnastics. Static deps (.a) ignore the rpath
|
2026-05-27 05:43:56 +02:00
|
|
|
|
// harmlessly. PE/COFF targets (mingw, msvc) resolve DLLs via PATH
|
|
|
|
|
|
// or adjacency rather than rpath, so lld warns if we pass it.
|
feat(lint): const-local and constexpr-constant rules
Two rules the AST makes possible, plus the mutation analysis behind them.
const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction is what makes
the answer exact rather than a guess: a scalar has no member functions, so the
only ways to write one are assignment, ++/--, having its address taken, or
binding to a non-const reference. All four are now tracked in the walk:
- assignment and compound assignment visit their LEFT operand in a write
context, the right one normally;
- ++/-- and & write their operand;
- a call argument is checked against the callee's parameter type, so passing
to `const int&` or by value is a read while `int&` is a write;
- initialising a non-const reference writes what it binds to.
For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.
constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.
On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.
Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:50:48 +02:00
|
|
|
|
const bool isPe = target == "x86_64-w64-mingw32" || target == "x86_64-pc-windows-msvc";
|
2026-05-06 03:59:19 +02:00
|
|
|
|
std::string buildDirAbs = fs::absolute(cmakeBuildDir).string();
|
|
|
|
|
|
result.linkFlags.push_back(std::format("-L{}", buildDirAbs));
|
2026-05-27 05:43:56 +02:00
|
|
|
|
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", buildDirAbs));
|
2026-05-06 03:59:19 +02:00
|
|
|
|
for (const fs::path& libDir : dep.libDirs) {
|
|
|
|
|
|
fs::path full = libDir.is_absolute() ? libDir : cmakeBuildDir / libDir;
|
|
|
|
|
|
std::string fullAbs = fs::absolute(full).string();
|
|
|
|
|
|
result.linkFlags.push_back(std::format("-L{}", fullAbs));
|
2026-05-27 05:43:56 +02:00
|
|
|
|
if (!isPe) result.linkFlags.push_back(std::format("-Wl,-rpath,{}", fullAbs));
|
2026-05-06 03:59:19 +02:00
|
|
|
|
}
|
2026-04-27 07:04:42 +02:00
|
|
|
|
for (const std::string& lib : dep.libs) {
|
|
|
|
|
|
result.linkFlags.push_back(std::format("-l{}", lib));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (fs::exists(cmakeBuildDir)) {
|
|
|
|
|
|
for (const auto& entry : fs::directory_iterator(cmakeBuildDir)) {
|
|
|
|
|
|
if (!entry.is_regular_file()) continue;
|
|
|
|
|
|
const fs::path& p = entry.path();
|
|
|
|
|
|
if (p.extension() != ".a" && p.extension() != ".so") continue;
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
fs::file_time_type t = entry.last_write_time(ec);
|
|
|
|
|
|
if (!ec && t > result.latestArtifact) result.latestArtifact = t;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
2026-04-30 02:20:19 +02:00
|
|
|
|
|
|
|
|
|
|
namespace {
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::mutex ProjectCacheMutex;
|
|
|
|
|
|
std::unordered_map<std::string, std::unique_ptr<Configuration>> ProjectCache;
|
2026-04-30 02:20:19 +02:00
|
|
|
|
|
|
|
|
|
|
// Run the dep's project.cpp from its own directory so its relative paths
|
|
|
|
|
|
// (cfg.path = "./", interfaces/, lib/, ...) resolve against the dep
|
|
|
|
|
|
// root, then rebase cfg.path and all other relative path collections to
|
|
|
|
|
|
// absolute so Build() can run from any cwd without misresolving them.
|
|
|
|
|
|
// The mutex is NOT held across LoadProject — the dep may itself call
|
|
|
|
|
|
// GitProject/LocalProject, and a non-recursive lock would deadlock.
|
|
|
|
|
|
Configuration LoadProjectFromRoot(const fs::path& projectPath, const fs::path& depRoot, std::span<const std::string_view> args) {
|
|
|
|
|
|
fs::path savedCwd = fs::current_path();
|
|
|
|
|
|
Configuration cfg;
|
|
|
|
|
|
fs::current_path(depRoot);
|
|
|
|
|
|
try {
|
|
|
|
|
|
cfg = LoadProject(projectPath, args);
|
|
|
|
|
|
} catch (...) {
|
|
|
|
|
|
fs::current_path(savedCwd);
|
|
|
|
|
|
throw;
|
|
|
|
|
|
}
|
|
|
|
|
|
fs::current_path(savedCwd);
|
|
|
|
|
|
cfg.path = fs::absolute(depRoot / cfg.path).lexically_normal();
|
|
|
|
|
|
auto abs = [&](const fs::path& p) -> fs::path {
|
|
|
|
|
|
return p.is_absolute() ? p : fs::absolute(depRoot / p).lexically_normal();
|
|
|
|
|
|
};
|
|
|
|
|
|
for (fs::path& p : cfg.cFiles) p = abs(p);
|
|
|
|
|
|
for (fs::path& p : cfg.cuda) p = abs(p);
|
|
|
|
|
|
for (fs::path& p : cfg.files) p = abs(p);
|
2026-05-02 21:08:51 +02:00
|
|
|
|
for (fs::path& p : cfg.buildFiles) p = abs(p);
|
2026-04-30 02:20:19 +02:00
|
|
|
|
for (Shader& s : cfg.shaders) s.path = abs(s.path);
|
|
|
|
|
|
return cfg;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Configuration* Crafter::GitProject(const GitProjectSpec& spec) {
|
|
|
|
|
|
// Two hashes: the clone is shared across specs that point at the same
|
|
|
|
|
|
// source revision (forwarded args don't change what gets cloned, just
|
|
|
|
|
|
// how the dep's project.cpp interprets them at runtime), while the
|
|
|
|
|
|
// in-memory Configuration is keyed by the full spec so each (args)
|
|
|
|
|
|
// permutation gets its own Configuration object.
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::string srcKey = std::format("git|{}|{}|{}|{}", spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string());
|
2026-04-30 02:20:19 +02:00
|
|
|
|
std::string srcHash = std::format("{:016x}", std::hash<std::string>{}(srcKey));
|
|
|
|
|
|
|
|
|
|
|
|
std::string fullKey = srcKey;
|
|
|
|
|
|
for (const std::string& a : spec.args) {
|
|
|
|
|
|
fullKey += '|';
|
|
|
|
|
|
fullKey += a;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey));
|
|
|
|
|
|
|
|
|
|
|
|
{
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::lock_guard lock(ProjectCacheMutex);
|
|
|
|
|
|
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return it->second.get();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::string name = DeriveName(spec.source);
|
|
|
|
|
|
if (name.empty()) {
|
|
|
|
|
|
throw std::runtime_error(std::format("GitProject: could not derive name from URL '{}'", spec.source.url));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fs::path externalRoot = GetCacheDir() / "external";
|
|
|
|
|
|
fs::create_directories(externalRoot);
|
|
|
|
|
|
fs::path cloneDir = externalRoot / std::format("{}-{}", name, srcHash);
|
|
|
|
|
|
|
|
|
|
|
|
{
|
feat(lint): const-local and constexpr-constant rules
Two rules the AST makes possible, plus the mutation analysis behind them.
const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction is what makes
the answer exact rather than a guess: a scalar has no member functions, so the
only ways to write one are assignment, ++/--, having its address taken, or
binding to a non-const reference. All four are now tracked in the walk:
- assignment and compound assignment visit their LEFT operand in a write
context, the right one normally;
- ++/-- and & write their operand;
- a call argument is checked against the callee's parameter type, so passing
to `const int&` or by value is a read while `int&` is a write;
- initialising a non-const reference writes what it binds to.
For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.
constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.
On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.
Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:50:48 +02:00
|
|
|
|
const bool exists = fs::exists(cloneDir);
|
2026-04-30 02:20:19 +02:00
|
|
|
|
Progress::Task task(std::format("{} {}", exists ? "Updating" : "Cloning", name));
|
|
|
|
|
|
if (std::string err = FetchGit(spec.source, cloneDir); !err.empty()) {
|
|
|
|
|
|
throw std::runtime_error(std::format("GitProject({}): {}", spec.source.url, err));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fs::path projectPath = cloneDir / spec.projectFile;
|
|
|
|
|
|
if (!fs::exists(projectPath)) {
|
2026-07-23 01:24:42 +02:00
|
|
|
|
throw std::runtime_error(std::format("GitProject({}): {} not found in clone", spec.source.url, spec.projectFile.string()));
|
2026-04-30 02:20:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
|
|
|
|
|
|
Configuration cfg = LoadProjectFromRoot(projectPath, cloneDir, argViews);
|
|
|
|
|
|
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::lock_guard lock(ProjectCacheMutex);
|
|
|
|
|
|
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return it->second.get();
|
|
|
|
|
|
}
|
|
|
|
|
|
auto stored = std::make_unique<Configuration>(std::move(cfg));
|
|
|
|
|
|
Configuration* ptr = stored.get();
|
2026-07-23 01:24:42 +02:00
|
|
|
|
ProjectCache.emplace(std::move(fullHash), std::move(stored));
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return ptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-19 00:50:06 +02:00
|
|
|
|
fs::path Crafter::GitFetch(const GitSource& source) {
|
|
|
|
|
|
std::string name = DeriveName(source);
|
|
|
|
|
|
if (name.empty()) {
|
2026-07-23 01:24:42 +02:00
|
|
|
|
throw std::runtime_error(std::format("GitFetch: could not derive name from URL '{}'", source.url));
|
2026-05-19 00:50:06 +02:00
|
|
|
|
}
|
|
|
|
|
|
fs::path externalRoot = GetCacheDir() / "external";
|
|
|
|
|
|
std::error_code ec;
|
|
|
|
|
|
fs::create_directories(externalRoot, ec);
|
|
|
|
|
|
if (ec) {
|
2026-07-23 01:24:42 +02:00
|
|
|
|
throw std::runtime_error(std::format("GitFetch: failed to create {}: {}", externalRoot.string(), ec.message()));
|
2026-05-19 00:50:06 +02:00
|
|
|
|
}
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::string keyMaterial = std::format("git|{}|{}|{}", source.url, source.branch, source.commit);
|
2026-05-19 00:50:06 +02:00
|
|
|
|
std::size_t key = std::hash<std::string>{}(keyMaterial);
|
|
|
|
|
|
fs::path cloneDir = externalRoot / std::format("{}-{:016x}", name, key);
|
|
|
|
|
|
|
|
|
|
|
|
{
|
feat(lint): const-local and constexpr-constant rules
Two rules the AST makes possible, plus the mutation analysis behind them.
const-local reports a local that is never written. It is restricted to SCALARS
— integers, bools, enums, floating types — and that restriction is what makes
the answer exact rather than a guess: a scalar has no member functions, so the
only ways to write one are assignment, ++/--, having its address taken, or
binding to a non-const reference. All four are now tracked in the walk:
- assignment and compound assignment visit their LEFT operand in a write
context, the right one normally;
- ++/-- and & write their operand;
- a call argument is checked against the callee's parameter type, so passing
to `const int&` or by value is a read while `int&` is a write;
- initialising a non-const reference writes what it binds to.
For a class type a non-const method call could mutate it, and deciding that is
the whole-program analysis clang-tidy does, so those are simply out of scope
rather than guessed at.
constexpr-constant promotes a const constant whose initialiser is made only of
literals and operators, so `const int A = 1 << 4;` qualifies and
`const int B = Compute();` does not.
On this repository const-local found 103 candidates, which was too many to be
useful, and the reason was informative: most were range-for bindings and
pointer locals. `for (T* const x : …)` and `T* const p` are not spellings
anybody writes, and the useful constness for a pointer is on the pointee, which
this rule cannot advise on. Excluding both leaves 36, all plain bool or enum
locals worth fixing — isWasm, isPe, exists, writes, isC and so on. Those 36 are
fixed in this commit; the compiler verified every one.
Both rules are report-only. The analysis is exact, but adding const is a
judgement about intent as much as mechanics, and a wrong suggestion should cost
a glance rather than a build. const-local also deliberately does not become a
transform: inserting `const` before a shared type would apply it to every
declarator in a multi-declarator statement, including any that IS written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:50:48 +02:00
|
|
|
|
const bool exists = fs::exists(cloneDir);
|
2026-05-19 00:50:06 +02:00
|
|
|
|
Progress::Task task(std::format("{} {}", exists ? "Updating" : "Cloning", name));
|
|
|
|
|
|
if (std::string err = FetchGit(source, cloneDir); !err.empty()) {
|
|
|
|
|
|
throw std::runtime_error(std::format("GitFetch({}): {}", source.url, err));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return cloneDir;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 02:20:19 +02:00
|
|
|
|
Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) {
|
|
|
|
|
|
fs::path projectPath = fs::absolute(spec.projectFile).lexically_normal();
|
|
|
|
|
|
if (!fs::exists(projectPath)) {
|
|
|
|
|
|
throw std::runtime_error(std::format("LocalProject: {} not found", projectPath.string()));
|
|
|
|
|
|
}
|
|
|
|
|
|
fs::path canonical = fs::canonical(projectPath);
|
|
|
|
|
|
|
|
|
|
|
|
std::string fullKey = std::format("local|{}", canonical.string());
|
|
|
|
|
|
for (const std::string& a : spec.args) {
|
|
|
|
|
|
fullKey += '|';
|
|
|
|
|
|
fullKey += a;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::string fullHash = std::format("{:016x}", std::hash<std::string>{}(fullKey));
|
|
|
|
|
|
|
|
|
|
|
|
{
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::lock_guard lock(ProjectCacheMutex);
|
|
|
|
|
|
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return it->second.get();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fs::path depRoot = canonical.parent_path();
|
|
|
|
|
|
std::vector<std::string_view> argViews(spec.args.begin(), spec.args.end());
|
|
|
|
|
|
Configuration cfg = LoadProjectFromRoot(canonical, depRoot, argViews);
|
|
|
|
|
|
|
2026-07-23 01:24:42 +02:00
|
|
|
|
std::lock_guard lock(ProjectCacheMutex);
|
|
|
|
|
|
if (auto it = ProjectCache.find(fullHash); it != ProjectCache.end()) {
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return it->second.get();
|
|
|
|
|
|
}
|
|
|
|
|
|
auto stored = std::make_unique<Configuration>(std::move(cfg));
|
|
|
|
|
|
Configuration* ptr = stored.get();
|
2026-07-23 01:24:42 +02:00
|
|
|
|
ProjectCache.emplace(std::move(fullHash), std::move(stored));
|
2026-04-30 02:20:19 +02:00
|
|
|
|
return ptr;
|
|
|
|
|
|
}
|