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>>{});
|
||||
}
|
||||
};
|
||||
}
|
||||
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-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 import Crafter.Debug.Core;
|
||||
import std;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
std::string_view Version() {
|
||||
return "0.0.0-spike";
|
||||
return "0.0.0-dev";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue