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>
This commit is contained in:
parent
d693d7b645
commit
fb2c06c32e
10 changed files with 360 additions and 0 deletions
3
tooling/AGENTS.consumer.md
Normal file
3
tooling/AGENTS.consumer.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Crafter.Debug
|
||||
|
||||
Spike placeholder: the real notes land with the client. This project can be inspected and driven at runtime: build it with the project's debug flag, then run `./build/crafter-debug`.
|
||||
190
tooling/interfaces/Crafter.Debug.Build.cppm
Normal file
190
tooling/interfaces/Crafter.Debug.Build.cppm
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// 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));
|
||||
}
|
||||
}
|
||||
31
tooling/project.cpp
Normal file
31
tooling/project.cpp
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The build helper consumers load through
|
||||
// #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp"
|
||||
// A separate project file because a pragma dependency is built for the host
|
||||
// and gets none of the consumer's args, so the root project.cpp could not
|
||||
// tell this load from the one that builds the library for the target.
|
||||
//
|
||||
// No dependency on Crafter.Build, as in Crafter.LintRules: this module only
|
||||
// ever runs inside a crafter-build process, which provides the symbols, and
|
||||
// the host build already sees the Crafter.Build modules through the PCM cache
|
||||
// crafter-build keeps for project files. Nor on anything else: whatever this
|
||||
// imported would be built on every load of every consumer.
|
||||
import std;
|
||||
import Crafter.Build;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter;
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||
Configuration cfg;
|
||||
cfg.path = "./";
|
||||
cfg.name = "Crafter.Debug.Build";
|
||||
cfg.outputName = "Crafter.Debug.Build";
|
||||
cfg.type = ConfigurationType::LibraryStatic;
|
||||
ApplyStandardArgs(cfg, args);
|
||||
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug.Build" };
|
||||
std::array<fs::path, 0> implementations = {};
|
||||
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
||||
return cfg;
|
||||
}
|
||||
Loading…
Reference in a new issue