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,118 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Debug.Core:Codec_impl;
import std;
import :Value;
import :Codec;
using namespace Crafter::Debug;
namespace {
// "a string" or "an integer", for a kind as errors name it.
std::string WithArticle(ValueKind kind) {
const std::string_view name = KindName(kind);
if (kind == ValueKind::Null) return std::string(name);
const bool vowel = name.starts_with('a') || name.starts_with('i') || name.starts_with('o');
return std::format("{} {}", vowel ? "an" : "a", name);
}
std::size_t Distance(std::string_view a, std::string_view b) {
std::vector<std::size_t> previous(b.size() + 1);
std::vector<std::size_t> current(b.size() + 1);
for (std::size_t j = 0; j <= b.size(); ++j) previous[j] = j;
for (std::size_t i = 1; i <= a.size(); ++i) {
current[0] = i;
for (std::size_t j = 1; j <= b.size(); ++j) {
const std::size_t substitution = previous[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1);
current[j] = std::min({ previous[j] + 1, current[j - 1] + 1, substitution });
}
std::swap(previous, current);
}
return previous[b.size()];
}
std::string Join(std::span<const std::string_view> names) {
std::string joined;
for (const std::string_view name : names) {
if (!joined.empty()) joined += ", ";
joined += name;
}
return joined;
}
}
void DecodeContext::Fail(std::string message) {
issues.push_back({ Path(), std::move(message) });
}
std::string DecodeContext::Report() const {
std::string report;
for (const DecodeIssue& issue : issues) {
if (!report.empty()) report += "; ";
report += issue.path.empty() ? issue.message : std::format("{}: {}", issue.path, issue.message);
}
return report;
}
DecodeContext::Scope::~Scope() {
context.segments.pop_back();
}
DecodeContext::Scope DecodeContext::Field(std::string_view name) {
segments.emplace_back(name);
return Scope(*this);
}
DecodeContext::Scope DecodeContext::Index(std::size_t index) {
segments.push_back(std::format("[{}]", index));
return Scope(*this);
}
std::string DecodeContext::Path() const {
std::string path;
for (const std::string& segment : segments) {
if (!path.empty() && !segment.starts_with('[')) path += '.';
path += segment;
}
return path;
}
std::optional<std::string_view> Crafter::Debug::Suggest(std::string_view word, std::span<const std::string_view> candidates) {
const std::size_t allowed = word.size() <= 4 ? 1 : 2;
std::optional<std::string_view> best;
std::size_t bestDistance = allowed + 1;
for (const std::string_view candidate : candidates) {
const std::size_t distance = Distance(word, candidate);
if (distance < bestDistance) {
best = candidate;
bestDistance = distance;
}
}
return best;
}
std::string Crafter::Debug::Detail::Expected(std::string_view wanted, const Value& got) {
return std::format("expected {}, got {}", wanted, WithArticle(got.Kind()));
}
Value Crafter::Debug::Detail::Type(std::string_view type) {
Value description = Value::MakeObject();
description.Set("type", Value(type));
return description;
}
std::string Crafter::Debug::Detail::UnknownField(std::string_view key, std::span<const std::string_view> fields) {
if (fields.empty()) return "unknown field; this takes no arguments";
if (const auto suggestion = Suggest(key, fields)) {
return std::format("unknown field; did you mean '{}'? (fields: {})", *suggestion, Join(fields));
}
return std::format("unknown field (fields: {})", Join(fields));
}
std::string Crafter::Debug::Detail::UnknownName(std::string_view name, std::span<const std::string_view> names) {
if (const auto suggestion = Suggest(name, names)) {
return std::format("'{}' is not a name here; did you mean '{}'? (one of: {})", name, *suggestion, Join(names));
}
return std::format("'{}' is not a name here (one of: {})", name, Join(names));
}

View file

@ -0,0 +1,507 @@
// 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;
}
}
}

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

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

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

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

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

View file

@ -1,12 +1,15 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // 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 module Crafter.Debug;
export import Crafter.Debug.Core;
import std; import std;
export namespace Crafter::Debug { export namespace Crafter::Debug {
std::string_view Version() { std::string_view Version() {
return "0.0.0-spike"; return "0.0.0-dev";
} }
} }

View file

