feat(core): Value, reflection, codecs and results, shared by app and client
Crafter.Debug.Core is what the app side and the crafter-debug client both need, without the session: - Value: a JSON value with its own parser and printer, so no consumer interface sees a JSON library. Integers stay integers and whole doubles stay doubles; NaN and the infinities, which JSON cannot spell, are written as strings rather than hidden, because a debugger must not lose a NaN; invalid UTF-8 is replaced so output always parses. Objects keep insertion order, the order an agent reads. - Reflect: aggregate members and names through structured-binding packs and source_location, taken from Crafter.DB, plus scoped-enum names by probing a range of values. Self-tested with static_asserts so a compiler that changes function_name()'s shape fails the build. - Codec<T>: encode, decode and describe for bool, integers, floating point, strings, enums, optional, vector, array, string-keyed maps, Value, Required<T> and every aggregate. Decode reports every problem in a call with its path and, for a typo, the likely intended name, and never throws: a bad call cannot take an app down, which a wrongly typed parameter could in 3DForts' hand-written from_json. - Result, Error and Async: handler results, including ones that take frames to arrive, so callers need no ready:false polling. project.cpp now builds Crafter.Debug.Core as its own library, used by both the app library and the client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb2c06c32e
commit
89a12bcd61
13 changed files with 1951 additions and 11 deletions
118
implementations/Crafter.Debug.Core-Codec.cpp
Normal file
118
implementations/Crafter.Debug.Core-Codec.cpp
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module Crafter.Debug.Core:Codec_impl;
|
||||||
|
import std;
|
||||||
|
import :Value;
|
||||||
|
import :Codec;
|
||||||
|
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// "a string" or "an integer", for a kind as errors name it.
|
||||||
|
std::string WithArticle(ValueKind kind) {
|
||||||
|
const std::string_view name = KindName(kind);
|
||||||
|
if (kind == ValueKind::Null) return std::string(name);
|
||||||
|
const bool vowel = name.starts_with('a') || name.starts_with('i') || name.starts_with('o');
|
||||||
|
return std::format("{} {}", vowel ? "an" : "a", name);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t Distance(std::string_view a, std::string_view b) {
|
||||||
|
std::vector<std::size_t> previous(b.size() + 1);
|
||||||
|
std::vector<std::size_t> current(b.size() + 1);
|
||||||
|
for (std::size_t j = 0; j <= b.size(); ++j) previous[j] = j;
|
||||||
|
for (std::size_t i = 1; i <= a.size(); ++i) {
|
||||||
|
current[0] = i;
|
||||||
|
for (std::size_t j = 1; j <= b.size(); ++j) {
|
||||||
|
const std::size_t substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
|
||||||
|
current[j] = std::min({ previous[j] + 1, current[j - 1] + 1, substitution });
|
||||||
|
}
|
||||||
|
std::swap(previous, current);
|
||||||
|
}
|
||||||
|
return previous[b.size()];
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Join(std::span<const std::string_view> names) {
|
||||||
|
std::string joined;
|
||||||
|
for (const std::string_view name : names) {
|
||||||
|
if (!joined.empty()) joined += ", ";
|
||||||
|
joined += name;
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DecodeContext::Fail(std::string message) {
|
||||||
|
issues.push_back({ Path(), std::move(message) });
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DecodeContext::Report() const {
|
||||||
|
std::string report;
|
||||||
|
for (const DecodeIssue& issue : issues) {
|
||||||
|
if (!report.empty()) report += "; ";
|
||||||
|
report += issue.path.empty() ? issue.message : std::format("{}: {}", issue.path, issue.message);
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecodeContext::Scope::~Scope() {
|
||||||
|
context.segments.pop_back();
|
||||||
|
}
|
||||||
|
|
||||||
|
DecodeContext::Scope DecodeContext::Field(std::string_view name) {
|
||||||
|
segments.emplace_back(name);
|
||||||
|
return Scope(*this);
|
||||||
|
}
|
||||||
|
|
||||||
|
DecodeContext::Scope DecodeContext::Index(std::size_t index) {
|
||||||
|
segments.push_back(std::format("[{}]", index));
|
||||||
|
return Scope(*this);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string DecodeContext::Path() const {
|
||||||
|
std::string path;
|
||||||
|
for (const std::string& segment : segments) {
|
||||||
|
if (!path.empty() && !segment.starts_with('[')) path += '.';
|
||||||
|
path += segment;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string_view> Crafter::Debug::Suggest(std::string_view word, std::span<const std::string_view> candidates) {
|
||||||
|
const std::size_t allowed = word.size() <= 4 ? 1 : 2;
|
||||||
|
std::optional<std::string_view> best;
|
||||||
|
std::size_t bestDistance = allowed + 1;
|
||||||
|
for (const std::string_view candidate : candidates) {
|
||||||
|
const std::size_t distance = Distance(word, candidate);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
best = candidate;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Crafter::Debug::Detail::Expected(std::string_view wanted, const Value& got) {
|
||||||
|
return std::format("expected {}, got {}", wanted, WithArticle(got.Kind()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Crafter::Debug::Detail::Type(std::string_view type) {
|
||||||
|
Value description = Value::MakeObject();
|
||||||
|
description.Set("type", Value(type));
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Crafter::Debug::Detail::UnknownField(std::string_view key, std::span<const std::string_view> fields) {
|
||||||
|
if (fields.empty()) return "unknown field; this takes no arguments";
|
||||||
|
if (const auto suggestion = Suggest(key, fields)) {
|
||||||
|
return std::format("unknown field; did you mean '{}'? (fields: {})", *suggestion, Join(fields));
|
||||||
|
}
|
||||||
|
return std::format("unknown field (fields: {})", Join(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Crafter::Debug::Detail::UnknownName(std::string_view name, std::span<const std::string_view> names) {
|
||||||
|
if (const auto suggestion = Suggest(name, names)) {
|
||||||
|
return std::format("'{}' is not a name here; did you mean '{}'? (one of: {})", name, *suggestion, Join(names));
|
||||||
|
}
|
||||||
|
return std::format("'{}' is not a name here (one of: {})", name, Join(names));
|
||||||
|
}
|
||||||
507
implementations/Crafter.Debug.Core-Value.cpp
Normal file
507
implementations/Crafter.Debug.Core-Value.cpp
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module Crafter.Debug.Core:Value_impl;
|
||||||
|
import std;
|
||||||
|
import :Value;
|
||||||
|
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Nesting deeper than this is refused rather than recursed into: the
|
||||||
|
// parser is recursive, and its text comes from another process.
|
||||||
|
constexpr std::uint32_t maxDepth = 256;
|
||||||
|
|
||||||
|
bool IsDigit(char c) {
|
||||||
|
return c >= '0' && c <= '9';
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendUtf8(std::string& out, std::uint32_t code) {
|
||||||
|
if (code < 0x80) {
|
||||||
|
out += static_cast<char>(code);
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
out += static_cast<char>(0xC0 | (code >> 6));
|
||||||
|
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||||
|
} else if (code < 0x10000) {
|
||||||
|
out += static_cast<char>(0xE0 | (code >> 12));
|
||||||
|
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||||
|
} else {
|
||||||
|
out += static_cast<char>(0xF0 | (code >> 18));
|
||||||
|
out += static_cast<char>(0x80 | ((code >> 12) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | ((code >> 6) & 0x3F));
|
||||||
|
out += static_cast<char>(0x80 | (code & 0x3F));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The length of the valid UTF-8 sequence that starts at text[at], or 0
|
||||||
|
// when it is not one: a stray continuation byte, a truncated or overlong
|
||||||
|
// sequence, a surrogate, or a code point above U+10FFFF.
|
||||||
|
std::size_t Utf8SequenceLength(std::string_view text, std::size_t at) {
|
||||||
|
const auto byte = [&](std::size_t i) { return static_cast<std::uint8_t>(text[at + i]); };
|
||||||
|
const std::uint8_t lead = byte(0);
|
||||||
|
std::size_t length = 0;
|
||||||
|
std::uint8_t low = 0x80;
|
||||||
|
std::uint8_t high = 0xBF;
|
||||||
|
if (lead >= 0xC2 && lead <= 0xDF) {
|
||||||
|
length = 2;
|
||||||
|
} else if (lead >= 0xE0 && lead <= 0xEF) {
|
||||||
|
length = 3;
|
||||||
|
if (lead == 0xE0) low = 0xA0;
|
||||||
|
if (lead == 0xED) high = 0x9F;
|
||||||
|
} else if (lead >= 0xF0 && lead <= 0xF4) {
|
||||||
|
length = 4;
|
||||||
|
if (lead == 0xF0) low = 0x90;
|
||||||
|
if (lead == 0xF4) high = 0x8F;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (at + length > text.size()) return 0;
|
||||||
|
if (byte(1) < low || byte(1) > high) return 0;
|
||||||
|
for (std::size_t i = 2; i < length; ++i) {
|
||||||
|
if (byte(i) < 0x80 || byte(i) > 0xBF) return 0;
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `text` as a JSON string. Invalid UTF-8 becomes U+FFFD, so the output
|
||||||
|
// always parses, whatever bytes an app put in a name.
|
||||||
|
void AppendEscaped(std::string& out, std::string_view text) {
|
||||||
|
out += '"';
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < text.size()) {
|
||||||
|
const std::uint8_t c = static_cast<std::uint8_t>(text[i]);
|
||||||
|
if (c < 0x80) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\b': out += "\\b"; break;
|
||||||
|
case '\f': out += "\\f"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20 || c == 0x7F) {
|
||||||
|
out += std::format("\\u{:04x}", c);
|
||||||
|
} else {
|
||||||
|
out += static_cast<char>(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::size_t length = Utf8SequenceLength(text, i);
|
||||||
|
if (length == 0) {
|
||||||
|
out += "\xEF\xBF\xBD";
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.append(text.substr(i, length));
|
||||||
|
i += length;
|
||||||
|
}
|
||||||
|
out += '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendNumber(std::string& out, double number) {
|
||||||
|
if (std::isnan(number)) {
|
||||||
|
out += "\"NaN\"";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (std::isinf(number)) {
|
||||||
|
out += number > 0 ? "\"Infinity\"" : "\"-Infinity\"";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::array<char, 32> buffer{};
|
||||||
|
const auto [end, error] = std::to_chars(buffer.data(), buffer.data() + buffer.size(), number);
|
||||||
|
const std::string_view digits(buffer.data(), static_cast<std::size_t>(end - buffer.data()));
|
||||||
|
out += digits;
|
||||||
|
// A whole double keeps a fraction, so it reads back as a Number.
|
||||||
|
if (digits.find_first_of(".eE") == std::string_view::npos) out += ".0";
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
public:
|
||||||
|
explicit Parser(std::string_view text) : text(text) {}
|
||||||
|
|
||||||
|
Value Document() {
|
||||||
|
SkipWhitespace();
|
||||||
|
Value value = Any(0);
|
||||||
|
SkipWhitespace();
|
||||||
|
if (position != text.size()) Fail("unexpected text after the value");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string_view text;
|
||||||
|
std::size_t position = 0;
|
||||||
|
|
||||||
|
[[noreturn]] void Fail(std::string_view what) const {
|
||||||
|
throw ParseError(position, what);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AtEnd() const {
|
||||||
|
return position >= text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Peek(char c) const {
|
||||||
|
return !AtEnd() && text[position] == c;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Consume(char c) {
|
||||||
|
if (!Peek(c)) return false;
|
||||||
|
++position;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SkipWhitespace() {
|
||||||
|
while (!AtEnd() && (text[position] == ' ' || text[position] == '\t' || text[position] == '\n' || text[position] == '\r')) ++position;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Literal(std::string_view word) {
|
||||||
|
if (text.substr(position, word.size()) != word) Fail("expected a value");
|
||||||
|
position += word.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Any(std::uint32_t depth) {
|
||||||
|
if (depth > maxDepth) Fail("nested too deeply");
|
||||||
|
if (AtEnd()) Fail("unexpected end of text");
|
||||||
|
switch (text[position]) {
|
||||||
|
case '{': return Object(depth);
|
||||||
|
case '[': return Array(depth);
|
||||||
|
case '"': return Value(String());
|
||||||
|
case 't': Literal("true"); return Value(true);
|
||||||
|
case 'f': Literal("false"); return Value(false);
|
||||||
|
case 'n': Literal("null"); return Value();
|
||||||
|
default: return Number();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Object(std::uint32_t depth) {
|
||||||
|
++position;
|
||||||
|
Value object = Value::MakeObject();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (Consume('}')) return object;
|
||||||
|
while (true) {
|
||||||
|
SkipWhitespace();
|
||||||
|
if (!Peek('"')) Fail("expected a member name in quotes");
|
||||||
|
const std::string key = String();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (!Consume(':')) Fail("expected ':' after a member name");
|
||||||
|
SkipWhitespace();
|
||||||
|
object.Set(key, Any(depth + 1));
|
||||||
|
SkipWhitespace();
|
||||||
|
if (Consume('}')) return object;
|
||||||
|
if (!Consume(',')) Fail("expected ',' or '}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Array(std::uint32_t depth) {
|
||||||
|
++position;
|
||||||
|
Value array = Value::MakeArray();
|
||||||
|
SkipWhitespace();
|
||||||
|
if (Consume(']')) return array;
|
||||||
|
while (true) {
|
||||||
|
SkipWhitespace();
|
||||||
|
array.Push(Any(depth + 1));
|
||||||
|
SkipWhitespace();
|
||||||
|
if (Consume(']')) return array;
|
||||||
|
if (!Consume(',')) Fail("expected ',' or ']'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t Hex4() {
|
||||||
|
if (position + 4 > text.size()) Fail("truncated \\u escape");
|
||||||
|
std::uint32_t code = 0;
|
||||||
|
const auto [end, error] = std::from_chars(text.data() + position, text.data() + position + 4, code, 16);
|
||||||
|
if (error != std::errc() || end != text.data() + position + 4) Fail("malformed \\u escape");
|
||||||
|
position += 4;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string String() {
|
||||||
|
++position;
|
||||||
|
std::string out;
|
||||||
|
while (true) {
|
||||||
|
if (AtEnd()) Fail("unterminated string");
|
||||||
|
const char c = text[position];
|
||||||
|
if (c == '"') {
|
||||||
|
++position;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (static_cast<std::uint8_t>(c) < 0x20) Fail("control character in a string");
|
||||||
|
++position;
|
||||||
|
if (c != '\\') {
|
||||||
|
out += c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (AtEnd()) Fail("unterminated escape");
|
||||||
|
const char escape = text[position++];
|
||||||
|
switch (escape) {
|
||||||
|
case '"': out += '"'; break;
|
||||||
|
case '\\': out += '\\'; break;
|
||||||
|
case '/': out += '/'; break;
|
||||||
|
case 'b': out += '\b'; break;
|
||||||
|
case 'f': out += '\f'; break;
|
||||||
|
case 'n': out += '\n'; break;
|
||||||
|
case 'r': out += '\r'; break;
|
||||||
|
case 't': out += '\t'; break;
|
||||||
|
case 'u': {
|
||||||
|
std::uint32_t code = Hex4();
|
||||||
|
if (code >= 0xD800 && code <= 0xDBFF) {
|
||||||
|
if (text.substr(position, 2) != "\\u") Fail("unpaired surrogate");
|
||||||
|
position += 2;
|
||||||
|
const std::uint32_t low = Hex4();
|
||||||
|
if (low < 0xDC00 || low > 0xDFFF) Fail("unpaired surrogate");
|
||||||
|
code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
|
||||||
|
} else if (code >= 0xDC00 && code <= 0xDFFF) {
|
||||||
|
Fail("unpaired surrogate");
|
||||||
|
}
|
||||||
|
AppendUtf8(out, code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
--position;
|
||||||
|
Fail("unknown escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Number() {
|
||||||
|
const std::size_t start = position;
|
||||||
|
Consume('-');
|
||||||
|
if (AtEnd() || !IsDigit(text[position])) Fail("expected a value");
|
||||||
|
if (!Consume('0')) {
|
||||||
|
while (!AtEnd() && IsDigit(text[position])) ++position;
|
||||||
|
}
|
||||||
|
bool whole = true;
|
||||||
|
if (Consume('.')) {
|
||||||
|
whole = false;
|
||||||
|
if (AtEnd() || !IsDigit(text[position])) Fail("expected a digit after the decimal point");
|
||||||
|
while (!AtEnd() && IsDigit(text[position])) ++position;
|
||||||
|
}
|
||||||
|
if (Consume('e') || Consume('E')) {
|
||||||
|
whole = false;
|
||||||
|
if (!Consume('+')) Consume('-');
|
||||||
|
if (AtEnd() || !IsDigit(text[position])) Fail("expected a digit in the exponent");
|
||||||
|
while (!AtEnd() && IsDigit(text[position])) ++position;
|
||||||
|
}
|
||||||
|
const char* first = text.data() + start;
|
||||||
|
const char* last = text.data() + position;
|
||||||
|
if (whole) {
|
||||||
|
std::int64_t integer = 0;
|
||||||
|
const auto [end, error] = std::from_chars(first, last, integer);
|
||||||
|
if (error == std::errc() && end == last) return Value(integer);
|
||||||
|
// Beyond int64: read as a double below.
|
||||||
|
}
|
||||||
|
double number = 0;
|
||||||
|
const auto [end, error] = std::from_chars(first, last, number);
|
||||||
|
if (error == std::errc::result_out_of_range) {
|
||||||
|
position = start;
|
||||||
|
Fail("number out of range");
|
||||||
|
}
|
||||||
|
if (error != std::errc() || end != last) {
|
||||||
|
position = start;
|
||||||
|
Fail("malformed number");
|
||||||
|
}
|
||||||
|
return Value(number);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
std::logic_error WrongKind(ValueKind actual, std::string_view wanted) {
|
||||||
|
return std::logic_error(std::format("Value is {}, not {}", KindName(actual), wanted));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string_view Crafter::Debug::KindName(ValueKind kind) {
|
||||||
|
switch (kind) {
|
||||||
|
case ValueKind::Null: return "null";
|
||||||
|
case ValueKind::Bool: return "boolean";
|
||||||
|
case ValueKind::Integer: return "integer";
|
||||||
|
case ValueKind::Number: return "number";
|
||||||
|
case ValueKind::String: return "string";
|
||||||
|
case ValueKind::Array: return "array";
|
||||||
|
case ValueKind::Object: return "object";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseError::ParseError(std::size_t offset, std::string_view what)
|
||||||
|
: std::runtime_error(std::format("{} at offset {}", what, offset)), offset(offset) {}
|
||||||
|
|
||||||
|
Value Value::MakeArray(std::vector<Value> items) {
|
||||||
|
Value value;
|
||||||
|
value.kind = ValueKind::Array;
|
||||||
|
value.items = std::move(items);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Value::MakeObject() {
|
||||||
|
Value value;
|
||||||
|
value.kind = ValueKind::Object;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Value::AsBool() const {
|
||||||
|
if (kind != ValueKind::Bool) throw WrongKind(kind, "a boolean");
|
||||||
|
return boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int64_t Value::AsInteger() const {
|
||||||
|
if (kind != ValueKind::Integer) throw WrongKind(kind, "an integer");
|
||||||
|
return integer;
|
||||||
|
}
|
||||||
|
|
||||||
|
double Value::AsNumber() const {
|
||||||
|
if (kind == ValueKind::Integer) return static_cast<double>(integer);
|
||||||
|
if (kind != ValueKind::Number) throw WrongKind(kind, "a number");
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& Value::AsString() const {
|
||||||
|
if (kind != ValueKind::String) throw WrongKind(kind, "a string");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::span<const Value> Value::Items() const {
|
||||||
|
if (kind != ValueKind::Array) throw WrongKind(kind, "an array");
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::span<Value> Value::Items() {
|
||||||
|
if (kind != ValueKind::Array) throw WrongKind(kind, "an array");
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Value::Push(Value item) {
|
||||||
|
if (kind == ValueKind::Null) kind = ValueKind::Array;
|
||||||
|
if (kind != ValueKind::Array) throw WrongKind(kind, "an array");
|
||||||
|
items.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
const Value* Value::Find(std::string_view key) const {
|
||||||
|
if (kind != ValueKind::Object) return nullptr;
|
||||||
|
const auto found = std::ranges::find(keys, key);
|
||||||
|
return found == keys.end() ? nullptr : &values[static_cast<std::size_t>(found - keys.begin())];
|
||||||
|
}
|
||||||
|
|
||||||
|
Value* Value::Find(std::string_view key) {
|
||||||
|
return const_cast<Value*>(std::as_const(*this).Find(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Value& Value::Set(std::string_view key, Value value) {
|
||||||
|
if (kind == ValueKind::Null) kind = ValueKind::Object;
|
||||||
|
if (kind != ValueKind::Object) throw WrongKind(kind, "an object");
|
||||||
|
if (Value* existing = Find(key)) {
|
||||||
|
*existing = std::move(value);
|
||||||
|
return *existing;
|
||||||
|
}
|
||||||
|
keys.emplace_back(key);
|
||||||
|
values.push_back(std::move(value));
|
||||||
|
return values.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
Value& Value::operator[](std::string_view key) {
|
||||||
|
if (Value* existing = Find(key)) return *existing;
|
||||||
|
return Set(key, Value());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Value::Erase(std::string_view key) {
|
||||||
|
if (kind != ValueKind::Object) return false;
|
||||||
|
const auto found = std::ranges::find(keys, key);
|
||||||
|
if (found == keys.end()) return false;
|
||||||
|
const auto index = found - keys.begin();
|
||||||
|
keys.erase(found);
|
||||||
|
values.erase(values.begin() + index);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t Value::Size() const {
|
||||||
|
if (kind == ValueKind::Array) return items.size();
|
||||||
|
if (kind == ValueKind::Object) return keys.size();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Value Value::Parse(std::string_view json) {
|
||||||
|
return Parser(json).Document();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Value::Dump() const {
|
||||||
|
std::string out;
|
||||||
|
DumpTo(out, 0, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Value::DumpPretty(std::uint32_t indent) const {
|
||||||
|
std::string out;
|
||||||
|
DumpTo(out, indent == 0 ? 1 : indent, 0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Value::DumpTo(std::string& out, std::uint32_t indent, std::uint32_t depth) const {
|
||||||
|
const auto newline = [&](std::uint32_t level) {
|
||||||
|
if (indent == 0) return;
|
||||||
|
out += '\n';
|
||||||
|
out.append(static_cast<std::size_t>(indent) * level, ' ');
|
||||||
|
};
|
||||||
|
switch (kind) {
|
||||||
|
case ValueKind::Null: out += "null"; return;
|
||||||
|
case ValueKind::Bool: out += boolean ? "true" : "false"; return;
|
||||||
|
case ValueKind::Integer: out += std::to_string(integer); return;
|
||||||
|
case ValueKind::Number: AppendNumber(out, number); return;
|
||||||
|
case ValueKind::String: AppendEscaped(out, text); return;
|
||||||
|
case ValueKind::Array:
|
||||||
|
if (items.empty()) {
|
||||||
|
out += "[]";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out += '[';
|
||||||
|
for (std::size_t i = 0; i < items.size(); ++i) {
|
||||||
|
if (i > 0) out += ',';
|
||||||
|
newline(depth + 1);
|
||||||
|
items[i].DumpTo(out, indent, depth + 1);
|
||||||
|
}
|
||||||
|
newline(depth);
|
||||||
|
out += ']';
|
||||||
|
return;
|
||||||
|
case ValueKind::Object:
|
||||||
|
if (keys.empty()) {
|
||||||
|
out += "{}";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out += '{';
|
||||||
|
for (std::size_t i = 0; i < keys.size(); ++i) {
|
||||||
|
if (i > 0) out += ',';
|
||||||
|
newline(depth + 1);
|
||||||
|
AppendEscaped(out, keys[i]);
|
||||||
|
out += indent == 0 ? ":" : ": ";
|
||||||
|
values[i].DumpTo(out, indent, depth + 1);
|
||||||
|
}
|
||||||
|
newline(depth);
|
||||||
|
out += '}';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Crafter::Debug {
|
||||||
|
bool operator==(const Value& a, const Value& b) {
|
||||||
|
if (a.IsNumber() && b.IsNumber()) {
|
||||||
|
if (a.kind == ValueKind::Integer && b.kind == ValueKind::Integer) return a.integer == b.integer;
|
||||||
|
return a.AsNumber() == b.AsNumber();
|
||||||
|
}
|
||||||
|
if (a.kind != b.kind) return false;
|
||||||
|
switch (a.kind) {
|
||||||
|
case ValueKind::Null: return true;
|
||||||
|
case ValueKind::Bool: return a.boolean == b.boolean;
|
||||||
|
case ValueKind::String: return a.text == b.text;
|
||||||
|
case ValueKind::Array: return a.items == b.items;
|
||||||
|
case ValueKind::Object:
|
||||||
|
if (a.keys.size() != b.keys.size()) return false;
|
||||||
|
for (std::size_t i = 0; i < a.keys.size(); ++i) {
|
||||||
|
const Value* other = b.Find(a.keys[i]);
|
||||||
|
if (other == nullptr || !(a.values[i] == *other)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
512
interfaces/Crafter.Debug.Core-Codec.cppm
Normal file
512
interfaces/Crafter.Debug.Core-Codec.cppm
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// How C++ values become Values and back, and how their types are described
|
||||||
|
// to an agent. Codec<T> is the customization point: one specialization per
|
||||||
|
// type, with three static functions,
|
||||||
|
//
|
||||||
|
// static Value Encode(const T& value);
|
||||||
|
// static void Decode(const Value& value, T& out, DecodeContext& context);
|
||||||
|
// static Value Describe();
|
||||||
|
//
|
||||||
|
// provided here for bool, integers, floating point, std::string, enums,
|
||||||
|
// std::optional, std::vector, std::array, string-keyed maps, Value itself,
|
||||||
|
// Required<T> and every aggregate, whose members are found by :Reflect.
|
||||||
|
// Specialize it for a type that is none of those.
|
||||||
|
//
|
||||||
|
// Decode writes into an existing object, so a field the input leaves out
|
||||||
|
// keeps the value it had: for a command's arguments, the struct's default
|
||||||
|
// member initializer. It reports through the DecodeContext instead of
|
||||||
|
// throwing, and keeps going after a problem, so one reply names every
|
||||||
|
// mistake in a call, each with its path ("target.points[2].x") and, for an
|
||||||
|
// unknown field, the field the caller most likely meant. Describe gives a
|
||||||
|
// JSON-Schema-like Value that `crafter-debug help` renders.
|
||||||
|
export module Crafter.Debug.Core:Codec;
|
||||||
|
import std;
|
||||||
|
import :Value;
|
||||||
|
import :Reflect;
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
// An argument field the caller must pass. Decoding fails when it is
|
||||||
|
// missing, and help lists it as required instead of showing a default.
|
||||||
|
template<class T>
|
||||||
|
struct Required {
|
||||||
|
T value{};
|
||||||
|
constexpr T& operator*() noexcept { return value; }
|
||||||
|
constexpr const T& operator*() const noexcept { return value; }
|
||||||
|
constexpr T* operator->() noexcept { return &value; }
|
||||||
|
constexpr const T* operator->() const noexcept { return &value; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DecodeIssue {
|
||||||
|
// Where, as "target.points[2].x"; empty for the value itself.
|
||||||
|
std::string path;
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DecodeContext {
|
||||||
|
public:
|
||||||
|
// Records a problem at the current path.
|
||||||
|
void Fail(std::string message);
|
||||||
|
bool Ok() const { return issues.empty(); }
|
||||||
|
std::span<const DecodeIssue> Issues() const { return issues; }
|
||||||
|
// Every problem, "path: message", separated by "; ".
|
||||||
|
std::string Report() const;
|
||||||
|
|
||||||
|
// Extends the path while it lives: a member, or an array item.
|
||||||
|
class Scope {
|
||||||
|
public:
|
||||||
|
~Scope();
|
||||||
|
Scope(const Scope&) = delete;
|
||||||
|
Scope& operator=(const Scope&) = delete;
|
||||||
|
|
||||||
|
private:
|
||||||
|
friend class DecodeContext;
|
||||||
|
explicit Scope(DecodeContext& context) : context(context) {}
|
||||||
|
DecodeContext& context;
|
||||||
|
};
|
||||||
|
[[nodiscard]] Scope Field(std::string_view name);
|
||||||
|
[[nodiscard]] Scope Index(std::size_t index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<std::string> segments;
|
||||||
|
std::vector<DecodeIssue> issues;
|
||||||
|
std::string Path() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The candidate a mistyped `word` most likely meant, if one is close
|
||||||
|
// enough to suggest: Levenshtein distance 1 for words of up to four
|
||||||
|
// characters, 2 for longer ones.
|
||||||
|
std::optional<std::string_view> Suggest(std::string_view word, std::span<const std::string_view> candidates);
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
struct Codec;
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
concept Codable = requires(const T& in, const Value& value, T& out, DecodeContext& context) {
|
||||||
|
{ Codec<T>::Encode(in) } -> std::same_as<Value>;
|
||||||
|
Codec<T>::Decode(value, out, context);
|
||||||
|
{ Codec<T>::Describe() } -> std::same_as<Value>;
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
Value Encode(const T& value) {
|
||||||
|
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
|
||||||
|
return Codec<T>::Encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
void Decode(const Value& value, T& out, DecodeContext& context) {
|
||||||
|
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
|
||||||
|
Codec<T>::Decode(value, out, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
Value Describe() {
|
||||||
|
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
|
||||||
|
return Codec<T>::Describe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers the codecs below share. Module linkage, not an anonymous
|
||||||
|
// namespace: the exported templates that call them are instantiated in the
|
||||||
|
// consumer's translation units.
|
||||||
|
namespace Crafter::Debug::Detail {
|
||||||
|
// "expected an integer, got a string"
|
||||||
|
std::string Expected(std::string_view wanted, const Value& got);
|
||||||
|
// {"type": type}
|
||||||
|
Value Type(std::string_view type);
|
||||||
|
// The message for a field an aggregate does not have.
|
||||||
|
std::string UnknownField(std::string_view key, std::span<const std::string_view> fields);
|
||||||
|
// The message for a name a scoped enum does not have.
|
||||||
|
std::string UnknownName(std::string_view name, std::span<const std::string_view> names);
|
||||||
|
|
||||||
|
// The integer types that are numbers: not bool, and none of the
|
||||||
|
// character types, which std::in_range refuses and which read as text.
|
||||||
|
template<class T>
|
||||||
|
concept Number = std::integral<T> && !std::same_as<T, bool> && !std::same_as<T, char> && !std::same_as<T, wchar_t>
|
||||||
|
&& !std::same_as<T, char8_t> && !std::same_as<T, char16_t> && !std::same_as<T, char32_t>;
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
inline constexpr bool isRequired = false;
|
||||||
|
template<class T>
|
||||||
|
inline constexpr bool isRequired<Required<T>> = true;
|
||||||
|
|
||||||
|
// Describe recursion depth, so a type that contains itself (through a
|
||||||
|
// vector, say) is described a few levels deep instead of forever.
|
||||||
|
inline thread_local std::uint32_t describeDepth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
template<>
|
||||||
|
struct Codec<bool> {
|
||||||
|
static Value Encode(bool value) { return Value(value); }
|
||||||
|
static void Decode(const Value& value, bool& out, DecodeContext& context) {
|
||||||
|
if (!value.IsBool()) {
|
||||||
|
context.Fail(Detail::Expected("a boolean", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out = value.AsBool();
|
||||||
|
}
|
||||||
|
static Value Describe() { return Detail::Type("boolean"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<Detail::Number T>
|
||||||
|
struct Codec<T> {
|
||||||
|
static Value Encode(T value) { return Value(value); }
|
||||||
|
|
||||||
|
static void Decode(const Value& value, T& out, DecodeContext& context) {
|
||||||
|
if (value.IsInteger()) {
|
||||||
|
const std::int64_t integer = value.AsInteger();
|
||||||
|
if (std::in_range<T>(integer)) {
|
||||||
|
out = static_cast<T>(integer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(std::format("{} is out of range ({}..{})", integer, std::numeric_limits<T>::min(), std::numeric_limits<T>::max()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.IsNumber()) {
|
||||||
|
// A double: an unsigned 64-bit value above the int64 range
|
||||||
|
// arrives as one, and so does 3.0 from a lenient client.
|
||||||
|
const double number = value.AsNumber();
|
||||||
|
if (number == std::trunc(number) && number >= static_cast<double>(std::numeric_limits<T>::min()) && number <= static_cast<double>(std::numeric_limits<T>::max())) {
|
||||||
|
out = static_cast<T>(number);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(std::format("expected an integer in {}..{}, got {}", std::numeric_limits<T>::min(), std::numeric_limits<T>::max(), value.Dump()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(Detail::Expected("an integer", value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Detail::Type("integer");
|
||||||
|
if constexpr (sizeof(T) < sizeof(std::int64_t)) {
|
||||||
|
description.Set("minimum", Value(std::numeric_limits<T>::min()));
|
||||||
|
description.Set("maximum", Value(std::numeric_limits<T>::max()));
|
||||||
|
}
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template<std::floating_point T>
|
||||||
|
struct Codec<T> {
|
||||||
|
static Value Encode(T value) { return Value(static_cast<double>(value)); }
|
||||||
|
|
||||||
|
static void Decode(const Value& value, T& out, DecodeContext& context) {
|
||||||
|
if (value.IsNumber()) {
|
||||||
|
out = static_cast<T>(value.AsNumber());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The spellings Value::Dump uses for what JSON cannot write.
|
||||||
|
if (value.IsString()) {
|
||||||
|
const std::string& text = value.AsString();
|
||||||
|
if (text == "NaN") {
|
||||||
|
out = std::numeric_limits<T>::quiet_NaN();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text == "Infinity" || text == "-Infinity") {
|
||||||
|
out = text.starts_with('-') ? -std::numeric_limits<T>::infinity() : std::numeric_limits<T>::infinity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.Fail(Detail::Expected("a number", value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() { return Detail::Type("number"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<>
|
||||||
|
struct Codec<std::string> {
|
||||||
|
static Value Encode(const std::string& value) { return Value(value); }
|
||||||
|
static void Decode(const Value& value, std::string& out, DecodeContext& context) {
|
||||||
|
if (!value.IsString()) {
|
||||||
|
context.Fail(Detail::Expected("a string", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out = value.AsString();
|
||||||
|
}
|
||||||
|
static Value Describe() { return Detail::Type("string"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// For results and watches that hand out views. Nothing decodes into
|
||||||
|
// one: it would point into a request that is gone by the time it is read.
|
||||||
|
template<>
|
||||||
|
struct Codec<std::string_view> {
|
||||||
|
static Value Encode(std::string_view value) { return Value(value); }
|
||||||
|
static void Decode(const Value&, std::string_view&, DecodeContext& context) {
|
||||||
|
context.Fail("cannot be set: a std::string_view field is read-only, use std::string");
|
||||||
|
}
|
||||||
|
static Value Describe() { return Detail::Type("string"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scoped enums travel by name, and also accept the integer; unscoped ones
|
||||||
|
// by value only (see EnumRange in :Reflect).
|
||||||
|
template<class E>
|
||||||
|
requires std::is_enum_v<E>
|
||||||
|
struct Codec<E> {
|
||||||
|
static Value Encode(E value) {
|
||||||
|
if constexpr (std::is_scoped_enum_v<E>) {
|
||||||
|
if (const auto name = EnumName(value)) return Value(*name);
|
||||||
|
}
|
||||||
|
return Value(std::to_underlying(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, E& out, DecodeContext& context) {
|
||||||
|
if constexpr (std::is_scoped_enum_v<E>) {
|
||||||
|
if (value.IsString()) {
|
||||||
|
if (const auto parsed = EnumFromName<E>(value.AsString())) {
|
||||||
|
out = *parsed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(Detail::UnknownName(value.AsString(), Names()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value.IsInteger()) {
|
||||||
|
const std::int64_t integer = value.AsInteger();
|
||||||
|
if (std::in_range<std::underlying_type_t<E>>(integer)) {
|
||||||
|
out = static_cast<E>(integer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(std::format("{} is out of range for this enum", integer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.Fail(Detail::Expected(std::is_scoped_enum_v<E> ? "a name or an integer" : "an integer", value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
if constexpr (std::is_scoped_enum_v<E>) {
|
||||||
|
Value description = Detail::Type("enum");
|
||||||
|
Value values = Value::MakeArray();
|
||||||
|
for (const std::string_view name : Names()) values.Push(Value(name));
|
||||||
|
description.Set("values", std::move(values));
|
||||||
|
return description;
|
||||||
|
} else {
|
||||||
|
return Detail::Type("integer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::vector<std::string_view> Names()
|
||||||
|
requires std::is_scoped_enum_v<E>
|
||||||
|
{
|
||||||
|
std::vector<std::string_view> names;
|
||||||
|
for (const Enumerator<E>& e : enumerators<E>) names.push_back(e.name);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
struct Codec<std::optional<T>> {
|
||||||
|
static Value Encode(const std::optional<T>& value) {
|
||||||
|
return value ? Debug::Encode(*value) : Value();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, std::optional<T>& out, DecodeContext& context) {
|
||||||
|
if (value.IsNull()) {
|
||||||
|
out.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!out) out.emplace();
|
||||||
|
Debug::Decode(value, *out, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Debug::Describe<T>();
|
||||||
|
description.Set("nullable", true);
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T, class A>
|
||||||
|
struct Codec<std::vector<T, A>> {
|
||||||
|
static Value Encode(const std::vector<T, A>& value) {
|
||||||
|
Value array = Value::MakeArray();
|
||||||
|
for (const T& item : value) array.Push(Debug::Encode(item));
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, std::vector<T, A>& out, DecodeContext& context) {
|
||||||
|
if (!value.IsArray()) {
|
||||||
|
context.Fail(Detail::Expected("an array", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::vector<T, A> decoded;
|
||||||
|
decoded.reserve(value.Size());
|
||||||
|
const std::span<const Value> items = value.Items();
|
||||||
|
for (std::size_t i = 0; i < items.size(); ++i) {
|
||||||
|
const auto scope = context.Index(i);
|
||||||
|
// Through a local, so std::vector<bool> works too.
|
||||||
|
T item{};
|
||||||
|
Debug::Decode(items[i], item, context);
|
||||||
|
decoded.push_back(std::move(item));
|
||||||
|
}
|
||||||
|
out = std::move(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Detail::Type("array");
|
||||||
|
description.Set("items", Debug::Describe<T>());
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T, std::size_t N>
|
||||||
|
struct Codec<std::array<T, N>> {
|
||||||
|
static Value Encode(const std::array<T, N>& value) {
|
||||||
|
Value array = Value::MakeArray();
|
||||||
|
for (const T& item : value) array.Push(Debug::Encode(item));
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, std::array<T, N>& out, DecodeContext& context) {
|
||||||
|
if (!value.IsArray()) {
|
||||||
|
context.Fail(Detail::Expected("an array", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.Size() != N) {
|
||||||
|
context.Fail(std::format("expected {} items, got {}", N, value.Size()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::span<const Value> items = value.Items();
|
||||||
|
for (std::size_t i = 0; i < N; ++i) {
|
||||||
|
const auto scope = context.Index(i);
|
||||||
|
Debug::Decode(items[i], out[i], context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Detail::Type("array");
|
||||||
|
description.Set("items", Debug::Describe<T>());
|
||||||
|
description.Set("length", Value(N));
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Maps keyed by string: std::map and std::unordered_map alike.
|
||||||
|
template<class M>
|
||||||
|
struct MapCodec {
|
||||||
|
using Mapped = typename M::mapped_type;
|
||||||
|
|
||||||
|
static Value Encode(const M& value) {
|
||||||
|
Value object = Value::MakeObject();
|
||||||
|
for (const auto& [key, item] : value) object.Set(key, Debug::Encode(item));
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, M& out, DecodeContext& context) {
|
||||||
|
if (!value.IsObject()) {
|
||||||
|
context.Fail(Detail::Expected("an object", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M decoded;
|
||||||
|
for (const auto& [key, item] : value.Members()) {
|
||||||
|
const auto scope = context.Field(key);
|
||||||
|
Mapped mapped{};
|
||||||
|
Debug::Decode(item, mapped, context);
|
||||||
|
decoded.emplace(key, std::move(mapped));
|
||||||
|
}
|
||||||
|
out = std::move(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Detail::Type("object");
|
||||||
|
description.Set("values", Debug::Describe<Mapped>());
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T, class C, class A>
|
||||||
|
struct Codec<std::map<std::string, T, C, A>> : MapCodec<std::map<std::string, T, C, A>> {};
|
||||||
|
|
||||||
|
template<class T, class H, class E, class A>
|
||||||
|
struct Codec<std::unordered_map<std::string, T, H, E, A>> : MapCodec<std::unordered_map<std::string, T, H, E, A>> {};
|
||||||
|
|
||||||
|
template<>
|
||||||
|
struct Codec<Value> {
|
||||||
|
static Value Encode(const Value& value) { return value; }
|
||||||
|
static void Decode(const Value& value, Value& out, DecodeContext&) { out = value; }
|
||||||
|
static Value Describe() { return Detail::Type("any"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
struct Codec<Required<T>> {
|
||||||
|
static Value Encode(const Required<T>& value) { return Debug::Encode(value.value); }
|
||||||
|
static void Decode(const Value& value, Required<T>& out, DecodeContext& context) { Debug::Decode(value, out.value, context); }
|
||||||
|
static Value Describe() { return Debug::Describe<T>(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Every aggregate: an object with one member per field, by name.
|
||||||
|
template<class T>
|
||||||
|
requires Aggregate<T>
|
||||||
|
struct Codec<T> {
|
||||||
|
static Value Encode(const T& value) {
|
||||||
|
Value object = Value::MakeObject();
|
||||||
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
||||||
|
(object.Set(MemberName<I, T>(), Debug::Encode(Member<I>(value))), ...);
|
||||||
|
}(std::make_index_sequence<memberCount<T>>{});
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void Decode(const Value& value, T& out, DecodeContext& context) {
|
||||||
|
if (!value.IsObject()) {
|
||||||
|
context.Fail(Detail::Expected("an object", value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
||||||
|
([&] {
|
||||||
|
constexpr std::string_view name = MemberName<I, T>();
|
||||||
|
const auto scope = context.Field(name);
|
||||||
|
if (const Value* field = value.Find(name)) {
|
||||||
|
Debug::Decode(*field, Member<I>(out), context);
|
||||||
|
} else if constexpr (Detail::isRequired<MemberType<I, T>>) {
|
||||||
|
context.Fail("missing, and this field is required");
|
||||||
|
}
|
||||||
|
}(), ...);
|
||||||
|
}(std::make_index_sequence<memberCount<T>>{});
|
||||||
|
static constexpr std::array<std::string_view, memberCount<T>> names = Names();
|
||||||
|
for (const auto& [key, item] : value.Members()) {
|
||||||
|
if (std::ranges::find(names, std::string_view(key)) != names.end()) continue;
|
||||||
|
const auto scope = context.Field(key);
|
||||||
|
context.Fail(Detail::UnknownField(key, names));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Value Describe() {
|
||||||
|
Value description = Detail::Type("object");
|
||||||
|
description.Set("name", Value(TypeName<T>()));
|
||||||
|
if (Detail::describeDepth >= 8) {
|
||||||
|
description.Set("truncated", true);
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
++Detail::describeDepth;
|
||||||
|
Value fields = Value::MakeArray();
|
||||||
|
const T defaults{};
|
||||||
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
||||||
|
([&] {
|
||||||
|
using M = MemberType<I, T>;
|
||||||
|
Value field = Value::MakeObject();
|
||||||
|
field.Set("name", Value(MemberName<I, T>()));
|
||||||
|
field.Set("type", Debug::Describe<M>());
|
||||||
|
if constexpr (Detail::isRequired<M>) {
|
||||||
|
field.Set("required", true);
|
||||||
|
} else {
|
||||||
|
field.Set("default", Debug::Encode(Member<I>(defaults)));
|
||||||
|
}
|
||||||
|
fields.Push(std::move(field));
|
||||||
|
}(), ...);
|
||||||
|
}(std::make_index_sequence<memberCount<T>>{});
|
||||||
|
--Detail::describeDepth;
|
||||||
|
description.Set("fields", std::move(fields));
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static consteval std::array<std::string_view, memberCount<T>> Names() {
|
||||||
|
return [&]<std::size_t... I>(std::index_sequence<I...>) {
|
||||||
|
return std::array<std::string_view, memberCount<T>>{ MemberName<I, T>()... };
|
||||||
|
}(std::make_index_sequence<memberCount<T>>{});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
205
interfaces/Crafter.Debug.Core-Reflect.cppm
Normal file
205
interfaces/Crafter.Debug.Core-Reflect.cppm
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// Compile-time introspection without P2996 (clang 23 has no <meta>), taken
|
||||||
|
// from Crafter.DB:Reflect. For aggregates: structured-binding packs give the
|
||||||
|
// member count and per-member access, and std::source_location's
|
||||||
|
// function_name() spells a member's name when a pointer to a member of an
|
||||||
|
// extern object is a template argument. For scoped enums: function_name()
|
||||||
|
// spells a template argument that is an enumerator by its name and any other
|
||||||
|
// value as a cast, so probing a range of values finds every enumerator.
|
||||||
|
// When a clang ships <meta>, this partition is the only thing to replace.
|
||||||
|
export module Crafter.Debug.Core:Reflect;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
namespace Crafter::Debug::Reflect {
|
||||||
|
template<class T>
|
||||||
|
struct CountHelper {
|
||||||
|
template<class U>
|
||||||
|
static consteval auto Count(U& u) {
|
||||||
|
auto&& [...m] = u;
|
||||||
|
return std::integral_constant<std::size_t, sizeof...(m)>{};
|
||||||
|
}
|
||||||
|
static constexpr std::size_t value = decltype(Count(std::declval<T&>()))::value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An instance whose member addresses are turned into text. Only its
|
||||||
|
// address is used, in constant expressions; no_destroy keeps the compiler
|
||||||
|
// from registering a destructor for it.
|
||||||
|
template<class T>
|
||||||
|
[[clang::no_destroy]] inline const T external{};
|
||||||
|
|
||||||
|
template<auto Probe>
|
||||||
|
consteval std::string_view RawName() {
|
||||||
|
return std::source_location::current().function_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class T>
|
||||||
|
consteval std::string_view RawTypeName() {
|
||||||
|
return std::source_location::current().function_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The part of RawName<P>()'s text that spells P: what follows "Probe = "
|
||||||
|
// up to the closing bracket.
|
||||||
|
consteval std::string_view ProbeText(std::string_view raw) {
|
||||||
|
const std::size_t end = raw.rfind(']');
|
||||||
|
const std::size_t start = raw.rfind("Probe = ", end);
|
||||||
|
return raw.substr(start + 8, end - start - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enumerator name for V, or "" when V is not an enumerator: clang
|
||||||
|
// spells those as a cast, "(Ns::Color)5".
|
||||||
|
template<auto V>
|
||||||
|
consteval std::string_view EnumeratorName() {
|
||||||
|
const std::string_view text = ProbeText(RawName<V>());
|
||||||
|
if (text.starts_with('(')) return {};
|
||||||
|
const std::size_t colon = text.rfind("::");
|
||||||
|
return colon == std::string_view::npos ? text : text.substr(colon + 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
// True for class types whose non-static data members a structured
|
||||||
|
// binding can bind: public members, no bases carrying members, no
|
||||||
|
// user-declared constructors.
|
||||||
|
template<class T>
|
||||||
|
concept Aggregate = std::is_class_v<T> && std::is_aggregate_v<T> && requires { Reflect::CountHelper<T>::value; };
|
||||||
|
|
||||||
|
template<Aggregate T>
|
||||||
|
inline constexpr std::size_t memberCount = Reflect::CountHelper<T>::value;
|
||||||
|
|
||||||
|
// The I-th member of an aggregate lvalue.
|
||||||
|
template<std::size_t I, class T>
|
||||||
|
constexpr decltype(auto) Member(T&& t) noexcept {
|
||||||
|
auto&& [...m] = t;
|
||||||
|
return (m...[I]);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<std::size_t I, class T>
|
||||||
|
using MemberType = std::remove_cvref_t<decltype(Member<I>(std::declval<T&>()))>;
|
||||||
|
|
||||||
|
// The declared name of the I-th member: "yaw". Parsed out of
|
||||||
|
// "std::string_view ...::RawName() [Probe = &external.yaw]".
|
||||||
|
template<std::size_t I, Aggregate T>
|
||||||
|
consteval std::string_view MemberName() {
|
||||||
|
constexpr std::string_view text = Reflect::ProbeText(Reflect::RawName<&Member<I>(Reflect::external<T>)>());
|
||||||
|
return text.substr(text.rfind('.') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type's name as the compiler spells it ("Game::AimArgs"), for help
|
||||||
|
// and errors; the spelling is not load-bearing.
|
||||||
|
template<class T>
|
||||||
|
consteval std::string_view TypeName() {
|
||||||
|
constexpr std::string_view raw = Reflect::RawTypeName<T>();
|
||||||
|
const std::size_t end = raw.rfind(']');
|
||||||
|
const std::size_t start = raw.rfind("T = ", end);
|
||||||
|
return raw.substr(start + 4, end - start - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calls f.template operator()<I>(member) for every member, in order.
|
||||||
|
template<Aggregate T, class F>
|
||||||
|
constexpr void ForEachMember(T& t, F&& f) {
|
||||||
|
[&]<std::size_t... I>(std::index_sequence<I...>) {
|
||||||
|
(f.template operator()<I>(Member<I>(t)), ...);
|
||||||
|
}(std::make_index_sequence<memberCount<T>>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The values probed for enumerator names, a half-open range clamped to
|
||||||
|
// the enum's underlying type. Specialize for an enum with values outside
|
||||||
|
// the default range; every probe is one template instantiation.
|
||||||
|
template<class E>
|
||||||
|
struct EnumRange {
|
||||||
|
static constexpr std::int64_t min = -128;
|
||||||
|
static constexpr std::int64_t max = 128;
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class E>
|
||||||
|
struct Enumerator {
|
||||||
|
E value;
|
||||||
|
std::string_view name;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace Crafter::Debug::Reflect {
|
||||||
|
template<class E>
|
||||||
|
consteval std::int64_t ProbeMin() {
|
||||||
|
using U = std::underlying_type_t<E>;
|
||||||
|
return std::max<std::int64_t>(EnumRange<E>::min, static_cast<std::int64_t>(std::numeric_limits<U>::min()));
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class E>
|
||||||
|
consteval std::int64_t ProbeMax() {
|
||||||
|
using U = std::underlying_type_t<E>;
|
||||||
|
constexpr std::int64_t limit = static_cast<std::uint64_t>(std::numeric_limits<U>::max()) >= static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())
|
||||||
|
? std::numeric_limits<std::int64_t>::max()
|
||||||
|
: static_cast<std::int64_t>(std::numeric_limits<U>::max()) + 1;
|
||||||
|
return std::min<std::int64_t>(EnumRange<E>::max, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class E, std::size_t... I>
|
||||||
|
consteval auto EnumTable(std::index_sequence<I...>) {
|
||||||
|
constexpr std::int64_t first = ProbeMin<E>();
|
||||||
|
constexpr std::array<std::string_view, sizeof...(I)> names = {
|
||||||
|
EnumeratorName<static_cast<E>(first + static_cast<std::int64_t>(I))>()...
|
||||||
|
};
|
||||||
|
constexpr std::size_t count = static_cast<std::size_t>(std::ranges::count_if(names, [](std::string_view name) { return !name.empty(); }));
|
||||||
|
std::array<Enumerator<E>, count> table{};
|
||||||
|
std::size_t next = 0;
|
||||||
|
for (std::size_t i = 0; i < names.size(); ++i) {
|
||||||
|
if (!names[i].empty()) table[next++] = { static_cast<E>(first + static_cast<std::int64_t>(i)), names[i] };
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
// Every named value of a scoped enum within EnumRange, in value order.
|
||||||
|
// Scoped only: clang rejects casting an out-of-range value to an unscoped
|
||||||
|
// enum without a fixed underlying type, even in a template argument.
|
||||||
|
template<class E>
|
||||||
|
requires std::is_scoped_enum_v<E>
|
||||||
|
inline constexpr auto enumerators = Reflect::EnumTable<E>(std::make_index_sequence<static_cast<std::size_t>(Reflect::ProbeMax<E>() - Reflect::ProbeMin<E>())>{});
|
||||||
|
|
||||||
|
template<class E>
|
||||||
|
requires std::is_scoped_enum_v<E>
|
||||||
|
constexpr std::optional<std::string_view> EnumName(E value) {
|
||||||
|
for (const Enumerator<E>& e : enumerators<E>) {
|
||||||
|
if (e.value == value) return e.name;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
template<class E>
|
||||||
|
requires std::is_scoped_enum_v<E>
|
||||||
|
constexpr std::optional<E> EnumFromName(std::string_view name) {
|
||||||
|
for (const Enumerator<E>& e : enumerators<E>) {
|
||||||
|
if (e.name == name) return e.value;
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-tests: a compiler whose function_name() text changes shape fails
|
||||||
|
// here, at the library's build, instead of producing garbage names.
|
||||||
|
namespace Crafter::Debug::Reflect {
|
||||||
|
struct ProbeStruct {
|
||||||
|
std::int32_t alpha;
|
||||||
|
double beta;
|
||||||
|
std::string gamma;
|
||||||
|
};
|
||||||
|
enum class ProbeEnum : std::uint8_t { Zero, One, Seven = 7 };
|
||||||
|
enum class ProbeSigned : std::int16_t { Minus = -3, Big = 100 };
|
||||||
|
|
||||||
|
static_assert(Aggregate<ProbeStruct>);
|
||||||
|
static_assert(memberCount<ProbeStruct> == 3);
|
||||||
|
static_assert(MemberName<0, ProbeStruct>() == "alpha");
|
||||||
|
static_assert(MemberName<2, ProbeStruct>() == "gamma");
|
||||||
|
static_assert(std::is_same_v<MemberType<1, ProbeStruct>, double>);
|
||||||
|
static_assert(TypeName<ProbeStruct>().ends_with("ProbeStruct"));
|
||||||
|
static_assert(enumerators<ProbeEnum>.size() == 3);
|
||||||
|
static_assert(EnumName(ProbeEnum::Seven) == "Seven");
|
||||||
|
static_assert(!EnumName(static_cast<ProbeEnum>(5)).has_value());
|
||||||
|
static_assert(EnumFromName<ProbeEnum>("One") == ProbeEnum::One);
|
||||||
|
static_assert(EnumName(ProbeSigned::Minus) == "Minus");
|
||||||
|
static_assert(enumerators<ProbeSigned>.size() == 2);
|
||||||
|
}
|
||||||
93
interfaces/Crafter.Debug.Core-Result.cppm
Normal file
93
interfaces/Crafter.Debug.Core-Result.cppm
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// What a command handler returns besides a plain value: an Error with a
|
||||||
|
// stable code and a message written for the agent reading it, a Result that
|
||||||
|
// is either, and an Async for a result that takes frames to produce.
|
||||||
|
export module Crafter.Debug.Core:Result;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
struct Error {
|
||||||
|
// Stable and machine-readable, "InvalidScene": scripts branch on it.
|
||||||
|
std::string code;
|
||||||
|
// For whoever reads the reply: what went wrong, and the next step
|
||||||
|
// ("not in a game; run Lobby_Start first").
|
||||||
|
std::string message;
|
||||||
|
};
|
||||||
|
|
||||||
|
template<class T = void>
|
||||||
|
class Result {
|
||||||
|
public:
|
||||||
|
// Anything T can be made from, so `return "text";` works for a
|
||||||
|
// Result<std::string>.
|
||||||
|
template<class U = T>
|
||||||
|
requires (std::constructible_from<T, U &&> && !std::same_as<std::remove_cvref_t<U>, Error> && !std::same_as<std::remove_cvref_t<U>, Result>)
|
||||||
|
Result(U&& value) : state(std::in_place_index<0>, std::forward<U>(value)) {}
|
||||||
|
Result(Error error) : state(std::in_place_index<1>, std::move(error)) {}
|
||||||
|
|
||||||
|
bool Ok() const { return state.index() == 0; }
|
||||||
|
explicit operator bool() const { return Ok(); }
|
||||||
|
T& Get() { return std::get<0>(state); }
|
||||||
|
const T& Get() const { return std::get<0>(state); }
|
||||||
|
const Error& GetError() const { return std::get<1>(state); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::variant<T, Error> state;
|
||||||
|
};
|
||||||
|
|
||||||
|
template<>
|
||||||
|
class Result<void> {
|
||||||
|
public:
|
||||||
|
Result() = default;
|
||||||
|
Result(Error error) : error(std::move(error)) {}
|
||||||
|
|
||||||
|
bool Ok() const { return !error.has_value(); }
|
||||||
|
explicit operator bool() const { return Ok(); }
|
||||||
|
const Error& GetError() const { return *error; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::optional<Error> error;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A result that takes frames to arrive, such as a GPU readback. The
|
||||||
|
// session polls it once per Pump, on the app's thread, until it yields;
|
||||||
|
// the caller simply waits, with no ready:false loop of its own. The
|
||||||
|
// poller returns std::optional<T> (empty while not ready), or
|
||||||
|
// std::optional<Result<T>> to fail; for Async<void>, a bool or a
|
||||||
|
// std::optional<Result<>>.
|
||||||
|
template<class T = void>
|
||||||
|
class Async {
|
||||||
|
public:
|
||||||
|
template<class F>
|
||||||
|
requires std::invocable<F&>
|
||||||
|
Async(F poller) : poll(Wrap(std::move(poller))) {}
|
||||||
|
|
||||||
|
// Empty while not ready.
|
||||||
|
std::optional<Result<T>> Poll() { return poll(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::function<std::optional<Result<T>>()> poll;
|
||||||
|
|
||||||
|
template<class F>
|
||||||
|
static std::function<std::optional<Result<T>>()> Wrap(F poller) {
|
||||||
|
using R = std::invoke_result_t<F&>;
|
||||||
|
if constexpr (std::is_same_v<R, std::optional<Result<T>>>) {
|
||||||
|
return poller;
|
||||||
|
} else if constexpr (std::is_void_v<T>) {
|
||||||
|
static_assert(std::is_same_v<R, bool>, "an Async<void> poller returns bool or std::optional<Result<>>");
|
||||||
|
return [poller = std::move(poller)]() mutable -> std::optional<Result<T>> {
|
||||||
|
if (!poller()) return std::nullopt;
|
||||||
|
return Result<T>();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
static_assert(std::is_same_v<R, std::optional<T>>, "an Async<T> poller returns std::optional<T> or std::optional<Result<T>>");
|
||||||
|
return [poller = std::move(poller)]() mutable -> std::optional<Result<T>> {
|
||||||
|
std::optional<T> value = poller();
|
||||||
|
if (!value) return std::nullopt;
|
||||||
|
return Result<T>(std::move(*value));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
130
interfaces/Crafter.Debug.Core-Value.cppm
Normal file
130
interfaces/Crafter.Debug.Core-Value.cppm
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// A JSON value: what travels between an app and crafter-debug, and what
|
||||||
|
// command arguments, results, watches and events are encoded to. Its own type
|
||||||
|
// rather than a JSON library's, so no consumer interface ever sees one, and
|
||||||
|
// small enough to read in one sitting. Objects keep their members in the
|
||||||
|
// order they were set, which is the order an agent reads them in.
|
||||||
|
//
|
||||||
|
// Numbers keep the distinction JSON text does not: an Integer is a whole
|
||||||
|
// number that fits an int64, anything else is a Number (a double). Integer 1
|
||||||
|
// and Number 1.0 still compare equal. Non-finite doubles have no JSON
|
||||||
|
// spelling, and a debugger must not hide a NaN, so Dump writes them as the
|
||||||
|
// strings "NaN", "Infinity" and "-Infinity", which Codec reads back.
|
||||||
|
export module Crafter.Debug.Core:Value;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
export namespace Crafter::Debug {
|
||||||
|
enum class ValueKind : std::uint8_t {
|
||||||
|
Null,
|
||||||
|
Bool,
|
||||||
|
Integer,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Array,
|
||||||
|
Object,
|
||||||
|
};
|
||||||
|
|
||||||
|
// "null", "boolean", "integer", "number", "string", "array" or "object":
|
||||||
|
// how errors name a kind.
|
||||||
|
std::string_view KindName(ValueKind kind);
|
||||||
|
|
||||||
|
// Thrown by Value::Parse: what is wrong, and the byte offset in the text.
|
||||||
|
class ParseError : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
std::size_t offset;
|
||||||
|
ParseError(std::size_t offset, std::string_view what);
|
||||||
|
};
|
||||||
|
|
||||||
|
class Value {
|
||||||
|
public:
|
||||||
|
Value() = default;
|
||||||
|
Value(std::nullptr_t) {}
|
||||||
|
Value(bool value) : kind(ValueKind::Bool), boolean(value) {}
|
||||||
|
// Any integer type but bool and char. An unsigned value above the
|
||||||
|
// int64 range becomes a Number, which is exact up to 2^53.
|
||||||
|
template<std::integral T>
|
||||||
|
requires (!std::same_as<T, bool> && !std::same_as<T, char>)
|
||||||
|
Value(T value) {
|
||||||
|
if constexpr (std::is_unsigned_v<T> && sizeof(T) >= sizeof(std::int64_t)) {
|
||||||
|
if (value > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
|
||||||
|
kind = ValueKind::Number;
|
||||||
|
number = static_cast<double>(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kind = ValueKind::Integer;
|
||||||
|
integer = static_cast<std::int64_t>(value);
|
||||||
|
}
|
||||||
|
Value(double value) : kind(ValueKind::Number), number(value) {}
|
||||||
|
Value(float value) : Value(static_cast<double>(value)) {}
|
||||||
|
Value(std::string value) : kind(ValueKind::String), text(std::move(value)) {}
|
||||||
|
Value(std::string_view value) : Value(std::string(value)) {}
|
||||||
|
// String literals; without this, `Value("x")` would pick the bool
|
||||||
|
// constructor, a standard conversion beating a user-defined one.
|
||||||
|
Value(const char* value) : Value(std::string(value)) {}
|
||||||
|
|
||||||
|
static Value MakeArray(std::vector<Value> items = {});
|
||||||
|
static Value MakeObject();
|
||||||
|
|
||||||
|
ValueKind Kind() const { return kind; }
|
||||||
|
bool IsNull() const { return kind == ValueKind::Null; }
|
||||||
|
bool IsBool() const { return kind == ValueKind::Bool; }
|
||||||
|
bool IsInteger() const { return kind == ValueKind::Integer; }
|
||||||
|
// Integer or Number.
|
||||||
|
bool IsNumber() const { return kind == ValueKind::Integer || kind == ValueKind::Number; }
|
||||||
|
bool IsString() const { return kind == ValueKind::String; }
|
||||||
|
bool IsArray() const { return kind == ValueKind::Array; }
|
||||||
|
bool IsObject() const { return kind == ValueKind::Object; }
|
||||||
|
|
||||||
|
// Each throws std::logic_error for a value of another kind; check the
|
||||||
|
// kind first. AsNumber accepts an Integer too.
|
||||||
|
bool AsBool() const;
|
||||||
|
std::int64_t AsInteger() const;
|
||||||
|
double AsNumber() const;
|
||||||
|
const std::string& AsString() const;
|
||||||
|
|
||||||
|
// Array items. Push turns a null value into an array first.
|
||||||
|
std::span<const Value> Items() const;
|
||||||
|
std::span<Value> Items();
|
||||||
|
void Push(Value item);
|
||||||
|
|
||||||
|
// Object members, in the order they were first set. Set replaces the
|
||||||
|
// value of an existing key in place, and turns a null value into an
|
||||||
|
// object first, as operator[] does when the key is missing.
|
||||||
|
const Value* Find(std::string_view key) const;
|
||||||
|
Value* Find(std::string_view key);
|
||||||
|
Value& Set(std::string_view key, Value value);
|
||||||
|
Value& operator[](std::string_view key);
|
||||||
|
bool Erase(std::string_view key);
|
||||||
|
auto Members() const { return std::views::zip(keys, values); }
|
||||||
|
|
||||||
|
// Array items or object members; 0 for anything else.
|
||||||
|
std::size_t Size() const;
|
||||||
|
|
||||||
|
// JSON text. Parse throws ParseError; a duplicate key keeps the last
|
||||||
|
// value. Dump writes compact text, DumpPretty indents.
|
||||||
|
static Value Parse(std::string_view json);
|
||||||
|
std::string Dump() const;
|
||||||
|
std::string DumpPretty(std::uint32_t indent = 2) const;
|
||||||
|
|
||||||
|
// Numbers compare by value across Integer and Number; objects compare
|
||||||
|
// their members whatever their order.
|
||||||
|
friend bool operator==(const Value& a, const Value& b);
|
||||||
|
|
||||||
|
private:
|
||||||
|
ValueKind kind = ValueKind::Null;
|
||||||
|
bool boolean = false;
|
||||||
|
std::int64_t integer = 0;
|
||||||
|
double number = 0;
|
||||||
|
std::string text;
|
||||||
|
std::vector<Value> items;
|
||||||
|
// Object members as two parallel lists: a member type holding a Value
|
||||||
|
// could not be complete inside Value's own definition.
|
||||||
|
std::vector<std::string> keys;
|
||||||
|
std::vector<Value> values;
|
||||||
|
|
||||||
|
void DumpTo(std::string& out, std::uint32_t indent, std::uint32_t depth) const;
|
||||||
|
};
|
||||||
|
}
|
||||||
11
interfaces/Crafter.Debug.Core.cppm
Normal file
11
interfaces/Crafter.Debug.Core.cppm
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// What the app side and the crafter-debug client share: Value and its text
|
||||||
|
// form, reflection, the codecs, and the result types. The app imports it
|
||||||
|
// through Crafter.Debug; the client imports it directly, without the session.
|
||||||
|
export module Crafter.Debug.Core;
|
||||||
|
export import :Value;
|
||||||
|
export import :Reflect;
|
||||||
|
export import :Codec;
|
||||||
|
export import :Result;
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
// SPDX-License-Identifier: LGPL-3.0-only
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
// Spike placeholder: the real API replaces this in Phase 1.
|
// Runtime debugging for AI agents: what an app imports. The session, the
|
||||||
|
// registry of commands and watches, the timeline and pause control follow in
|
||||||
|
// later phases; for now this re-exports the shared core.
|
||||||
export module Crafter.Debug;
|
export module Crafter.Debug;
|
||||||
|
export import Crafter.Debug.Core;
|
||||||
import std;
|
import std;
|
||||||
|
|
||||||
export namespace Crafter::Debug {
|
export namespace Crafter::Debug {
|
||||||
std::string_view Version() {
|
std::string_view Version() {
|
||||||
return "0.0.0-spike";
|
return "0.0.0-dev";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
project.cpp
59
project.cpp
|
|
@ -1,6 +1,14 @@
|
||||||
// SPDX-License-Identifier: LGPL-3.0-only
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// Three configurations:
|
||||||
|
// Crafter.Debug.Core what the app and the client share (values, codecs)
|
||||||
|
// crafter.debug-cli the client, crafter-debug
|
||||||
|
// Crafter.Debug what an app links (returned), depending on both
|
||||||
|
// The client is one of the library's dependencies only so that 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. Consumers load this file through
|
||||||
|
// Debug::Use (tooling/), which also writes their launcher.
|
||||||
import std;
|
import std;
|
||||||
import Crafter.Build;
|
import Crafter.Build;
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
|
|
@ -18,6 +26,13 @@ namespace {
|
||||||
fs::path Root() {
|
fs::path Root() {
|
||||||
return fs::path(std::source_location::current().file_name()).parent_path();
|
return fs::path(std::source_location::current().file_name()).parent_path();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constexpr std::array<std::string_view, 4> tests = {
|
||||||
|
"ShouldRoundTripJson",
|
||||||
|
"ShouldRoundTripAggregates",
|
||||||
|
"ShouldReportDecodeErrorsWithPaths",
|
||||||
|
"ShouldDescribeDefaultsAndRequired",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||||
|
|
@ -27,17 +42,30 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
cfg.outputName = "Crafter.Debug";
|
cfg.outputName = "Crafter.Debug";
|
||||||
cfg.type = ConfigurationType::LibraryStatic;
|
cfg.type = ConfigurationType::LibraryStatic;
|
||||||
ApplyStandardArgs(cfg, args);
|
ApplyStandardArgs(cfg, args);
|
||||||
|
|
||||||
|
Configuration& core = owned.emplace_back();
|
||||||
|
core.path = Root();
|
||||||
|
core.name = "Crafter.Debug.Core";
|
||||||
|
core.outputName = "Crafter.Debug.Core";
|
||||||
|
core.type = ConfigurationType::LibraryStatic;
|
||||||
|
ApplyStandardArgs(core, args);
|
||||||
{
|
{
|
||||||
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug" };
|
std::array<fs::path, 5> interfaces = {
|
||||||
std::array<fs::path, 0> implementations = {};
|
"interfaces/Crafter.Debug.Core-Value",
|
||||||
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
"interfaces/Crafter.Debug.Core-Reflect",
|
||||||
|
"interfaces/Crafter.Debug.Core-Codec",
|
||||||
|
"interfaces/Crafter.Debug.Core-Result",
|
||||||
|
"interfaces/Crafter.Debug.Core",
|
||||||
|
};
|
||||||
|
std::array<fs::path, 2> implementations = {
|
||||||
|
"implementations/Crafter.Debug.Core-Value",
|
||||||
|
"implementations/Crafter.Debug.Core-Codec",
|
||||||
|
};
|
||||||
|
core.GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The client, crafter-debug. One of the library's dependencies, so a
|
// The client, built for the library's target. Not through
|
||||||
// consumer's debug build compiles it in the same pass as the app; an
|
// ApplyStandardArgs: a consumer's --lib would make it a library.
|
||||||
// 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();
|
Configuration& client = owned.emplace_back();
|
||||||
client.path = Root();
|
client.path = Root();
|
||||||
client.name = "crafter.debug-cli";
|
client.name = "crafter.debug-cli";
|
||||||
|
|
@ -48,11 +76,24 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
client.mtune = cfg.mtune;
|
client.mtune = cfg.mtune;
|
||||||
client.sysroot = cfg.sysroot;
|
client.sysroot = cfg.sysroot;
|
||||||
client.debug = cfg.debug;
|
client.debug = cfg.debug;
|
||||||
|
client.dependencies = { &core };
|
||||||
{
|
{
|
||||||
std::array<fs::path, 0> interfaces = {};
|
std::array<fs::path, 0> interfaces = {};
|
||||||
std::array<fs::path, 1> implementations = { "implementations/main" };
|
std::array<fs::path, 1> implementations = { "implementations/main" };
|
||||||
client.GetInterfacesAndImplementations(interfaces, implementations);
|
client.GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
}
|
}
|
||||||
cfg.dependencies.push_back(&client);
|
|
||||||
|
cfg.dependencies = { &core, &client };
|
||||||
|
{
|
||||||
|
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug" };
|
||||||
|
std::array<fs::path, 0> implementations = {};
|
||||||
|
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests only when this is the project being built: a consumer's load of
|
||||||
|
// this file has no use for them.
|
||||||
|
if (IsTopLevelProject()) {
|
||||||
|
for (const std::string_view test : tests) cfg.AddTest(test).Dependencies({ &core });
|
||||||
|
}
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
tests/ShouldDescribeDefaultsAndRequired/main.cpp
Normal file
67
tests/ShouldDescribeDefaultsAndRequired/main.cpp
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// Describe gives `crafter-debug help` what an agent needs to call a command
|
||||||
|
// right the first time: each field's name and type, its default taken from
|
||||||
|
// the member initializer, Required fields marked with no default, a narrow
|
||||||
|
// integer's range, an enum's names, a nested aggregate's own fields. A type
|
||||||
|
// that contains itself is described a few levels deep instead of forever.
|
||||||
|
import std;
|
||||||
|
import Crafter.Debug.Core;
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace Game {
|
||||||
|
enum class Mode : std::uint8_t { Idle, Build };
|
||||||
|
|
||||||
|
struct Vec3 {
|
||||||
|
float x = 0;
|
||||||
|
float y = 1;
|
||||||
|
float z = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Aim {
|
||||||
|
Required<std::uint8_t> slot;
|
||||||
|
float yaw = 45;
|
||||||
|
Vec3 at;
|
||||||
|
std::optional<std::string> label;
|
||||||
|
Mode mode = Mode::Build;
|
||||||
|
std::vector<std::int32_t> ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Node {
|
||||||
|
std::vector<Node> children;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::int32_t failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
const Value description = Describe<Game::Aim>();
|
||||||
|
std::println("{}", description.DumpPretty());
|
||||||
|
Check(description.Find("type")->AsString() == "object" && description.Find("name")->AsString().ends_with("Aim"), "an aggregate describes as a named object");
|
||||||
|
const std::span<const Value> fields = description.Find("fields")->Items();
|
||||||
|
Check(fields.size() == 6, "one entry per field");
|
||||||
|
const auto field = [&](std::string_view name) -> const Value& {
|
||||||
|
for (const Value& f : fields) {
|
||||||
|
if (f.Find("name")->AsString() == name) return f;
|
||||||
|
}
|
||||||
|
throw std::runtime_error(std::format("no field {}", name));
|
||||||
|
};
|
||||||
|
Check(field("slot").Find("required") != nullptr && field("slot").Find("default") == nullptr, "a Required field is marked required, with no default");
|
||||||
|
Check(field("slot").Find("type")->Find("maximum")->AsInteger() == 255, "a narrow integer carries its range");
|
||||||
|
Check(field("yaw").Find("default")->AsNumber() == 45, "a default comes from the member initializer");
|
||||||
|
Check(field("at").Find("type")->Find("fields")->Size() == 3 && field("at").Find("default")->Find("y")->AsNumber() == 1, "a nested aggregate describes its fields and default");
|
||||||
|
Check(field("label").Find("type")->Find("nullable") != nullptr && field("label").Find("default")->IsNull(), "an optional is nullable and defaults to null");
|
||||||
|
Check(field("mode").Find("type")->Find("values")->Size() == 2 && field("mode").Find("default")->AsString() == "Build", "an enum lists its names");
|
||||||
|
Check(field("ids").Find("type")->Find("items")->Find("type")->AsString() == "integer", "a vector describes its items");
|
||||||
|
Check(!Describe<Game::Node>().Dump().empty(), "a type that contains itself is described without recursing forever");
|
||||||
|
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
78
tests/ShouldReportDecodeErrorsWithPaths/main.cpp
Normal file
78
tests/ShouldReportDecodeErrorsWithPaths/main.cpp
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// A command call with several mistakes gets one reply that names each of
|
||||||
|
// them: a missing Required field, a wrong type naming both kinds, a nested
|
||||||
|
// path ("path[1].x"), an out-of-range integer with the range, and for an
|
||||||
|
// unknown field or enum name the one the caller most likely meant. Decoding
|
||||||
|
// never throws, so a bad call cannot take the app down. Guards the replies an
|
||||||
|
// agent corrects its calls from.
|
||||||
|
import std;
|
||||||
|
import Crafter.Debug.Core;
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace Game {
|
||||||
|
enum class Mode : std::uint8_t { Idle, Build, Fight };
|
||||||
|
|
||||||
|
struct Vec3 {
|
||||||
|
float x = 0;
|
||||||
|
float y = 0;
|
||||||
|
float z = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Aim {
|
||||||
|
Required<std::uint8_t> slot;
|
||||||
|
float yaw = 0;
|
||||||
|
float pitch = 0;
|
||||||
|
std::vector<Vec3> path;
|
||||||
|
Mode mode = Mode::Idle;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::int32_t failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Has(const DecodeContext& context, std::string_view path, std::string_view text) {
|
||||||
|
return std::ranges::any_of(context.Issues(), [&](const DecodeIssue& issue) {
|
||||||
|
return issue.path == path && issue.message.find(text) != std::string::npos;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
Game::Aim aim;
|
||||||
|
DecodeContext context;
|
||||||
|
Decode(Value::Parse(R"({"yw": 1, "pitch": "up", "path": [{"x": 1}, {"x": "no"}], "mode": "Fihgt"})"), aim, context);
|
||||||
|
std::println("report: {}", context.Report());
|
||||||
|
Check(context.Issues().size() == 5, std::format("five mistakes, five issues ({})", context.Issues().size()));
|
||||||
|
Check(Has(context, "slot", "required"), "a missing Required field is reported");
|
||||||
|
Check(Has(context, "pitch", "expected a number, got a string"), "a wrong type names both kinds");
|
||||||
|
Check(Has(context, "path[1].x", "expected a number"), "a nested path names the item and the member");
|
||||||
|
Check(Has(context, "mode", "did you mean 'Fight'"), "a mistyped enum name gets a suggestion");
|
||||||
|
Check(Has(context, "yw", "did you mean 'yaw'"), "an unknown field gets a suggestion");
|
||||||
|
Check(context.Report().find("slot: missing") != std::string::npos, "the report prefixes each issue with its path");
|
||||||
|
|
||||||
|
Game::Aim outOfRange;
|
||||||
|
DecodeContext rangeContext;
|
||||||
|
Decode(Value::Parse(R"({"slot": 300})"), outOfRange, rangeContext);
|
||||||
|
Check(Has(rangeContext, "slot", "300 is out of range (0..255)"), "an out-of-range integer names the range");
|
||||||
|
|
||||||
|
Game::Aim notAnObject;
|
||||||
|
DecodeContext objectContext;
|
||||||
|
Decode(Value::Parse("[1]"), notAnObject, objectContext);
|
||||||
|
Check(objectContext.Issues().size() == 1 && objectContext.Issues()[0].path.empty()
|
||||||
|
&& objectContext.Issues()[0].message == "expected an object, got an array",
|
||||||
|
"a non-object is one issue at the root");
|
||||||
|
|
||||||
|
Game::Aim farOff;
|
||||||
|
DecodeContext farContext;
|
||||||
|
Decode(Value::Parse(R"({"slot": 1, "zzzzzz": 1})"), farOff, farContext);
|
||||||
|
Check(Has(farContext, "zzzzzz", "unknown field (fields: slot, yaw, pitch, path, mode)"), "an unknown field with no close match lists the fields");
|
||||||
|
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
94
tests/ShouldRoundTripAggregates/main.cpp
Normal file
94
tests/ShouldRoundTripAggregates/main.cpp
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// Codec round-trips an aggregate built from every supported field type
|
||||||
|
// (strings, nested aggregates in a vector, optional, std::array, string-keyed
|
||||||
|
// maps, a scoped enum, Required) through Value and its text, with no
|
||||||
|
// hand-written serialization. Fields the input leaves out keep their default
|
||||||
|
// member initializers, null clears an optional, and an enum also accepts its
|
||||||
|
// integer. Guards the reflection that command arguments and results rely on.
|
||||||
|
import std;
|
||||||
|
import Crafter.Debug.Core;
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace Game {
|
||||||
|
enum class Mode : std::uint8_t { Idle, Build, Fight = 5 };
|
||||||
|
|
||||||
|
struct Vec3 {
|
||||||
|
float x = 0;
|
||||||
|
float y = 0;
|
||||||
|
float z = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Loadout {
|
||||||
|
std::string name = "default";
|
||||||
|
std::vector<Vec3> points;
|
||||||
|
std::optional<std::int32_t> target;
|
||||||
|
std::array<bool, 3> flags{};
|
||||||
|
std::map<std::string, double> weights;
|
||||||
|
Mode mode = Mode::Idle;
|
||||||
|
Required<std::uint16_t> slot;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::int32_t failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
Game::Loadout in;
|
||||||
|
in.name = "fort \"one\"";
|
||||||
|
in.points = { { 1, 2, 3 }, { -0.5F, 0, 1e10F } };
|
||||||
|
in.target = -7;
|
||||||
|
in.flags = { true, false, true };
|
||||||
|
in.weights = { { "cannon", 0.25 }, { "laser", 1 } };
|
||||||
|
in.mode = Game::Mode::Fight;
|
||||||
|
*in.slot = 3;
|
||||||
|
|
||||||
|
const Value encoded = Encode(in);
|
||||||
|
Check(encoded.IsObject() && encoded.Size() == 7, "an aggregate encodes as an object with one member per field");
|
||||||
|
Check(encoded.Find("mode")->AsString() == "Fight", "a scoped enum encodes by name");
|
||||||
|
Check(encoded.Find("slot")->AsInteger() == 3, "Required encodes as its value");
|
||||||
|
Check(encoded.Find("target")->AsInteger() == -7, "an optional with a value encodes as the value");
|
||||||
|
Check(encoded.Find("points")->Items()[1].Find("z")->AsNumber() == 1e10F, "nested aggregates encode inside a vector");
|
||||||
|
|
||||||
|
Game::Loadout out;
|
||||||
|
DecodeContext context;
|
||||||
|
Decode(Value::Parse(encoded.Dump()), out, context);
|
||||||
|
Check(context.Ok(), std::format("decodes what it encoded ({})", context.Report()));
|
||||||
|
Check(out.name == in.name && out.points.size() == 2 && out.points[1].z == 1e10F && out.target == -7 && out.flags[2]
|
||||||
|
&& out.weights.at("laser") == 1 && out.mode == Game::Mode::Fight && *out.slot == 3,
|
||||||
|
"every field survives the round trip");
|
||||||
|
|
||||||
|
Game::Loadout partial;
|
||||||
|
partial.target = 9;
|
||||||
|
DecodeContext partialContext;
|
||||||
|
Decode(Value::Parse(R"({"slot": 1, "target": null, "mode": 1})"), partial, partialContext);
|
||||||
|
Check(partialContext.Ok(), std::format("a partial object decodes ({})", partialContext.Report()));
|
||||||
|
Check(partial.name == "default" && partial.points.empty(), "fields left out keep their defaults");
|
||||||
|
Check(!partial.target.has_value(), "null clears an optional");
|
||||||
|
Check(partial.mode == Game::Mode::Build, "an enum accepts its integer");
|
||||||
|
|
||||||
|
float nan = 0;
|
||||||
|
DecodeContext nanContext;
|
||||||
|
Decode(Encode(std::numeric_limits<float>::quiet_NaN()), nan, nanContext);
|
||||||
|
Check(nanContext.Ok() && std::isnan(nan), "NaN survives the round trip");
|
||||||
|
|
||||||
|
std::vector<bool> bits;
|
||||||
|
DecodeContext bitsContext;
|
||||||
|
Decode(Value::Parse("[true,false,true]"), bits, bitsContext);
|
||||||
|
Check(bitsContext.Ok() && bits.size() == 3 && bits[2] && !bits[1], "std::vector<bool> decodes");
|
||||||
|
|
||||||
|
const std::uint64_t large = (std::uint64_t{ 1 } << 63) + 2048;
|
||||||
|
std::uint64_t decoded = 0;
|
||||||
|
DecodeContext largeContext;
|
||||||
|
Decode(Value::Parse(Encode(large).Dump()), decoded, largeContext);
|
||||||
|
Check(largeContext.Ok() && decoded == large, "a uint64 above the int64 range survives");
|
||||||
|
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
81
tests/ShouldRoundTripJson/main.cpp
Normal file
81
tests/ShouldRoundTripJson/main.cpp
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// Value's JSON text, the format the app and crafter-debug exchange. Parse
|
||||||
|
// and Dump round-trip every kind, escapes and \u sequences (surrogate pairs
|
||||||
|
// included) decode to UTF-8, integers stay integers and whole doubles stay
|
||||||
|
// doubles, non-finite doubles are written as the strings NaN and Infinity,
|
||||||
|
// invalid UTF-8 in a string is replaced so the output always parses, and
|
||||||
|
// malformed text is refused with the offset of the problem.
|
||||||
|
import std;
|
||||||
|
import Crafter.Debug.Core;
|
||||||
|
using namespace Crafter::Debug;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
std::int32_t failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Refused(std::string_view text) {
|
||||||
|
try {
|
||||||
|
(void)Value::Parse(text);
|
||||||
|
return false;
|
||||||
|
} catch (const ParseError&) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
const Value parsed = Value::Parse(R"({"a":1,"b":[true,false,null],"c":"x\"y\\z\n","d":-2.5,"e":{"f":"éé😀"}})");
|
||||||
|
Check(parsed.IsObject() && parsed.Size() == 5, "parses an object with five members");
|
||||||
|
Check(parsed.Find("a") != nullptr && parsed.Find("a")->IsInteger() && parsed.Find("a")->AsInteger() == 1, "an integer stays an integer");
|
||||||
|
Check(parsed.Find("d")->Kind() == ValueKind::Number && parsed.Find("d")->AsNumber() == -2.5, "a fraction is a number");
|
||||||
|
Check(parsed.Find("c")->AsString() == "x\"y\\z\n", "escapes decode");
|
||||||
|
Check(parsed.Find("e")->Find("f")->AsString() == "\xC3\xA9\xC3\xA9\xF0\x9F\x98\x80", "\\u escapes and surrogate pairs decode to UTF-8");
|
||||||
|
Check(Value::Parse(parsed.Dump()) == parsed, "compact text round-trips");
|
||||||
|
Check(Value::Parse(parsed.DumpPretty()) == parsed, "pretty text round-trips");
|
||||||
|
Check(parsed.Dump().starts_with(R"({"a":1,"b":[true,false,null],)"), "members keep their order");
|
||||||
|
|
||||||
|
Check(Value(1.0).Dump() == "1.0", "a whole double keeps a fraction");
|
||||||
|
Check(Value::Parse("1.0").Kind() == ValueKind::Number, "1.0 reads back as a number");
|
||||||
|
Check(Value(1.0) == Value(1), "Integer 1 equals Number 1.0");
|
||||||
|
Check(Value(std::numeric_limits<double>::quiet_NaN()).Dump() == "\"NaN\"", "NaN is written as a string");
|
||||||
|
Check(Value(-std::numeric_limits<double>::infinity()).Dump() == "\"-Infinity\"", "-Infinity is written as a string");
|
||||||
|
Check(Value(std::numeric_limits<std::uint64_t>::max()).Kind() == ValueKind::Number, "a uint64 above the int64 range becomes a number");
|
||||||
|
Check(Value::Parse("9223372036854775807").AsInteger() == std::numeric_limits<std::int64_t>::max(), "int64 max parses as an integer");
|
||||||
|
Check(Value::Parse("9223372036854775808").Kind() == ValueKind::Number, "beyond int64 parses as a number");
|
||||||
|
|
||||||
|
Check(Value(std::string("ok\xff\xc3")).Dump() == "\"ok\xEF\xBF\xBD\xEF\xBF\xBD\"", "invalid UTF-8 becomes U+FFFD");
|
||||||
|
Check(Value(std::string("\x01\x7f")).Dump() == "\"\\u0001\\u007f\"", "control characters are escaped");
|
||||||
|
Check(Value::Parse(Value(std::string("tab\tquote\"")).Dump()).AsString() == "tab\tquote\"", "escaped text reads back");
|
||||||
|
|
||||||
|
Check(Refused(R"({"a":1,})"), "a trailing comma is refused");
|
||||||
|
Check(Refused("[1 2]"), "a missing comma is refused");
|
||||||
|
Check(Refused("01"), "a leading zero is refused");
|
||||||
|
Check(Refused(R"("\ud800")"), "an unpaired surrogate is refused");
|
||||||
|
Check(Refused("\"a\nb\""), "a raw newline in a string is refused");
|
||||||
|
Check(Refused("1e999"), "an out-of-range number is refused");
|
||||||
|
Check(Refused(std::string(300, '[') + std::string(300, ']')), "nesting beyond the limit is refused");
|
||||||
|
Check(Refused(""), "empty text is refused");
|
||||||
|
try {
|
||||||
|
(void)Value::Parse("[1, x]");
|
||||||
|
Check(false, "a parse error carries its offset");
|
||||||
|
} catch (const ParseError& error) {
|
||||||
|
Check(error.offset == 4, std::format("a parse error carries its offset ({})", error.offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
Value object;
|
||||||
|
object["z"] = Value(1);
|
||||||
|
object["a"] = Value("two");
|
||||||
|
object.Set("z", Value(3));
|
||||||
|
Check(object.Dump() == R"({"z":3,"a":"two"})", "Set replaces in place, keeping the order");
|
||||||
|
Check(object.Erase("z") && object.Dump() == R"({"a":"two"})", "Erase removes a member");
|
||||||
|
Check(Value::Parse(R"({"a":1,"b":2})") == Value::Parse(R"({"b":2,"a":1})"), "member order does not affect equality");
|
||||||
|
Check(!(Value::Parse("[1,2]") == Value::Parse("[2,1]")), "item order does affect equality");
|
||||||
|
|
||||||
|
return failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue