Expose structured gameplay build diagnostics
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
add_library(faset_build_service STATIC
|
||||
${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp)
|
||||
${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp
|
||||
${PROJECT_SOURCE_DIR}/src/editor/build_diagnostics.cpp)
|
||||
target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
|
||||
target_compile_features(faset_build_service PUBLIC cxx_std_20)
|
||||
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads)
|
||||
if(BUILD_TESTING)
|
||||
add_executable(faset_build_diagnostics_tests ${PROJECT_SOURCE_DIR}/tests/build_diagnostics_tests.cpp)
|
||||
target_link_libraries(faset_build_diagnostics_tests PRIVATE faset_build_service)
|
||||
add_test(NAME build_diagnostics COMMAND faset_build_diagnostics_tests)
|
||||
add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp)
|
||||
target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service)
|
||||
target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <faset/core/json.hpp>
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
|
||||
namespace faset::editor {
|
||||
|
||||
// Parse compiler, MSVC/clang-cl and Lua output into bounded Editor-facing rows.
|
||||
// Only sources under the current project's Scripts directory receive a file
|
||||
// key, so clients cannot offer navigation to unrelated filesystem paths.
|
||||
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
|
||||
const std::filesystem::path& project_root);
|
||||
|
||||
} // namespace faset::editor
|
||||
@@ -22,6 +22,7 @@ struct JobStatus {
|
||||
std::string id, kind, state{"queued"}, stage{"queued"};
|
||||
double progress{};
|
||||
std::string log, error;
|
||||
Json diagnostics = Json::array();
|
||||
Json result = Json::object();
|
||||
bool finished() const {
|
||||
return state == "succeeded" || state == "failed" || state == "cancelled";
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#include <faset/editor/build_diagnostics.hpp>
|
||||
|
||||
#include <faset/core/io.hpp>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <regex>
|
||||
#include <sstream>
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
constexpr std::size_t max_rows = 200;
|
||||
constexpr std::size_t max_line = 8192;
|
||||
constexpr std::size_t max_message = 2048;
|
||||
|
||||
std::string strip_ansi(std::string_view text) {
|
||||
std::string clean;
|
||||
clean.reserve(text.size());
|
||||
for (std::size_t index = 0; index < text.size(); ++index) {
|
||||
if (text[index] == '\x1b' && index + 1 < text.size() && text[index + 1] == '[') {
|
||||
index += 2;
|
||||
while (index < text.size() && !(text[index] >= '@' && text[index] <= '~'))
|
||||
++index;
|
||||
continue;
|
||||
}
|
||||
clean += text[index];
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
std::string normalize_path(std::string path) {
|
||||
std::replace(path.begin(), path.end(), '\\', '/');
|
||||
path = std::filesystem::path(path).lexically_normal().generic_string();
|
||||
while (path.size() > 1 && path.back() == '/')
|
||||
path.pop_back();
|
||||
return path;
|
||||
}
|
||||
bool has_windows_drive(std::string_view path) {
|
||||
return path.size() >= 2 && std::isalpha(static_cast<unsigned char>(path[0])) &&
|
||||
path[1] == ':';
|
||||
}
|
||||
std::string lower_ascii(std::string value) {
|
||||
for (auto& character : value)
|
||||
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
|
||||
return value;
|
||||
}
|
||||
std::string project_source(std::string raw, const std::filesystem::path& project_root) {
|
||||
if (raw.starts_with("lua: "))
|
||||
raw.erase(0, 5);
|
||||
std::replace(raw.begin(), raw.end(), '\\', '/');
|
||||
for (const auto& component : std::filesystem::path(raw))
|
||||
if (component == "..")
|
||||
return {};
|
||||
const auto path = normalize_path(std::move(raw));
|
||||
const auto root = normalize_path(path_to_utf8(project_root));
|
||||
std::string relative;
|
||||
if (!path.empty() && path[0] != '/' && !has_windows_drive(path))
|
||||
relative = path;
|
||||
else {
|
||||
const auto windows = has_windows_drive(path) && has_windows_drive(root);
|
||||
const auto comparable_path = windows ? lower_ascii(path) : path;
|
||||
const auto comparable_root = windows ? lower_ascii(root) : root;
|
||||
if (comparable_path.size() <= comparable_root.size() ||
|
||||
!comparable_path.starts_with(comparable_root) ||
|
||||
comparable_path[comparable_root.size()] != '/')
|
||||
return {};
|
||||
relative = path.substr(root.size() + 1);
|
||||
}
|
||||
if (!relative.starts_with("Scripts/") || relative.size() <= 8)
|
||||
return {};
|
||||
if (relative.find("/../") != std::string::npos || relative.ends_with("/.."))
|
||||
return {};
|
||||
return relative;
|
||||
}
|
||||
int positive_number(const std::string& text) {
|
||||
try {
|
||||
const auto value = std::stoll(text);
|
||||
return value > 0 && value <= 100000000 ? static_cast<int>(value) : 0;
|
||||
} catch (const std::exception&) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
|
||||
const std::filesystem::path& project_root) {
|
||||
static const std::regex msvc(
|
||||
R"(^(.+)\(([0-9]+)(?:,([0-9]+))?\)\s*:\s*(fatal error|error|warning|note)\s*([A-Za-z]+[0-9]+)?\s*:\s*(.*)$)");
|
||||
static const std::regex clang_column(
|
||||
R"(^(.+):([0-9]+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
|
||||
static const std::regex clang_line(
|
||||
R"(^(.+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
|
||||
static const std::regex lua(R"(^(.+\.lua):([0-9]+):\s*(.*)$)");
|
||||
Json rows = Json::array();
|
||||
std::istringstream stream{std::string(raw_log)};
|
||||
std::string raw;
|
||||
while (rows.size() < max_rows && std::getline(stream, raw)) {
|
||||
if (raw.size() > max_line)
|
||||
raw.resize(max_line);
|
||||
auto line = strip_ansi(raw);
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
line.pop_back();
|
||||
std::smatch match;
|
||||
std::string file, message, severity, code;
|
||||
int row = 0, column = 0;
|
||||
bool has_column = false;
|
||||
if (std::regex_match(line, match, msvc) ||
|
||||
std::regex_match(line, match, clang_column)) {
|
||||
file = match[1].str();
|
||||
row = positive_number(match[2].str());
|
||||
has_column = !match[3].str().empty();
|
||||
column = positive_number(match[3].str());
|
||||
severity = match[4].str();
|
||||
code = match[5].str();
|
||||
message = match[6].str();
|
||||
} else if (std::regex_match(line, match, clang_line)) {
|
||||
file = match[1].str();
|
||||
row = positive_number(match[2].str());
|
||||
severity = match[3].str();
|
||||
code = match[4].str();
|
||||
message = match[5].str();
|
||||
} else if (std::regex_match(line, match, lua)) {
|
||||
file = match[1].str();
|
||||
row = positive_number(match[2].str());
|
||||
severity = "error";
|
||||
message = match[3].str();
|
||||
} else
|
||||
continue;
|
||||
if (row == 0 || (has_column && column == 0))
|
||||
continue;
|
||||
if (severity == "fatal error")
|
||||
severity = "error";
|
||||
if (message.size() > max_message)
|
||||
message.resize(max_message);
|
||||
Json diagnostic{{"severity", severity},
|
||||
{"phase", std::string(phase)},
|
||||
{"message", message},
|
||||
{"line", row}};
|
||||
if (column > 0)
|
||||
diagnostic["column"] = column;
|
||||
if (!code.empty())
|
||||
diagnostic["code"] = code;
|
||||
if (auto relative = project_source(file, project_root); !relative.empty())
|
||||
diagnostic["file"] = std::move(relative);
|
||||
rows.push_back(std::move(diagnostic));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
} // namespace faset::editor
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/core/process.hpp>
|
||||
#include <faset/editor/build_cache.hpp>
|
||||
#include <faset/editor/build_diagnostics.hpp>
|
||||
#include <faset/editor/build_service.hpp>
|
||||
#include <faset/scripting/project.hpp>
|
||||
#include <fstream>
|
||||
@@ -94,7 +95,7 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio
|
||||
Json JobStatus::json() const {
|
||||
return {{"id", id}, {"kind", kind}, {"state", state},
|
||||
{"stage", stage}, {"progress", progress}, {"log", log},
|
||||
{"error", error}, {"result", result}};
|
||||
{"error", error}, {"diagnostics", diagnostics}, {"result", result}};
|
||||
}
|
||||
void write_cooked_scene(const fs::path& path, const Json& scene) {
|
||||
validate_scene(scene);
|
||||
@@ -199,6 +200,13 @@ struct BuildService::Impl {
|
||||
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
|
||||
if (job.cancelled)
|
||||
throw Cancelled{};
|
||||
std::string phase;
|
||||
std::size_t diagnostics_before;
|
||||
{
|
||||
std::lock_guard lock(job.mutex);
|
||||
phase = job.status.stage;
|
||||
diagnostics_before = job.status.diagnostics.size();
|
||||
}
|
||||
std::string description = "$";
|
||||
for (const auto& argument : arguments)
|
||||
description += " " + Json(argument).dump();
|
||||
@@ -206,6 +214,35 @@ struct BuildService::Impl {
|
||||
log(job, description);
|
||||
Process process({std::move(arguments), cwd, {}});
|
||||
std::string output;
|
||||
std::string pending;
|
||||
const auto collect_diagnostics = [&](std::string_view chunk, bool final) {
|
||||
pending.append(chunk);
|
||||
std::size_t newline;
|
||||
while ((newline = pending.find('\n')) != std::string::npos) {
|
||||
const auto line = pending.substr(0, newline + 1);
|
||||
pending.erase(0, newline + 1);
|
||||
const auto found = parse_build_diagnostics(line, phase, config.project_root);
|
||||
if (found.empty())
|
||||
continue;
|
||||
std::lock_guard lock(job.mutex);
|
||||
for (const auto& row : found) {
|
||||
if (job.status.diagnostics.size() >= 200)
|
||||
break;
|
||||
job.status.diagnostics.push_back(row);
|
||||
}
|
||||
}
|
||||
if (final && !pending.empty()) {
|
||||
const auto found = parse_build_diagnostics(pending, phase, config.project_root);
|
||||
std::lock_guard lock(job.mutex);
|
||||
for (const auto& row : found) {
|
||||
if (job.status.diagnostics.size() >= 200)
|
||||
break;
|
||||
job.status.diagnostics.push_back(row);
|
||||
}
|
||||
pending.clear();
|
||||
} else if (pending.size() > 8192)
|
||||
pending.erase(0, pending.size() - 8192);
|
||||
};
|
||||
while (true) {
|
||||
if (job.cancelled) {
|
||||
process.cancel();
|
||||
@@ -215,14 +252,29 @@ struct BuildService::Impl {
|
||||
}
|
||||
auto poll = process.poll();
|
||||
log(job, poll.output);
|
||||
collect_diagnostics(poll.output, !poll.running);
|
||||
output += poll.output;
|
||||
if (output.size() > max_log_bytes)
|
||||
output.erase(0, output.size() - max_log_bytes);
|
||||
if (!poll.running) {
|
||||
if (poll.exit_code.value_or(1) != 0)
|
||||
if (poll.exit_code.value_or(1) != 0) {
|
||||
std::lock_guard lock(job.mutex);
|
||||
bool has_error = false;
|
||||
for (std::size_t index = diagnostics_before;
|
||||
index < job.status.diagnostics.size(); ++index)
|
||||
if (job.status.diagnostics[index].value("severity", "") == "error")
|
||||
has_error = true;
|
||||
if (!has_error && job.status.diagnostics.size() < 200)
|
||||
job.status.diagnostics.push_back(
|
||||
{{"severity", "error"},
|
||||
{"phase", phase},
|
||||
{"message", "Process exited with code " +
|
||||
std::to_string(poll.exit_code.value_or(1)) +
|
||||
"; see job log"}});
|
||||
throw std::runtime_error("Process exited with code " +
|
||||
std::to_string(poll.exit_code.value_or(1)) +
|
||||
"; see job log");
|
||||
}
|
||||
return output;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(15));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#include <faset/editor/build_diagnostics.hpp>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
|
||||
using namespace faset;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace {
|
||||
void check(bool condition, std::string_view message) {
|
||||
if (!condition)
|
||||
throw std::runtime_error(std::string(message));
|
||||
}
|
||||
void contracts() {
|
||||
const auto project = fs::path("/project");
|
||||
auto rows = editor::parse_build_diagnostics(
|
||||
"/project/Scripts/Game.cpp:17:4: error: bad field\n", "compile", project);
|
||||
check(rows.size() == 1 && rows[0].at("file") == "Scripts/Game.cpp" &&
|
||||
rows[0].at("line") == 17 && rows[0].at("column") == 4 &&
|
||||
rows[0].at("severity") == "error" && rows[0].at("phase") == "compile",
|
||||
"Clang error has a project-relative source location");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
"\x1b[31mScripts/player.lua:6: unexpected symbol near '='\x1b[0m\n",
|
||||
"schema", project);
|
||||
check(rows.size() == 1 && rows[0].at("file") == "Scripts/player.lua" &&
|
||||
rows[0].at("line") == 6 && rows[0].at("severity") == "error" &&
|
||||
rows[0].at("message") == "unexpected symbol near '='",
|
||||
"Lua syntax error and ANSI stripping work");
|
||||
const auto windows = fs::path(R"(C:\Café)");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n"
|
||||
R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n",
|
||||
"compile", windows);
|
||||
check(rows.size() == 2 && rows[0].at("file") == "Scripts/Game.cpp" &&
|
||||
rows[0].at("code") == "C2143" && rows[0].at("line") == 17 &&
|
||||
rows[1].at("severity") == "warning" && rows[1].at("line") == 19,
|
||||
"Windows drive colon and Unicode directory remain intact");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
"Scripts/Game.cpp:9:3: error: missing value\n"
|
||||
" broken call\n"
|
||||
" ^~~~~~\n"
|
||||
"Scripts/Game.cpp:4:1: note: declared here\n",
|
||||
"compile", project);
|
||||
check(rows.size() == 2 && rows[0].at("severity") == "error" &&
|
||||
rows[1].at("severity") == "note" && rows[1].at("file") == "Scripts/Game.cpp",
|
||||
"Caret and source excerpts do not become duplicate diagnostics");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
"/outside/Scripts/Game.cpp:4:2: error: external\n"
|
||||
"../Scripts/Escape.cpp:5:2: error: escaped\n",
|
||||
"compile", project);
|
||||
check(rows.size() == 2 && !rows[0].contains("file") && !rows[1].contains("file"),
|
||||
"External and traversing sources are never navigable");
|
||||
rows = editor::parse_build_diagnostics(
|
||||
"Scripts/../Scripts/Game.cpp:4:2: error: disguised traversal\n"
|
||||
"Scripts/Game.cpp:4:0: error: invalid column\n",
|
||||
"compile", project);
|
||||
check(rows.size() == 1 && !rows[0].contains("file"),
|
||||
"Traversal stays non-navigable and zero columns are rejected");
|
||||
std::string noisy;
|
||||
for (int index = 0; index < 300; ++index)
|
||||
noisy += "Scripts/Game.cpp:2:1: warning: repeated\n";
|
||||
check(editor::parse_build_diagnostics(noisy, "compile", project).size() <= 200,
|
||||
"Structured diagnostic count is bounded");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
try {
|
||||
contracts();
|
||||
std::cout << "Build diagnostic parsing contracts passed\n";
|
||||
return 0;
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,25 @@ int test_main(int argc, char** argv) {
|
||||
const auto previous_player = sha256_file(player);
|
||||
const auto previous_schema = read_text(schema);
|
||||
const auto previous_manifest = read_text(directory / "manifest.json");
|
||||
atomic_write(config.project_root / "emit-clang-error-and-fail-build", "fixture\n");
|
||||
const auto compile_failed = builds.wait(builds.start_build());
|
||||
check(compile_failed.state == "failed" &&
|
||||
read_text(last_build) == previous_pointer &&
|
||||
compile_failed.json().at("diagnostics").size() == 1 &&
|
||||
compile_failed.json().at("diagnostics")[0].at("file") ==
|
||||
"Scripts/Gameplay.cpp" &&
|
||||
compile_failed.log.find("fixture compile failure") != std::string::npos,
|
||||
"Failed compiler output retains raw log and structured source location");
|
||||
fs::remove(config.project_root / "emit-clang-error-and-fail-build");
|
||||
atomic_write(config.project_root / "fail-unparseable-build", "fixture\n");
|
||||
const auto generic_failed = builds.wait(builds.start_build());
|
||||
check(generic_failed.state == "failed" &&
|
||||
generic_failed.json().at("diagnostics").size() == 1 &&
|
||||
generic_failed.json().at("diagnostics")[0].at("severity") == "error" &&
|
||||
!generic_failed.json().at("diagnostics")[0].contains("file") &&
|
||||
read_text(last_build) == previous_pointer,
|
||||
"Unparseable process failure has a non-navigable diagnostic");
|
||||
fs::remove(config.project_root / "fail-unparseable-build");
|
||||
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
|
||||
"#define BUILD_ONLY 3\n");
|
||||
atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n");
|
||||
|
||||
@@ -39,8 +39,18 @@ int tool_main(int argc, char** argv) {
|
||||
std::cout << "Native packaging fixture validated\n";
|
||||
return 0;
|
||||
}
|
||||
if (argc > 2 && std::string_view(argv[1]) == "--build")
|
||||
if (argc > 2 && std::string_view(argv[1]) == "--build") {
|
||||
if (fs::exists("emit-clang-error-and-fail-build")) {
|
||||
std::cerr << path_to_utf8(fs::current_path() / "Scripts/Gameplay.cpp")
|
||||
<< ":7:3: error: fixture compile failure\n";
|
||||
return 1;
|
||||
}
|
||||
if (fs::exists("fail-unparseable-build")) {
|
||||
std::cerr << "Synthetic native build failed without a source location\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
fs::path build;
|
||||
for (int i = 1; i + 1 < argc; ++i)
|
||||
if (std::string_view(argv[i]) == "-B")
|
||||
|
||||
Reference in New Issue
Block a user