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
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>>{});
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue