Crafter.Build/implementations/Crafter.Build-Script.cpp
Jorijn van der Graaf 959c65dbb2
fix(script): a script that throws anything at all exits 1
RunScript caught std::exception only. Anything else a script threw went
on across the project.so boundary, and crafter-build ended in
std::terminate instead of reporting the script's failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:08:26 +02:00

104 lines
3.9 KiB
C++

// 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;
} catch (...) {
Progress::Clear();
std::println(std::cerr, "script '{}' failed: it threw something that isn't a std::exception", found->name);
return 1;
}
}