Crafter.Debug/tooling/interfaces/Crafter.Debug.Build.cppm
Jorijn van der Graaf fb2c06c32e
feat(tooling): wire consumers in through Debug::Use and a launcher
A consumer loads Crafter.Debug.Build through
  #pragma crafter dependency "<Crafter.Debug>" project "tooling/project.cpp"
and calls Debug::Use in its own CrafterBuildProject, the way
Crafter.LintRules gets in. With the consumer's flag on, Use adds the
library from the checkout the pragma loaded, and writes
build/crafter-debug: a POSIX sh launcher for the client that library
builds. The client is one of the library's dependencies, so a debug
build compiles it in the same pass as the app, from the same sources.

Why a launcher and not a script: every `crafter-build run` loads the
whole project first, which costs 13-28 s per call on 3DForts (a git
pull per unpinned dependency plus rescanning sources), while the
launcher is one sandbox start, 0.08 s. A client built in the Flatpak
links the Flatpak's libc++ against the runtime's glibc, so the launcher
starts it through `flatpak run` and never on the host itself; inside a
CrafterBuild sandbox it runs it directly.

The library, the client and examples/Basic are placeholders that prove
the wiring: the debug build writes a launcher whose client exists, a
plain build has no Crafter::Debug symbols and keeps the notes from
tooling/AGENTS.consumer.md, and a fresh plain build writes a stub that
names the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:18:07 +01:00

190 lines
9.6 KiB
C++

// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Wires Crafter.Debug into a consumer's build. In the consumer's project.cpp:
//
// #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp"
// import Crafter.Debug.Build;
// ...
// Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api",
// .define = "MYAPP_DEBUG_API", .args = depArgs });
//
// crafter-build builds this module for the host and links it into the
// consumer's project.so, the way Crafter.LintRules gets in, so Use runs each
// time crafter-build loads the consumer: for every verb, before anything is
// built. With the consumer's flag on, Use adds the Crafter.Debug library from
// the checkout this module was built from, and writes build/crafter-debug, the
// launcher for the client that library builds. The client is one of the
// library's dependencies, so the build that follows compiles it in the same
// pass as the app, from the same sources.
//
// The launcher is a POSIX sh script, because what it starts has to run where
// it was built. A client built inside the CrafterBuild Flatpak links the libc++
// in that Flatpak's /app/lib against the runtime's glibc, so on the host the
// launcher starts it through `flatpak run`: nothing the Flatpak compiled runs
// on the host itself. The script keeps to what every /bin/sh on Linux shares
// (test, command -v, grep -q, exec "$@"), and every path in it is quoted.
export module Crafter.Debug.Build;
import std;
import Crafter.Build;
namespace fs = std::filesystem;
namespace Crafter::Debug {
// The checkout this module was built from. crafter-build compiles module
// sources from absolute paths, and this file is
// <checkout>/tooling/interfaces/Crafter.Debug.Build.cppm.
fs::path Checkout() {
return fs::path(std::source_location::current().file_name()).parent_path().parent_path().parent_path();
}
// The value of `key` in `section` of an INI-style file such as
// /.flatpak-info, or "" when either is missing.
std::string IniValue(std::string_view text, std::string_view section, std::string_view key) {
bool inSection = false;
for (const auto part : std::views::split(text, '\n')) {
const std::string_view line(std::ranges::data(part), std::ranges::size(part));
if (line.starts_with('[')) {
inSection = line.size() == section.size() + 2 && line.substr(1, section.size()) == section && line.back() == ']';
continue;
}
if (inSection && line.size() > key.size() && line.starts_with(key) && line[key.size()] == '=') {
return std::string(line.substr(key.size() + 1));
}
}
return {};
}
// The Flatpak crafter-build runs in, as its /.flatpak-info describes it:
// the app id and the branch. Both empty when crafter-build runs natively.
struct Sandbox {
std::string app;
std::string branch;
};
Sandbox CurrentSandbox() {
std::ifstream in("/.flatpak-info");
if (!in) return {};
const std::string text((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return { IniValue(text, "Application", "name"), IniValue(text, "Instance", "branch") };
}
// A target the debug server can run on: Linux, not the browser.
bool NativeLinux(std::string_view target) {
return target.find("-linux-") != std::string_view::npos && !target.starts_with("wasm");
}
// The launcher for `client`. Inside the Flatpak that built the client it
// runs it directly; on the host it starts it in that Flatpak, on the
// branch that built it, so the client gets the runtime it was linked
// against; from another app's sandbox it goes through flatpak-spawn.
// `--die-with-parent` ends the client when whatever started the launcher
// is killed, a timed-out `wait` or a Monitor-stopped `log --follow`.
std::string LauncherText(const fs::path& client, const Sandbox& sandbox, std::string_view flag) {
std::string text = std::format(
"#!/bin/sh\n"
"# Written by Crafter.Debug (Debug::Use) each time crafter-build loads this\n"
"# project with {0}: runs the crafter-debug client that build compiled.\n"
"client={1}\n"
"if [ ! -x \"$client\" ]; then\n"
" echo \"crafter-debug: the client is not built yet; build with: crafter-build {0}\" >&2\n"
" exit 2\n"
"fi\n",
flag, ShellQuote(client.string()));
if (sandbox.app.empty()) {
text += "exec \"$client\" \"$@\"\n";
return text;
}
const std::string run = std::format("flatpak run --die-with-parent --branch={} --command=\"$client\" {} \"$@\"",
ShellQuote(sandbox.branch), ShellQuote(sandbox.app));
text += std::format(
"if grep -qsx {0} /.flatpak-info; then exec \"$client\" \"$@\"; fi\n"
"if command -v flatpak >/dev/null 2>&1; then exec {1}; fi\n"
"if command -v flatpak-spawn >/dev/null 2>&1; then exec flatpak-spawn --host {1}; fi\n"
"echo \"crafter-debug: needs flatpak to start $client\" >&2\n"
"exit 1\n",
ShellQuote(std::format("name={}", sandbox.app)), run);
return text;
}
// What build/crafter-debug says before the project has been built with
// its debug flag.
std::string StubText(std::string_view flag) {
return std::format(
"#!/bin/sh\n"
"# Written by Crafter.Debug (Debug::Use): this project has no debug build yet.\n"
"echo \"crafter-debug: no debug build yet; build with: crafter-build {}\" >&2\n"
"exit 2\n",
flag);
}
// Replaces `file` with `text`, executable, unless it already holds it.
// Through a temporary name and a rename, so a launcher that is running
// never reads half a file. A failure is a warning: the build itself does
// not depend on the launcher.
void WriteIfChanged(const fs::path& file, const std::string& text) {
{
std::ifstream in(file, std::ios::binary);
if (in && std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>()) == text) return;
}
std::error_code ec;
fs::create_directories(file.parent_path(), ec);
std::random_device random;
fs::path temp = file;
temp += std::format(".{:08x}.tmp", random());
{
std::ofstream out(temp, std::ios::binary | std::ios::trunc);
out << text;
if (!out) {
std::println(std::cerr, "Crafter.Debug: could not write {}", temp.string());
return;
}
}
fs::permissions(temp, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, ec);
fs::rename(temp, file, ec);
if (ec) {
std::println(std::cerr, "Crafter.Debug: could not write {}: {}", file.string(), ec.message());
fs::remove(temp, ec);
}
}
}
export namespace Crafter::Debug {
struct UseOptions {
// Whether this build includes Crafter.Debug: the consumer's own flag,
// such as `opts.Has("--debug-api")`. Crafter.Debug never sees the flag.
bool enabled = false;
// That flag as the user types it, for the launcher's messages.
std::string flag = "--debug-api";
// The define the consumer's own sources test, such as "MYAPP_DEBUG_API".
std::string define;
// The args the consumer hands its other dependencies. A dependency
// two paths share must get the same list on both, or crafter-build
// refuses the graph ("module ... built twice").
std::vector<std::string> args;
};
// Adds Crafter.Debug to `cfg` when options.enabled, and writes the
// launcher; see the comment at the top. `at` defaults to the call site,
// which is how Use finds the consumer's directory.
void Use(Configuration& cfg, const UseOptions& options, std::source_location at = std::source_location::current()) {
const fs::path launcher = fs::path(at.file_name()).parent_path() / "build" / "crafter-debug";
// Only for the project crafter-build was pointed at: a consumer loaded
// as someone else's dependency has no agent driving it from its own
// directory. Asked before LocalProject, which loads another project.
const bool topLevel = IsTopLevelProject();
if (!options.enabled || !NativeLinux(cfg.target)) {
if (topLevel && !fs::exists(launcher)) WriteIfChanged(launcher, StubText(options.flag));
return;
}
Configuration* library = LocalProject({ .projectFile = Checkout() / "project.cpp", .args = options.args });
cfg.dependencies.push_back(library);
if (!options.define.empty()) cfg.defines.push_back({ options.define, "" });
if (!topLevel) return;
const auto client = std::ranges::find(library->dependencies, std::string_view("crafter.debug-cli"),
[](const Configuration* dependency) { return std::string_view(dependency->name); });
if (client == library->dependencies.end()) {
throw std::runtime_error(std::format("Crafter.Debug: {} declares no crafter.debug-cli client", (Checkout() / "project.cpp").string()));
}
WriteIfChanged(launcher, LauncherText((*client)->BinDir() / (*client)->outputName, CurrentSandbox(), options.flag));
}
}