@ -1,6 +1,14 @@
// SPDX-License-Identifier: LGPL-3.0-only // SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Three configurations:
// Crafter.Debug.Core what the app and the client share (values, codecs)
// crafter.debug-cli the client, crafter-debug
// Crafter.Debug what an app links (returned), depending on both
// The client is one of the library's dependencies only so that a consumer's
// debug build compiles it in the same pass as the app; an executable adds no
// link flags to what depends on it. Consumers load this file through
// Debug::Use (tooling/), which also writes their launcher.
import std; import std;
import Crafter.Build; import Crafter.Build;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -18,6 +26,13 @@ namespace {
fs::path Root() { fs::path Root() {
return fs::path(std::source_location::current().file_name()).parent_path(); return fs::path(std::source_location::current().file_name()).parent_path();
} }
constexpr std::array<std::string_view, 4> tests = {
"ShouldRoundTripJson",
"ShouldRoundTripAggregates",
"ShouldReportDecodeErrorsWithPaths",
"ShouldDescribeDefaultsAndRequired",
};
} }
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
@ -27,17 +42,30 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.outputName = "Crafter.Debug"; cfg.outputName = "Crafter.Debug";
cfg.type = ConfigurationType::LibraryStatic; cfg.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(cfg, args); ApplyStandardArgs(cfg, args);
Configuration& core = owned.emplace_back();
core.path = Root();
core.name = "Crafter.Debug.Core";
core.outputName = "Crafter.Debug.Core";
core.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(core, args);
{ {
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug" }; std::array<fs::path, 5> interfaces = {
std::array<fs::path, 0> implementations = {}; "interfaces/Crafter.Debug.Core-Value",
cfg.GetInterfacesAndImplementations(interfaces, implementations); "interfaces/Crafter.Debug.Core-Reflect",
"interfaces/Crafter.Debug.Core-Codec",
"interfaces/Crafter.Debug.Core-Result",
"interfaces/Crafter.Debug.Core",
};
std::array<fs::path, 2> implementations = {
"implementations/Crafter.Debug.Core-Value",
"implementations/Crafter.Debug.Core-Codec",
};
core.GetInterfacesAndImplementations(interfaces, implementations);
} }
// The client, crafter-debug. One of the library's dependencies, so a // The client, built for the library's target. Not through
// consumer's debug build compiles it in the same pass as the app; an // ApplyStandardArgs: a consumer's --lib would make it a library.
// executable adds no link flags to what depends on it. Built for the same
// target as the library, and not through ApplyStandardArgs, which would
// turn it into a library when a consumer passes --lib.
Configuration& client = owned.emplace_back(); Configuration& client = owned.emplace_back();
client.path = Root(); client.path = Root();
client.name = "crafter.debug-cli"; client.name = "crafter.debug-cli";
@ -48,11 +76,24 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
client.mtune = cfg.mtune; client.mtune = cfg.mtune;
client.sysroot = cfg.sysroot; client.sysroot = cfg.sysroot;
client.debug = cfg.debug; client.debug = cfg.debug;
client.dependencies = { &core };
{ {
std::array<fs::path, 0> interfaces = {}; std::array<fs::path, 0> interfaces = {};
std::array<fs::path, 1> implementations = { "implementations/main" }; std::array<fs::path, 1> implementations = { "implementations/main" };
client.GetInterfacesAndImplementations(interfaces, implementations); client.GetInterfacesAndImplementations(interfaces, implementations);
} }
cfg.dependencies.push_back(&client);
cfg.dependencies = { &core, &client };
{
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug" };
std::array<fs::path, 0> implementations = {};
cfg.GetInterfacesAndImplementations(interfaces, implementations);
}
// Tests only when this is the project being built: a consumer's load of
// this file has no use for them.
if (IsTopLevelProject()) {
for (const std::string_view test : tests) cfg.AddTest(test).Dependencies({ &core });
}
return cfg; return cfg;
} }

View file

