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>
This commit is contained in:
Jorijn van der Graaf 2026-10-06 18:16:00 +02:00
commit 561398013c
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
12 changed files with 313 additions and 56 deletions

View file

@ -22,12 +22,13 @@ crafter-build is a C++26 modules build system. A project is described by a `proj
Partitions and what they own:
- `:Toolchain` resolves the compiler once per process (`GetToolchain`): clang++, clang, llvm-ar, libclang, libc++'s std module source and the default sysroots, from `CRAFTER_BUILD_LLVM_DIR`, else a toolchain installed beside crafter-build, else PATH. It also holds the version baked in at build time. `SysrootStdModule` picks the std module source for a sysroot build by the sysroot's libc++ release.
- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint and test data types.
- `:Clang` holds `Configuration`, `Build()`, `VariantId()`, `ApplyStandardArgs`, `HostTarget`, the CLI entry point `Crafter::Run`, and the lint, test and script data types.
- `:Platform` runs commands, implements `LoadProject` (compile and load `project.cpp`), the host PCM cache, `#pragma crafter dependency`, and all OS-specific code.
- `:Interface` and `:Implementation` model module units, their staleness checks and their compiles.
- `:External` handles git + cmake dependencies, `GitProject` and `LocalProject`.
- `:Test` holds `AddTest`, `RunTests` and `TestRunner`.
- `:Lint` is the lint/format driver plus the libclang token and AST layer.
- `:Script` is the driver behind `crafter-build run`: it looks a name up in `cfg.scripts`, calls the callback and turns what it returns into the exit code.
- `:Progress` is console output. `:Shader` compiles shaders through glslang. `:Asset` is the hook into Crafter.Asset.
## Dev environment
@ -60,6 +61,8 @@ Three things to know about this loop:
./bin/crafter-build lint # dry style gate, exit 1 on any finding
./bin/crafter-build format # apply the transform rules in place
./bin/crafter-build format --check # CI-style dry run
./bin/crafter-build run # list the scripts project.cpp defines
./bin/crafter-build run <name> [args...] # run one; everything after the name is the script's
./bin/crafter-build clean
```
@ -142,6 +145,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
- `project.cpp` is compiled and loaded into the crafter-build process, and every `GitProject`, `#pragma crafter dependency` and `ExternalDependency` clones a repository and runs its build on the host. Treat a dependency URL as code you are choosing to run. This repo's own dependencies are sibling Catcrafts repos tracking their default branch on purpose; pin with `commit` when adding anything else, and whenever reproducibility matters.
- Unpinned dependencies `git pull` on every invocation, so builds and tests need network. A missing or private repo surfaces as a clone failure before anything is built.
- `--runner=cmd:<command>` executes the named wrapper around every test binary. Wrappers come from `--runner=` or `CRAFTER_BUILD_RUNNER_*`; do not hardcode hosts, wrappers or credentials in `project.cpp` or tests.
- `crafter-build run <name>` executes whatever the script does, with your permissions. Only the scripts of the project crafter-build was pointed at can be run, never a dependency's; keep it that way.
- The linter loads `libclang` at runtime by name. `CRAFTER_BUILD_LIBCLANG` pins an exact path and must fail loudly when wrong, never fall back to another library.
- CI force-pushes the rolling `latest` tag and publishes a prerelease on every push to `master` with the workflow's own token, and signs the Flatpak repo with the key mounted from the runner host. Do not add secrets to the workflow or echo the environment in it.
- Tests write only under `fs::temp_directory_path()` and the fixture's own `bin/` and `build/`. Keep it that way.
@ -164,6 +168,7 @@ To release: add a `<release>` to `packaging/flatpak/net.catcrafts.CrafterBuild.m
## Gotchas
- A new partition is listed in four places, in import order: `PARTITIONS` in `build.sh`, `CrafterBuildModules` in `Crafter.Build-Platform.cpp`, both arrays in `project.cpp`, and the umbrella `Crafter.Build.cppm`. Then `./build.sh`.
- A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away.
- A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer. Its test does link crafter-build, so that test is wired behind `IsTopLevelProject()`: without the guard, crafter-build loading the library as a dependency would load its own `project.cpp` again through the test's `GitProject` and hit the cycle guard.
- `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache.

View file

@ -48,7 +48,7 @@ cp wasi-runtime/index.html.in share/crafter-build/wasi-runtime/
# Partition order is the import order: every unit is listed before the
# units that import it, and the umbrella last.
PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Progress Asset"
PARTITIONS="Toolchain Shader Platform Interface Implementation External Clang Test Lint Script Progress Asset"
cp interfaces/Crafter.Build.cppm share/crafter-build/
for p in $PARTITIONS; do

View file

@ -12,6 +12,7 @@ import :Platform;
import :Toolchain;
import :Test;
import :Lint;
import :Script;
import :Progress;
import :Asset;
namespace fs = std::filesystem;
@ -1594,6 +1595,7 @@ R"(Usage:
{0} test [test-options] [globs...] Build and run the project's tests
{0} lint [lint-options] [globs...] Run the project's lint rules over its sources
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
{0} run [script [args...]] Run a script the project defines, or list them
{0} clean Delete the project's bin/ and build/ trees
{0} help | -h | --help Show this help
{0} --version Print the version and the compiler in use
@ -1646,6 +1648,23 @@ Format options (after the `format` subcommand):
transform. `format` rewrites changed files in place; `lint` reports the
same transforms as would-reformat findings without writing.
Run (after the `run` subcommand):
<script> [args...] Run the named script. Everything after the name is
handed to the script untouched, including what
looks like an option of crafter-build itself, so
-v, -q, --project= and project args go before it.
--list List the project's scripts. A bare `run` does too.
Scripts are defined in project.cpp, as entries of cfg.scripts:
cfg.scripts.push_back({{"docs", [](Crafter::ScriptContext& ctx) {{
return ctx.Run("python3 tools/gen-docs.py");
}}}});
The callback returns the exit status. ctx.Run runs a shell command with the
terminal attached and returns its status; ctx.args holds the arguments and
ctx.projectDir the project's directory. Commands run in the directory
crafter-build was started from. Only the scripts of the project being run
are available, never a dependency's.
Clean (after the `clean` subcommand):
Removes bin/ and build/ next to the project file. Does not load project.cpp,
so it works when the project itself no longer compiles. Every target, variant
@ -1677,6 +1696,8 @@ Exit status:
0 success / all non-skipped tests passed / lint clean / format applied
1 build failure, one or more tests failed, lint findings, no rules defined,
format write errors, or `format --check` found files that would change
`run` exits with the status its script returned, and with 1 when there is no
such script, when the script throws, or when it returns a value outside 0..255
)", argv0);
}
@ -1692,14 +1713,29 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
bool runLint = false;
bool runFormat = false;
bool runClean = false;
bool runScript = false;
bool runAfterBuild = false;
RunTestsOptions testOpts;
RunLintOptions lintOpts;
RunScriptOptions scriptOpts;
Progress::Verbosity verbosity = Progress::Verbosity::Default;
for (std::int32_t i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--version") {
// The two `run` branches come before everything else on purpose.
// What follows a script's name belongs to the script, whatever
// it looks like: `run package --help` must not print this
// tool's help, `run package clean` must not delete build/, and
// none of it may reach projectArgs, where it would change the
// variant hash and so the directory the project builds into.
if (scriptOpts.named) {
scriptOpts.args.emplace_back(arg);
} else if (runScript && !arg.starts_with("-")) {
// The first word after `run`, even one that is a verb
// elsewhere. Options before it keep their usual meaning.
scriptOpts.name = arg;
scriptOpts.named = true;
} else if (arg == "--version") {
// Answered here, before the project file is looked for, so
// it works in any directory and is never forwarded to
// project.cpp as an unknown argument.
@ -1717,6 +1753,10 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
} else if (!runTests && !runLint && !runClean && arg == "format") {
runFormat = true;
lintOpts.mode = LintMode::Apply;
} else if (!runTests && !runLint && !runFormat && !runClean && arg == "run") {
runScript = true;
} else if (runScript && arg == "--list") {
scriptOpts.listOnly = true;
} else if (runFormat && arg == "--check") {
lintOpts.mode = LintMode::Check;
} else if (arg == "-r") {
@ -1778,6 +1818,11 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) {
Configuration config = LoadProject(projectFile, projectArgs);
SetParentProject(&config);
if (runScript) {
scriptOpts.projectFile = fs::absolute(projectFile).lexically_normal();
return RunScript(config, scriptOpts);
}
if (runLint || runFormat) {
// lexically_normal: fs::absolute keeps a "./" component, which
// would leak into displayed paths and the project-root derivation.

View file

@ -21,16 +21,6 @@ std::string DeriveName(const GitSource& source) {
return slash == std::string::npos ? url : url.substr(slash + 1);
}
std::string ShellQuote(std::string_view s) {
std::string out = "'";
for (char c : s) {
if (c == '\'') out += "'\\''";
else out += c;
}
out += "'";
return out;
}
std::string JoinOptions(std::span<const std::string> options) {
std::string out;
for (const std::string& opt : options) {

View file

@ -552,6 +552,35 @@ bool Crafter::IsTopLevelProject() {
return LoadingFrames.size() <= 1;
}
namespace {
// The one reading of a wait status (pclose, system) for every helper
// that runs a command: a signal death is reported the way a shell
// reports it, as 128+signal.
void DecodeWaitStatus(std::int32_t status, CommandResult& result) {
if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.signal = WTERMSIG(status);
result.exitCode = 128 + result.signal;
result.crashed = true;
} else {
result.exitCode = -1;
}
}
}
std::string Crafter::ShellQuote(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\\''";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer;
CommandResult result{};
@ -564,19 +593,27 @@ CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
result.output += buffer.data();
}
std::int32_t status = pclose(pipe);
if (WIFEXITED(status)) {
result.exitCode = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
result.signal = WTERMSIG(status);
result.exitCode = 128 + result.signal;
result.crashed = true;
} else {
result.exitCode = -1;
}
DecodeWaitStatus(pclose(pipe), result);
return result;
}
std::int32_t Crafter::RunCommandAttached(std::string_view cmd) {
// The child writes straight to the terminal. Anything of ours that is
// still pending — the in-place status line, buffered stdout when it is
// a pipe — has to be out before the child's first byte, or it lands in
// the middle of the child's output or after it.
Progress::Clear();
std::fflush(stdout);
std::fflush(stderr);
const std::string command(cmd);
const std::int32_t status = std::system(command.c_str());
if (status == -1) throw std::runtime_error(std::format("could not start a shell for: {}", cmd));
CommandResult result{};
DecodeWaitStatus(status, result);
return result.exitCode;
}
CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::seconds timeout) {
std::array<char, 128> buffer;
CommandResult result{};
@ -592,25 +629,16 @@ CommandResult Crafter::RunCommandWithTimeout(std::string_view cmd, std::chrono::
result.output += buffer.data();
}
std::int32_t status = pclose(pipe);
if (WIFEXITED(status)) {
std::int32_t code = WEXITSTATUS(status);
if (code == 124) {
DecodeWaitStatus(pclose(pipe), result);
// What comes back is `timeout`'s own exit, and it speaks through the
// code: 124 for a command it had to stop, 128+signal for one that died.
if (!result.crashed) {
if (result.exitCode == 124) {
result.timedOut = true;
result.exitCode = 124;
} else if (code >= 128) {
result.signal = code - 128;
result.exitCode = code;
} else if (result.exitCode >= 128) {
result.signal = result.exitCode - 128;
result.crashed = true;
} else {
result.exitCode = code;
}
} else if (WIFSIGNALED(status)) {
result.signal = WTERMSIG(status);
result.exitCode = 128 + result.signal;
result.crashed = true;
} else {
result.exitCode = -1;
}
return result;
}
@ -736,7 +764,7 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
}
namespace {
constexpr std::array<std::string_view, 12> CrafterBuildModules = {
constexpr std::array<std::string_view, 13> CrafterBuildModules = {
"Crafter.Build-Toolchain",
"Crafter.Build-Shader",
"Crafter.Build-Platform",
@ -746,6 +774,7 @@ namespace {
"Crafter.Build-Clang",
"Crafter.Build-Test",
"Crafter.Build-Lint",
"Crafter.Build-Script",
"Crafter.Build-Progress",
"Crafter.Build-Asset",
"Crafter.Build",

View file

@ -0,0 +1,100 @@
// 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);
if (code == 0 || (code >= 1 && code <= 255)) return code;
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;
}
}

View file

@ -23,23 +23,11 @@ namespace {
// MatchGlob/MatchAny live in :Platform — shared with the lint verb.
std::string ShellQuoteSh(std::string_view s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('\'');
for (char c : s) {
if (c == '\'') out += "'\\''";
else out.push_back(c);
}
out.push_back('\'');
return out;
}
std::string JoinAndQuoteArgs(std::span<const std::string> args) {
std::string out;
for (const auto& a : args) {
if (!out.empty()) out.push_back(' ');
out += ShellQuoteSh(a);
out += ShellQuote(a);
}
return out;
}
@ -273,7 +261,7 @@ TestResult Crafter::RunSingleTest(const Test& test, const fs::path& binary, std:
if (test.runner.exec.empty()) {
// Local runner: spawn the binary directly through the host shell.
std::string cmd = std::format("{} {}", ShellQuoteSh(binary.string()), ph["{args}"]);
std::string cmd = std::format("{} {}", ShellQuote(binary.string()), ph["{args}"]);
r = RunCommandWithTimeout(cmd, timeout);
} else {
// Prefix runner (qemu-user, wasmtime, wine, ...): templated exec

View file

@ -356,6 +356,45 @@ export namespace Crafter {
bool needsAst = false;
};
struct Configuration;
// What a Script's callback is handed when `crafter-build run <name>`
// starts it.
struct ScriptContext {
// The Configuration project.cpp returned. Read the project through
// this rather than capturing the local in CrafterBuildProject by
// reference: that local is gone by the time a script runs.
Configuration& project;
// Absolute directory of the project file. Commands run in the
// directory crafter-build was started from, like everything else it
// does, so a script names its files through this.
fs::path projectDir;
// Everything the user typed after the script's name, untouched:
// crafter-build interprets none of it.
std::span<const std::string> args;
// Run `command` through sh with the terminal attached — output
// appears as it is produced, the command can prompt, Ctrl-C reaches
// it — after echoing it as `$ command` (not under -q). Returns its
// exit code, 128+signal when it was killed; return that from the
// script to make it the exit code of crafter-build. Quote anything
// that is not a literal with ShellQuote. Out-of-line like
// LintContext's members: the callback runs from project.so.
std::int32_t Run(std::string_view command) const;
};
// A project script: a chore that belongs to the project but is not part
// of building it (packaging, installing, publishing, serving), run by
// name with `crafter-build run <name> [args...]`. `run` returns the
// process exit code. It is only stored when project.cpp is loaded, which
// happens for every verb and for every project that depends on this one,
// so whatever a script needs to probe it probes when it runs. Only the
// scripts of the project crafter-build was pointed at can be run, never
// a dependency's.
struct Script {
std::string name;
std::function<std::int32_t(ScriptContext&)> run;
};
// The host target triple: what the resolved toolchain's clang++ prints
// for -print-target-triple (see GetToolchain). Used as the default for
// Configuration::target so projects don't have to hardcode it for the
@ -446,6 +485,12 @@ export namespace Crafter {
std::vector<Test> tests;
// Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules;
// Scripts for `crafter-build run <name>`, a plain list like defines:
// cfg.scripts.push_back({"docs", [](ScriptContext& ctx) {
// return ctx.Run("python3 tools/gen-docs.py");
// }});
// Not part of VariantId: a script changes nothing that is compiled.
std::vector<Script> scripts;
void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are

View file

@ -29,6 +29,18 @@ namespace Crafter {
// therefore keep the `if (!result.empty())` shape they already had.
std::string RunBuildCommand(std::string_view command);
export CommandResult RunCommandWithTimeout(std::string_view command, std::chrono::seconds timeout);
// Runs `command` through sh with this process's own stdin, stdout and
// stderr, so its output appears as it is produced and it can prompt.
// Every helper above captures instead, which suits a compile step and
// not a command that runs for half an hour. Returns the exit code, or
// 128+signal when the command was killed; a nonzero exit is the caller's
// to judge and never throws. While it runs, Ctrl-C goes to the command
// and not to crafter-build, which then sees it as exit code 130.
// Project scripts reach this through ScriptContext::Run.
std::int32_t RunCommandAttached(std::string_view command);
// `s` as one single-quoted sh word, safe to splice into a command line
// whatever it contains: paths with spaces, arguments a user typed.
export std::string ShellQuote(std::string_view s);
std::string GetBaseCommand(const Configuration& config);
export Configuration LoadProject(const fs::path& projectFile, std::span<const std::string_view> args);
// A dependency the project file currently running its CrafterBuildProject

View file

@ -0,0 +1,40 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Build:Script;
import std;
import :Clang;
export namespace Crafter {
struct RunScriptOptions {
// The script to run; read only when `named` is set.
std::string name;
// A name was given. Kept apart from `name` because an empty string
// is a name someone can type (`run ""`): it has to be looked up and
// refused, not read as a bare `run`, which lists.
bool named = false;
// What followed the name on the command line, handed to the script
// as ScriptContext::args.
std::vector<std::string> args;
// Print the script names instead of running one.
bool listOnly = false;
// Absolute path of the loaded project.cpp; its directory becomes
// ScriptContext::projectDir. Empty => fall back to cfg.path.
std::filesystem::path projectFile;
};
// Backs `crafter-build run`. Runs the script `opts` names out of
// `projectCfg.scripts` and returns what it returned, which the CLI uses
// as the process exit code. Without a name, or with listOnly, prints the
// names and returns 0.
//
// Returns 1, with the reason on stderr and without running anything,
// when the project defines no scripts, when a script is declared under a
// name that could never be typed (empty, or starting with '-') or under
// one name twice, and when no script has the requested name. A script
// that throws also yields 1, as does one that returns a nonzero value
// outside 1..255: an exit status is eight bits, and a value such as 256
// (a raw wait status passed along by mistake) would otherwise wrap to
// success.
std::int32_t RunScript(Configuration& projectCfg, const RunScriptOptions& opts);
}

View file

@ -10,5 +10,6 @@ export import :Shader;
export import :External;
export import :Test;
export import :Lint;
export import :Script;
export import :Progress;
export import :Asset;

View file

@ -51,7 +51,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// it reaches clang, which must still see a string literal.
CrafterBuildLib->defines.push_back({"CRAFTER_BUILD_VERSION", std::format("\\\"{}\\\"", version)});
{
std::array<fs::path, 12> interfaces = {
std::array<fs::path, 13> interfaces = {
"interfaces/Crafter.Build",
"interfaces/Crafter.Build-Toolchain",
"interfaces/Crafter.Build-Shader",
@ -62,10 +62,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"interfaces/Crafter.Build-Clang",
"interfaces/Crafter.Build-Test",
"interfaces/Crafter.Build-Lint",
"interfaces/Crafter.Build-Script",
"interfaces/Crafter.Build-Progress",
"interfaces/Crafter.Build-Asset",
};
std::array<fs::path, 11> implementations = {
std::array<fs::path, 12> implementations = {
"implementations/Crafter.Build-Toolchain",
"implementations/Crafter.Build-Shader",
"implementations/Crafter.Build-Platform",
@ -75,6 +76,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"implementations/Crafter.Build-Clang",
"implementations/Crafter.Build-Test",
"implementations/Crafter.Build-Lint",
"implementations/Crafter.Build-Script",
"implementations/Crafter.Build-Progress",
"implementations/Crafter.Build-Asset",
};