Crafter.Debug/implementations/Crafter.Debug.Core-Value.cpp
Jorijn van der Graaf 89a12bcd61
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>
2026-11-01 01:31:58 +01:00

507 lines
18 KiB
C++

// 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;
}
}
}