From fb2c06c32e1637156d6fc8555734590879fab176 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sun, 1 Nov 2026 01:18:07 +0100 Subject: [PATCH] feat(tooling): wire consumers in through Debug::Use and a launcher A consumer loads Crafter.Debug.Build through #pragma crafter dependency "" 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 --- examples/Basic/AGENTS.template.md | 3 + examples/Basic/CLAUDE.md | 1 + examples/Basic/implementations/main.cpp | 16 ++ examples/Basic/project.cpp | 33 ++++ implementations/main.cpp | 13 ++ interfaces/Crafter.Debug.cppm | 12 ++ project.cpp | 58 ++++++ tooling/AGENTS.consumer.md | 3 + tooling/interfaces/Crafter.Debug.Build.cppm | 190 ++++++++++++++++++++ tooling/project.cpp | 31 ++++ 10 files changed, 360 insertions(+) create mode 100644 examples/Basic/AGENTS.template.md create mode 100644 examples/Basic/CLAUDE.md create mode 100644 examples/Basic/implementations/main.cpp create mode 100644 examples/Basic/project.cpp create mode 100644 implementations/main.cpp create mode 100644 interfaces/Crafter.Debug.cppm create mode 100644 project.cpp create mode 100644 tooling/AGENTS.consumer.md create mode 100644 tooling/interfaces/Crafter.Debug.Build.cppm create mode 100644 tooling/project.cpp diff --git a/examples/Basic/AGENTS.template.md b/examples/Basic/AGENTS.template.md new file mode 100644 index 0000000..246dd41 --- /dev/null +++ b/examples/Basic/AGENTS.template.md @@ -0,0 +1,3 @@ +# Basic + +A Crafter.Debug example. Build with `crafter-build --debug-api` to include the debug server. diff --git a/examples/Basic/CLAUDE.md b/examples/Basic/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/examples/Basic/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/examples/Basic/implementations/main.cpp b/examples/Basic/implementations/main.cpp new file mode 100644 index 0000000..a931796 --- /dev/null +++ b/examples/Basic/implementations/main.cpp @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +#ifdef BASIC_DEBUG_API +import Crafter.Debug; +#endif + +int main() { +#ifdef BASIC_DEBUG_API + std::println("basic: debug build, Crafter.Debug {}", Crafter::Debug::Version()); +#else + std::println("basic: plain build"); +#endif + return 0; +} diff --git a/examples/Basic/project.cpp b/examples/Basic/project.cpp new file mode 100644 index 0000000..dba5ef5 --- /dev/null +++ b/examples/Basic/project.cpp @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +// This checkout's own build helper. A consumer elsewhere names the repository: +// #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp" +#pragma crafter dependency "../.." project "tooling/project.cpp" +import Crafter.Debug.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "Basic"; + cfg.outputName = "basic"; + cfg.type = ConfigurationType::Executable; + const ArgQuery opts = ApplyStandardArgs(cfg, args); + + // What every dependency gets: this project's args minus the flags only it + // reads. A dependency that two paths share must get one list on both. + std::vector depArgs; + for (const std::string_view arg : args) { + if (arg != "--debug-api") depArgs.emplace_back(arg); + } + Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "BASIC_DEBUG_API", .args = depArgs }); + + std::array interfaces = {}; + std::array implementations = { "implementations/main" }; + cfg.GetInterfacesAndImplementations(interfaces, implementations); + return cfg; +} diff --git a/implementations/main.cpp b/implementations/main.cpp new file mode 100644 index 0000000..8615dd4 --- /dev/null +++ b/implementations/main.cpp @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Spike placeholder for the crafter-debug client: reports where it runs. +import std; +namespace fs = std::filesystem; + +int main(int argc, char** argv) { + std::println("crafter-debug spike client at {}", fs::read_symlink("/proc/self/exe").string()); + std::println("inside a Flatpak sandbox: {}", fs::exists("/.flatpak-info")); + for (std::int32_t i = 1; i < argc; ++i) std::println("arg: {}", std::string_view(argv[i])); + return 0; +} diff --git a/interfaces/Crafter.Debug.cppm b/interfaces/Crafter.Debug.cppm new file mode 100644 index 0000000..a17cd1f --- /dev/null +++ b/interfaces/Crafter.Debug.cppm @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Spike placeholder: the real API replaces this in Phase 1. +export module Crafter.Debug; +import std; + +export namespace Crafter::Debug { + std::string_view Version() { + return "0.0.0-spike"; + } +} diff --git a/project.cpp b/project.cpp new file mode 100644 index 0000000..fdc129c --- /dev/null +++ b/project.cpp @@ -0,0 +1,58 @@ +// 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; + +namespace { + // The configurations this file makes besides the one it returns, at + // stable addresses for the pointers in `dependencies`. One set per load, + // not statics: a static would keep the first load's args for every load. + std::deque owned; + + // This file's directory. A configuration a project file makes for itself + // keeps the path it is given, and "./" would later resolve against the + // consumer's directory instead of this one. + fs::path Root() { + return fs::path(std::source_location::current().file_name()).parent_path(); + } +} + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "Crafter.Debug"; + cfg.outputName = "Crafter.Debug"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + { + std::array interfaces = { "interfaces/Crafter.Debug" }; + std::array implementations = {}; + cfg.GetInterfacesAndImplementations(interfaces, implementations); + } + + // The client, crafter-debug. One of the library's dependencies, so a + // consumer's debug build compiles it in the same pass as the app; an + // executable adds no link flags to what depends on it. Built for the same + // target as the library, and not through ApplyStandardArgs, which would + // turn it into a library when a consumer passes --lib. + Configuration& client = owned.emplace_back(); + client.path = Root(); + client.name = "crafter.debug-cli"; + client.outputName = "crafter-debug"; + client.type = ConfigurationType::Executable; + client.target = cfg.target; + client.march = cfg.march; + client.mtune = cfg.mtune; + client.sysroot = cfg.sysroot; + client.debug = cfg.debug; + { + std::array interfaces = {}; + std::array implementations = { "implementations/main" }; + client.GetInterfacesAndImplementations(interfaces, implementations); + } + cfg.dependencies.push_back(&client); + return cfg; +} diff --git a/tooling/AGENTS.consumer.md b/tooling/AGENTS.consumer.md new file mode 100644 index 0000000..abc40db --- /dev/null +++ b/tooling/AGENTS.consumer.md @@ -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`. diff --git a/tooling/interfaces/Crafter.Debug.Build.cppm b/tooling/interfaces/Crafter.Debug.Build.cppm new file mode 100644 index 0000000..d2c773b --- /dev/null +++ b/tooling/interfaces/Crafter.Debug.Build.cppm @@ -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 + // /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(in)), std::istreambuf_iterator()); + 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(in)), std::istreambuf_iterator()) == 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 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)); + } +} diff --git a/tooling/project.cpp b/tooling/project.cpp new file mode 100644 index 0000000..1314403 --- /dev/null +++ b/tooling/project.cpp @@ -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 args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "Crafter.Debug.Build"; + cfg.outputName = "Crafter.Debug.Build"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + std::array interfaces = { "interfaces/Crafter.Debug.Build" }; + std::array implementations = {}; + cfg.GetInterfacesAndImplementations(interfaces, implementations); + return cfg; +}