Crafter.Build/implementations/Crafter.Build-Script.cpp

104 lines
3.8 KiB
C++
Raw Permalink Normal View History

feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Build:Script_impl;
import std;
import :Script;
import :Clang;
import :Platform;
import :Progress;
namespace fs = std::filesystem;
using namespace Crafter;
namespace {
std::string JoinNames(std::span<const Script> scripts) {
std::string out;
for (const Script& script : scripts) {
if (!out.empty()) out += ", ";
out += script.name;
}
return out;
}
}
std::int32_t ScriptContext::Run(std::string_view command) const {
// Echoed by default, unlike a compile step: a script's commands are the
// only account of what it is doing to the machine.
if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) std::println("$ {}", command);
return RunCommandAttached(command);
}
std::int32_t Crafter::RunScript(Configuration& projectCfg, const RunScriptOptions& opts) {
// Loading the project may have left an in-place status line behind.
Progress::Clear();
const std::span<const Script> scripts = projectCfg.scripts;
if (scripts.empty()) {
std::println(std::cerr,
R"msg(No scripts defined.
Add them in project.cpp before returning the Configuration:
cfg.scripts.push_back({{"docs", [](Crafter::ScriptContext& ctx) {{
return ctx.Run("python3 tools/gen-docs.py");
}}}});
`crafter-build run docs [args...]` then runs it and exits with what it
returned; ctx.args holds the arguments.)msg");
return 1;
}
// Checked for every script on every run, the list included, so a bad
// declaration is reported the first time anyone uses `run` and not only
// when that one name is asked for.
std::unordered_set<std::string_view> seen;
for (const Script& script : scripts) {
if (script.name.empty() || script.name.starts_with('-')) {
std::println(std::cerr, "script name '{}' cannot be run: a name must not be empty or start with '-'", script.name);
return 1;
}
// Running the first or the last of two would both be a guess, and
// the usual cause is a pasted line whose name was never changed.
if (!seen.insert(script.name).second) {
std::println(std::cerr, "script '{}' is defined twice", script.name);
return 1;
}
if (!script.run) {
std::println(std::cerr, "script '{}' has no callback", script.name);
return 1;
}
}
if (opts.listOnly || !opts.named) {
for (const Script& script : scripts) std::println("{}", script.name);
return 0;
}
const auto found = std::ranges::find(scripts, opts.name, &Script::name);
if (found == scripts.end()) {
std::println(std::cerr, "No script named '{}'. Scripts: {}", opts.name, JoinNames(scripts));
return 1;
}
ScriptContext ctx{
.project = projectCfg,
// lexically_normal: fs::absolute keeps a "./" component.
.projectDir = opts.projectFile.empty() ? fs::absolute(projectCfg.path).lexically_normal() : opts.projectFile.parent_path(),
.args = opts.args,
};
try {
const std::int32_t code = found->run(ctx);
fix: rebuild and relink on what changed, submodules at their revisions, and mingw found where it is - Shaders record the files glslang opened (a depfile) and their settings (source path, stage, entry point, defines, include directories) in the build directory, and rebuild when an included file or a setting changes, not only the shader's own source. - An executable relinks when its link command changed, not only when an object is newer: a link flag in project.cpp, or a dependency's library that moved (an ExternalDependency built into a new cache directory). The command is recorded beside the objects; its flags come from ordered sets, so the order the dependency threads finish in can't change it. - An external's newest artifact counts its libDirs and versioned .so names. - Submodules are checked out at the revisions the fetched commit records, from the URLs its .gitmodules gives, after every clone, switch, checkout and pull, so a pinned commit builds against its own submodules, and one moved by hand is put back; with no .gitmodules, git isn't asked. - Each external clone's fetch and build hold a file lock: parallel builds pulled into one clone at once ("cannot lock ref"). - mingw: - -stdlib=libstdc++ always; - the std module's source is found through libstdc++.modules.json in the mingw toolchain's own library directories (the generic lib/ holds the host's Linux one), asking the compiler once a run; - a toolchain that isn't there is a build error saying where it looked, not std::terminate; - CRAFTER_MINGW_DIR names the prefix, the directory holding x86_64-w64-mingw32/ and lib/gcc/; - inside the Flatpak the host's /usr mingw is used. - RunScript's exit-status check reads as the one range it is. - Tests: ConcurrentExternalFetch, ExternalArtifactTime, GitSubmodules, LinkFlagsChange, MingwMissing, ShaderIncludeChange; ShaderCompile and ToolchainResolve extended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:13 +02:00
if (code >= 0 && code <= 255) return code;
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
std::println(std::cerr, "script '{}' returned {}, which is not an exit status (0..255); exiting with 1", found->name, code);
return 1;
} catch (const std::exception& e) {
// Never let a script's exception unwind across the project.so
// boundary and out of Run: report it as this script's failure.
Progress::Clear();
std::println(std::cerr, "script '{}' failed: {}", found->name, e.what());
return 1;
} catch (...) {
Progress::Clear();
std::println(std::cerr, "script '{}' failed: it threw something that isn't a std::exception", found->name);
return 1;
feat(script): scripts in project.cpp, run with `crafter-build run` A project.cpp could describe how a project is built, tested and linted, but not the chores around it: packaging, installing, publishing. Those stayed shell lines in a README or in someone's history. Configuration gains `scripts`, a plain list like `defines`. A Script is a name and a C++ callback, as a lint rule is; there is no script language and no config file. The callback gets a ScriptContext (the project, the project file's directory, the arguments) and returns the exit status. ctx.Run runs a shell command with the terminal attached and returns its status. It is only stored when project.cpp loads, which happens for every verb and for every project that depends on this one. `crafter-build run <name> [args...]` runs one, a bare `run` lists them. Only the scripts of the project crafter-build was pointed at are runnable, never a dependency's. Everything after the script's name is handed to the script untouched. The two branches that do this sit ahead of every other branch of the parse loop, --version and --help included. Below the verbs, `run package clean` would delete build/, and any other word would land in the project's args and so in the variant hash, moving the build to another directory. Options before the name keep their meaning, so project args go there. The driver is its own partition, :Script. It refuses a project with no scripts, a name that is empty or starts with '-' (it could never be typed), a name declared twice (running either would be a guess) and an unknown name, each with exit 1 and without running anything. A script that throws exits 1. A return value outside 0..255 becomes 1: an exit status is eight bits, and a raw wait status such as 256 would otherwise wrap to success. Two helpers in :Platform: - RunCommandAttached runs a command with inherited stdio. Every helper so far captured through popen, which is right for a compile step and useless for a command that runs for half an hour or asks a question. It reads the wait status through DecodeWaitStatus, which RunCommandChecked and RunCommandWithTimeout now share. - ShellQuote is exported. It existed twice, as ShellQuote in :External and ShellQuoteSh in :Test, with the same behaviour; both are gone. A script that puts a path into a command needs it too. Not included: a string shorthand for one-command scripts (a one-line lambda covers it) and a ctx.Build() helper (Build() is already exported). A new partition and new exported symbols: run ./build.sh. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 18:16:00 +02:00
}
}