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,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>>{});
}
};
}