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:
Jorijn van der Graaf 2026-11-01 01:31:58 +01:00
commit 89a12bcd61
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
13 changed files with 1951 additions and 11 deletions

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