@ -0,0 +1,67 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Describe gives `crafter-debug help` what an agent needs to call a command
// right the first time: each field's name and type, its default taken from
// the member initializer, Required fields marked with no default, a narrow
// integer's range, an enum's names, a nested aggregate's own fields. A type
// that contains itself is described a few levels deep instead of forever.
import std;
import Crafter.Debug.Core;
using namespace Crafter::Debug;
namespace Game {
enum class Mode : std::uint8_t { Idle, Build };
struct Vec3 {
float x = 0;
float y = 1;
float z = 0;
};
struct Aim {
Required<std::uint8_t> slot;
float yaw = 45;
Vec3 at;
std::optional<std::string> label;
Mode mode = Mode::Build;
std::vector<std::int32_t> ids;
};
struct Node {
std::vector<Node> children;
};
}
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
}
int main() {
const Value description = Describe<Game::Aim>();
std::println("{}", description.DumpPretty());
Check(description.Find("type")->AsString() == "object" && description.Find("name")->AsString().ends_with("Aim"), "an aggregate describes as a named object");
const std::span<const Value> fields = description.Find("fields")->Items();
Check(fields.size() == 6, "one entry per field");
const auto field = [&](std::string_view name) -> const Value& {
for (const Value& f : fields) {
if (f.Find("name")->AsString() == name) return f;
}
throw std::runtime_error(std::format("no field {}", name));
};
Check(field("slot").Find("required") != nullptr && field("slot").Find("default") == nullptr, "a Required field is marked required, with no default");
Check(field("slot").Find("type")->Find("maximum")->AsInteger() == 255, "a narrow integer carries its range");
Check(field("yaw").Find("default")->AsNumber() == 45, "a default comes from the member initializer");
Check(field("at").Find("type")->Find("fields")->Size() == 3 && field("at").Find("default")->Find("y")->AsNumber() == 1, "a nested aggregate describes its fields and default");
Check(field("label").Find("type")->Find("nullable") != nullptr && field("label").Find("default")->IsNull(), "an optional is nullable and defaults to null");
Check(field("mode").Find("type")->Find("values")->Size() == 2 && field("mode").Find("default")->AsString() == "Build", "an enum lists its names");
Check(field("ids").Find("type")->Find("items")->Find("type")->AsString() == "integer", "a vector describes its items");
Check(!Describe<Game::Node>().Dump().empty(), "a type that contains itself is described without recursing forever");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A command call with several mistakes gets one reply that names each of
// them: a missing Required field, a wrong type naming both kinds, a nested
// path ("path[1].x"), an out-of-range integer with the range, and for an
// unknown field or enum name the one the caller most likely meant. Decoding
// never throws, so a bad call cannot take the app down. Guards the replies an
// agent corrects its calls from.
import std;
import Crafter.Debug.Core;
using namespace Crafter::Debug;
namespace Game {
enum class Mode : std::uint8_t { Idle, Build, Fight };
struct Vec3 {
float x = 0;
float y = 0;
float z = 0;
};
struct Aim {
Required<std::uint8_t> slot;
float yaw = 0;
float pitch = 0;
std::vector<Vec3> path;
Mode mode = Mode::Idle;
};
}
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
bool Has(const DecodeContext& context, std::string_view path, std::string_view text) {
return std::ranges::any_of(context.Issues(), [&](const DecodeIssue& issue) {
return issue.path == path && issue.message.find(text) != std::string::npos;
});
}
}
int main() {
Game::Aim aim;
DecodeContext context;
Decode(Value::Parse(R"({"yw": 1, "pitch": "up", "path": [{"x": 1}, {"x": "no"}], "mode": "Fihgt"})"), aim, context);
std::println("report: {}", context.Report());
Check(context.Issues().size() == 5, std::format("five mistakes, five issues ({})", context.Issues().size()));
Check(Has(context, "slot", "required"), "a missing Required field is reported");
Check(Has(context, "pitch", "expected a number, got a string"), "a wrong type names both kinds");
Check(Has(context, "path[1].x", "expected a number"), "a nested path names the item and the member");
Check(Has(context, "mode", "did you mean 'Fight'"), "a mistyped enum name gets a suggestion");
Check(Has(context, "yw", "did you mean 'yaw'"), "an unknown field gets a suggestion");
Check(context.Report().find("slot: missing") != std::string::npos, "the report prefixes each issue with its path");
Game::Aim outOfRange;
DecodeContext rangeContext;
Decode(Value::Parse(R"({"slot": 300})"), outOfRange, rangeContext);
Check(Has(rangeContext, "slot", "300 is out of range (0..255)"), "an out-of-range integer names the range");
Game::Aim notAnObject;
DecodeContext objectContext;
Decode(Value::Parse("[1]"), notAnObject, objectContext);
Check(objectContext.Issues().size() == 1 && objectContext.Issues()[0].path.empty()
&& objectContext.Issues()[0].message == "expected an object, got an array",
"a non-object is one issue at the root");
Game::Aim farOff;
DecodeContext farContext;
Decode(Value::Parse(R"({"slot": 1, "zzzzzz": 1})"), farOff, farContext);
Check(Has(farContext, "zzzzzz", "unknown field (fields: slot, yaw, pitch, path, mode)"), "an unknown field with no close match lists the fields");
return failures == 0 ? 0 : 1;
}

View file

@ -0,0 +1,94 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Codec round-trips an aggregate built from every supported field type
// (strings, nested aggregates in a vector, optional, std::array, string-keyed
// maps, a scoped enum, Required) through Value and its text, with no
// hand-written serialization. Fields the input leaves out keep their default
// member initializers, null clears an optional, and an enum also accepts its
// integer. Guards the reflection that command arguments and results rely on.
import std;
import Crafter.Debug.Core;
using namespace Crafter::Debug;
namespace Game {
enum class Mode : std::uint8_t { Idle, Build, Fight = 5 };
struct Vec3 {
float x = 0;
float y = 0;
float z = 0;
};
struct Loadout {
std::string name = "default";
std::vector<Vec3> points;
std::optional<std::int32_t> target;
std::array<bool, 3> flags{};
std::map<std::string, double> weights;
Mode mode = Mode::Idle;
Required<std::uint16_t> slot;
};
}
namespace {
std::int32_t failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
}
int main() {
Game::Loadout in;
in.name = "fort \"one\"";
in.points = { { 1, 2, 3 }, { -0.5F, 0, 1e10F } };
in.target = -7;
in.flags = { true, false, true };
in.weights = { { "cannon", 0.25 }, { "laser", 1 } };
in.mode = Game::Mode::Fight;
*in.slot = 3;
const Value encoded = Encode(in);
Check(encoded.IsObject() && encoded.Size() == 7, "an aggregate encodes as an object with one member per field");
Check(encoded.Find("mode")->AsString() == "Fight", "a scoped enum encodes by name");
Check(encoded.Find("slot")->AsInteger() == 3, "Required encodes as its value");
Check(encoded.Find("target")->AsInteger() == -7, "an optional with a value encodes as the value");
Check(encoded.Find("points")->Items()[1].Find("z")->AsNumber() == 1e10F, "nested aggregates encode inside a vector");
Game::Loadout out;
DecodeContext context;
Decode(Value::Parse(encoded.Dump()), out, context);
Check(context.Ok(), std::format("decodes what it encoded ({})", context.Report()));
Check(out.name == in.name && out.points.size() == 2 && out.points[1].z == 1e10F && out.target == -7 && out.flags[2]
&& out.weights.at("laser") == 1 && out.mode == Game::Mode::Fight && *out.slot == 3,
"every field survives the round trip");
Game::Loadout partial;
partial.target = 9;
DecodeContext partialContext;
Decode(Value::Parse(R"({"slot": 1, "target": null, "mode": 1})"), partial, partialContext);
Check(partialContext.Ok(), std::format("a partial object decodes ({})", partialContext.Report()));
Check(partial.name == "default" && partial.points.empty(), "fields left out keep their defaults");
Check(!partial.target.has_value(), "null clears an optional");
Check(partial.mode == Game::Mode::Build, "an enum accepts its integer");
float nan = 0;
DecodeContext nanContext;
Decode(Encode(std::numeric_limits<float>::quiet_NaN()), nan, nanContext);
Check(nanContext.Ok() && std::isnan(nan), "NaN survives the round trip");
std::vector<bool> bits;
DecodeContext bitsContext;
Decode(Value::Parse("[true,false,true]"), bits, bitsContext);
Check(bitsContext.Ok() && bits.size() == 3 && bits[2] && !bits[1], "std::vector<bool> decodes");
const std::uint64_t large = (std::uint64_t{ 1 } << 63) + 2048;
std::uint64_t decoded = 0;
DecodeContext largeContext;
Decode(Value::Parse(Encode(large).Dump()), decoded, largeContext);
Check(largeContext.Ok() && decoded == large, "a uint64 above the int64 range survives");
return failures == 0 ? 0 : 1;
}

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