diff --git a/implementations/Crafter.Debug.Core-Codec.cpp b/implementations/Crafter.Debug.Core-Codec.cpp new file mode 100644 index 0000000..c29759f --- /dev/null +++ b/implementations/Crafter.Debug.Core-Codec.cpp @@ -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 previous(b.size() + 1); + std::vector 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 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 Crafter::Debug::Suggest(std::string_view word, std::span candidates) { + const std::size_t allowed = word.size() <= 4 ? 1 : 2; + std::optional 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 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 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)); +} diff --git a/implementations/Crafter.Debug.Core-Value.cpp b/implementations/Crafter.Debug.Core-Value.cpp new file mode 100644 index 0000000..83fb74e --- /dev/null +++ b/implementations/Crafter.Debug.Core-Value.cpp @@ -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(code); + } else if (code < 0x800) { + out += static_cast(0xC0 | (code >> 6)); + out += static_cast(0x80 | (code & 0x3F)); + } else if (code < 0x10000) { + out += static_cast(0xE0 | (code >> 12)); + out += static_cast(0x80 | ((code >> 6) & 0x3F)); + out += static_cast(0x80 | (code & 0x3F)); + } else { + out += static_cast(0xF0 | (code >> 18)); + out += static_cast(0x80 | ((code >> 12) & 0x3F)); + out += static_cast(0x80 | ((code >> 6) & 0x3F)); + out += static_cast(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(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(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(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 buffer{}; + const auto [end, error] = std::to_chars(buffer.data(), buffer.data() + buffer.size(), number); + const std::string_view digits(buffer.data(), static_cast(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(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 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(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 Value::Items() const { + if (kind != ValueKind::Array) throw WrongKind(kind, "an array"); + return items; +} + +std::span 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(found - keys.begin())]; +} + +Value* Value::Find(std::string_view key) { + return const_cast(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(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; + } + } +} diff --git a/interfaces/Crafter.Debug.Core-Codec.cppm b/interfaces/Crafter.Debug.Core-Codec.cppm new file mode 100644 index 0000000..69940fe --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Codec.cppm @@ -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 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 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 + 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 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 segments; + std::vector 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 Suggest(std::string_view word, std::span candidates); + + template + struct Codec; + + template + concept Codable = requires(const T& in, const Value& value, T& out, DecodeContext& context) { + { Codec::Encode(in) } -> std::same_as; + Codec::Decode(value, out, context); + { Codec::Describe() } -> std::same_as; + }; + + template + Value Encode(const T& value) { + static_assert(Codable, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec"); + return Codec::Encode(value); + } + + template + void Decode(const Value& value, T& out, DecodeContext& context) { + static_assert(Codable, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec"); + Codec::Decode(value, out, context); + } + + template + Value Describe() { + static_assert(Codable, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec"); + return Codec::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 fields); + // The message for a name a scoped enum does not have. + std::string UnknownName(std::string_view name, std::span 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 + concept Number = std::integral && !std::same_as && !std::same_as && !std::same_as + && !std::same_as && !std::same_as && !std::same_as; + + template + inline constexpr bool isRequired = false; + template + inline constexpr bool isRequired> = 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 { + 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 + struct Codec { + 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(integer)) { + out = static_cast(integer); + return; + } + context.Fail(std::format("{} is out of range ({}..{})", integer, std::numeric_limits::min(), std::numeric_limits::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(std::numeric_limits::min()) && number <= static_cast(std::numeric_limits::max())) { + out = static_cast(number); + return; + } + context.Fail(std::format("expected an integer in {}..{}, got {}", std::numeric_limits::min(), std::numeric_limits::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::min())); + description.Set("maximum", Value(std::numeric_limits::max())); + } + return description; + } + }; + + template + struct Codec { + static Value Encode(T value) { return Value(static_cast(value)); } + + static void Decode(const Value& value, T& out, DecodeContext& context) { + if (value.IsNumber()) { + out = static_cast(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::quiet_NaN(); + return; + } + if (text == "Infinity" || text == "-Infinity") { + out = text.starts_with('-') ? -std::numeric_limits::infinity() : std::numeric_limits::infinity(); + return; + } + } + context.Fail(Detail::Expected("a number", value)); + } + + static Value Describe() { return Detail::Type("number"); } + }; + + template<> + struct Codec { + 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 { + 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 + requires std::is_enum_v + struct Codec { + static Value Encode(E value) { + if constexpr (std::is_scoped_enum_v) { + 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) { + if (value.IsString()) { + if (const auto parsed = EnumFromName(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>(integer)) { + out = static_cast(integer); + return; + } + context.Fail(std::format("{} is out of range for this enum", integer)); + return; + } + context.Fail(Detail::Expected(std::is_scoped_enum_v ? "a name or an integer" : "an integer", value)); + } + + static Value Describe() { + if constexpr (std::is_scoped_enum_v) { + 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 Names() + requires std::is_scoped_enum_v + { + std::vector names; + for (const Enumerator& e : enumerators) names.push_back(e.name); + return names; + } + }; + + template + struct Codec> { + static Value Encode(const std::optional& value) { + return value ? Debug::Encode(*value) : Value(); + } + + static void Decode(const Value& value, std::optional& 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(); + description.Set("nullable", true); + return description; + } + }; + + template + struct Codec> { + static Value Encode(const std::vector& 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& out, DecodeContext& context) { + if (!value.IsArray()) { + context.Fail(Detail::Expected("an array", value)); + return; + } + std::vector decoded; + decoded.reserve(value.Size()); + const std::span 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 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()); + return description; + } + }; + + template + struct Codec> { + static Value Encode(const std::array& 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& 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 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()); + description.Set("length", Value(N)); + return description; + } + }; + + // Maps keyed by string: std::map and std::unordered_map alike. + template + 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()); + return description; + } + }; + + template + struct Codec> : MapCodec> {}; + + template + struct Codec> : MapCodec> {}; + + template<> + struct Codec { + 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 + struct Codec> { + static Value Encode(const Required& value) { return Debug::Encode(value.value); } + static void Decode(const Value& value, Required& out, DecodeContext& context) { Debug::Decode(value, out.value, context); } + static Value Describe() { return Debug::Describe(); } + }; + + // Every aggregate: an object with one member per field, by name. + template + requires Aggregate + struct Codec { + static Value Encode(const T& value) { + Value object = Value::MakeObject(); + [&](std::index_sequence) { + (object.Set(MemberName(), Debug::Encode(Member(value))), ...); + }(std::make_index_sequence>{}); + return object; + } + + static void Decode(const Value& value, T& out, DecodeContext& context) { + if (!value.IsObject()) { + context.Fail(Detail::Expected("an object", value)); + return; + } + [&](std::index_sequence) { + ([&] { + constexpr std::string_view name = MemberName(); + const auto scope = context.Field(name); + if (const Value* field = value.Find(name)) { + Debug::Decode(*field, Member(out), context); + } else if constexpr (Detail::isRequired>) { + context.Fail("missing, and this field is required"); + } + }(), ...); + }(std::make_index_sequence>{}); + static constexpr std::array> 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())); + if (Detail::describeDepth >= 8) { + description.Set("truncated", true); + return description; + } + ++Detail::describeDepth; + Value fields = Value::MakeArray(); + const T defaults{}; + [&](std::index_sequence) { + ([&] { + using M = MemberType; + Value field = Value::MakeObject(); + field.Set("name", Value(MemberName())); + field.Set("type", Debug::Describe()); + if constexpr (Detail::isRequired) { + field.Set("required", true); + } else { + field.Set("default", Debug::Encode(Member(defaults))); + } + fields.Push(std::move(field)); + }(), ...); + }(std::make_index_sequence>{}); + --Detail::describeDepth; + description.Set("fields", std::move(fields)); + return description; + } + + private: + static consteval std::array> Names() { + return [&](std::index_sequence) { + return std::array>{ MemberName()... }; + }(std::make_index_sequence>{}); + } + }; +} diff --git a/interfaces/Crafter.Debug.Core-Reflect.cppm b/interfaces/Crafter.Debug.Core-Reflect.cppm new file mode 100644 index 0000000..bad95f1 --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Reflect.cppm @@ -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 ), 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 , this partition is the only thing to replace. +export module Crafter.Debug.Core:Reflect; +import std; + +namespace Crafter::Debug::Reflect { + template + struct CountHelper { + template + static consteval auto Count(U& u) { + auto&& [...m] = u; + return std::integral_constant{}; + } + static constexpr std::size_t value = decltype(Count(std::declval()))::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 + [[clang::no_destroy]] inline const T external{}; + + template + consteval std::string_view RawName() { + return std::source_location::current().function_name(); + } + + template + consteval std::string_view RawTypeName() { + return std::source_location::current().function_name(); + } + + // The part of RawName

()'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 + consteval std::string_view EnumeratorName() { + const std::string_view text = ProbeText(RawName()); + 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 + concept Aggregate = std::is_class_v && std::is_aggregate_v && requires { Reflect::CountHelper::value; }; + + template + inline constexpr std::size_t memberCount = Reflect::CountHelper::value; + + // The I-th member of an aggregate lvalue. + template + constexpr decltype(auto) Member(T&& t) noexcept { + auto&& [...m] = t; + return (m...[I]); + } + + template + using MemberType = std::remove_cvref_t(std::declval()))>; + + // The declared name of the I-th member: "yaw". Parsed out of + // "std::string_view ...::RawName() [Probe = &external.yaw]". + template + consteval std::string_view MemberName() { + constexpr std::string_view text = Reflect::ProbeText(Reflect::RawName<&Member(Reflect::external)>()); + 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 + consteval std::string_view TypeName() { + constexpr std::string_view raw = Reflect::RawTypeName(); + 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()(member) for every member, in order. + template + constexpr void ForEachMember(T& t, F&& f) { + [&](std::index_sequence) { + (f.template operator()(Member(t)), ...); + }(std::make_index_sequence>{}); + } + + // 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 + struct EnumRange { + static constexpr std::int64_t min = -128; + static constexpr std::int64_t max = 128; + }; + + template + struct Enumerator { + E value; + std::string_view name; + }; +} + +namespace Crafter::Debug::Reflect { + template + consteval std::int64_t ProbeMin() { + using U = std::underlying_type_t; + return std::max(EnumRange::min, static_cast(std::numeric_limits::min())); + } + + template + consteval std::int64_t ProbeMax() { + using U = std::underlying_type_t; + constexpr std::int64_t limit = static_cast(std::numeric_limits::max()) >= static_cast(std::numeric_limits::max()) + ? std::numeric_limits::max() + : static_cast(std::numeric_limits::max()) + 1; + return std::min(EnumRange::max, limit); + } + + template + consteval auto EnumTable(std::index_sequence) { + constexpr std::int64_t first = ProbeMin(); + constexpr std::array names = { + EnumeratorName(first + static_cast(I))>()... + }; + constexpr std::size_t count = static_cast(std::ranges::count_if(names, [](std::string_view name) { return !name.empty(); })); + std::array, 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(first + static_cast(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 + requires std::is_scoped_enum_v + inline constexpr auto enumerators = Reflect::EnumTable(std::make_index_sequence(Reflect::ProbeMax() - Reflect::ProbeMin())>{}); + + template + requires std::is_scoped_enum_v + constexpr std::optional EnumName(E value) { + for (const Enumerator& e : enumerators) { + if (e.value == value) return e.name; + } + return std::nullopt; + } + + template + requires std::is_scoped_enum_v + constexpr std::optional EnumFromName(std::string_view name) { + for (const Enumerator& e : enumerators) { + 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); + static_assert(memberCount == 3); + static_assert(MemberName<0, ProbeStruct>() == "alpha"); + static_assert(MemberName<2, ProbeStruct>() == "gamma"); + static_assert(std::is_same_v, double>); + static_assert(TypeName().ends_with("ProbeStruct")); + static_assert(enumerators.size() == 3); + static_assert(EnumName(ProbeEnum::Seven) == "Seven"); + static_assert(!EnumName(static_cast(5)).has_value()); + static_assert(EnumFromName("One") == ProbeEnum::One); + static_assert(EnumName(ProbeSigned::Minus) == "Minus"); + static_assert(enumerators.size() == 2); +} diff --git a/interfaces/Crafter.Debug.Core-Result.cppm b/interfaces/Crafter.Debug.Core-Result.cppm new file mode 100644 index 0000000..813b22c --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Result.cppm @@ -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 Result { + public: + // Anything T can be made from, so `return "text";` works for a + // Result. + template + requires (std::constructible_from && !std::same_as, Error> && !std::same_as, Result>) + Result(U&& value) : state(std::in_place_index<0>, std::forward(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 state; + }; + + template<> + class Result { + 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; + }; + + // 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 (empty while not ready), or + // std::optional> to fail; for Async, a bool or a + // std::optional>. + template + class Async { + public: + template + requires std::invocable + Async(F poller) : poll(Wrap(std::move(poller))) {} + + // Empty while not ready. + std::optional> Poll() { return poll(); } + + private: + std::function>()> poll; + + template + static std::function>()> Wrap(F poller) { + using R = std::invoke_result_t; + if constexpr (std::is_same_v>>) { + return poller; + } else if constexpr (std::is_void_v) { + static_assert(std::is_same_v, "an Async poller returns bool or std::optional>"); + return [poller = std::move(poller)]() mutable -> std::optional> { + if (!poller()) return std::nullopt; + return Result(); + }; + } else { + static_assert(std::is_same_v>, "an Async poller returns std::optional or std::optional>"); + return [poller = std::move(poller)]() mutable -> std::optional> { + std::optional value = poller(); + if (!value) return std::nullopt; + return Result(std::move(*value)); + }; + } + } + }; +} diff --git a/interfaces/Crafter.Debug.Core-Value.cppm b/interfaces/Crafter.Debug.Core-Value.cppm new file mode 100644 index 0000000..fec1673 --- /dev/null +++ b/interfaces/Crafter.Debug.Core-Value.cppm @@ -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 + requires (!std::same_as && !std::same_as) + Value(T value) { + if constexpr (std::is_unsigned_v && sizeof(T) >= sizeof(std::int64_t)) { + if (value > static_cast(std::numeric_limits::max())) { + kind = ValueKind::Number; + number = static_cast(value); + return; + } + } + kind = ValueKind::Integer; + integer = static_cast(value); + } + Value(double value) : kind(ValueKind::Number), number(value) {} + Value(float value) : Value(static_cast(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 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 Items() const; + std::span 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 items; + // Object members as two parallel lists: a member type holding a Value + // could not be complete inside Value's own definition. + std::vector keys; + std::vector values; + + void DumpTo(std::string& out, std::uint32_t indent, std::uint32_t depth) const; + }; +} diff --git a/interfaces/Crafter.Debug.Core.cppm b/interfaces/Crafter.Debug.Core.cppm new file mode 100644 index 0000000..33eb239 --- /dev/null +++ b/interfaces/Crafter.Debug.Core.cppm @@ -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; diff --git a/interfaces/Crafter.Debug.cppm b/interfaces/Crafter.Debug.cppm index a17cd1f..b98a747 100644 --- a/interfaces/Crafter.Debug.cppm +++ b/interfaces/Crafter.Debug.cppm @@ -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"; } } diff --git a/project.cpp b/project.cpp index fdc129c..24b0d02 100644 --- a/project.cpp +++ b/project.cpp @@ -1,6 +1,14 @@ // SPDX-License-Identifier: LGPL-3.0-only // 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 Crafter.Build; namespace fs = std::filesystem; @@ -18,6 +26,13 @@ namespace { fs::path Root() { return fs::path(std::source_location::current().file_name()).parent_path(); } + + constexpr std::array tests = { + "ShouldRoundTripJson", + "ShouldRoundTripAggregates", + "ShouldReportDecodeErrorsWithPaths", + "ShouldDescribeDefaultsAndRequired", + }; } extern "C" Configuration CrafterBuildProject(std::span args) { @@ -27,17 +42,30 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "Crafter.Debug"; cfg.type = ConfigurationType::LibraryStatic; 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 interfaces = { "interfaces/Crafter.Debug" }; - std::array implementations = {}; - cfg.GetInterfacesAndImplementations(interfaces, implementations); + std::array interfaces = { + "interfaces/Crafter.Debug.Core-Value", + "interfaces/Crafter.Debug.Core-Reflect", + "interfaces/Crafter.Debug.Core-Codec", + "interfaces/Crafter.Debug.Core-Result", + "interfaces/Crafter.Debug.Core", + }; + std::array 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 - // consumer's debug build compiles it in the same pass as the app; an - // 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. + // The client, built for the library's target. Not through + // ApplyStandardArgs: a consumer's --lib would make it a library. Configuration& client = owned.emplace_back(); client.path = Root(); client.name = "crafter.debug-cli"; @@ -48,11 +76,24 @@ extern "C" Configuration CrafterBuildProject(std::span a client.mtune = cfg.mtune; client.sysroot = cfg.sysroot; client.debug = cfg.debug; + client.dependencies = { &core }; { std::array interfaces = {}; std::array implementations = { "implementations/main" }; client.GetInterfacesAndImplementations(interfaces, implementations); } - cfg.dependencies.push_back(&client); + + cfg.dependencies = { &core, &client }; + { + std::array interfaces = { "interfaces/Crafter.Debug" }; + std::array 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; } diff --git a/tests/ShouldDescribeDefaultsAndRequired/main.cpp b/tests/ShouldDescribeDefaultsAndRequired/main.cpp new file mode 100644 index 0000000..538735e --- /dev/null +++ b/tests/ShouldDescribeDefaultsAndRequired/main.cpp @@ -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 slot; + float yaw = 45; + Vec3 at; + std::optional label; + Mode mode = Mode::Build; + std::vector ids; + }; + + struct Node { + std::vector 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(); + 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 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().Dump().empty(), "a type that contains itself is described without recursing forever"); + + return failures == 0 ? 0 : 1; +} diff --git a/tests/ShouldReportDecodeErrorsWithPaths/main.cpp b/tests/ShouldReportDecodeErrorsWithPaths/main.cpp new file mode 100644 index 0000000..6060b04 --- /dev/null +++ b/tests/ShouldReportDecodeErrorsWithPaths/main.cpp @@ -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 slot; + float yaw = 0; + float pitch = 0; + std::vector 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; +} diff --git a/tests/ShouldRoundTripAggregates/main.cpp b/tests/ShouldRoundTripAggregates/main.cpp new file mode 100644 index 0000000..be30d8e --- /dev/null +++ b/tests/ShouldRoundTripAggregates/main.cpp @@ -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 points; + std::optional target; + std::array flags{}; + std::map weights; + Mode mode = Mode::Idle; + Required 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::quiet_NaN()), nan, nanContext); + Check(nanContext.Ok() && std::isnan(nan), "NaN survives the round trip"); + + std::vector bits; + DecodeContext bitsContext; + Decode(Value::Parse("[true,false,true]"), bits, bitsContext); + Check(bitsContext.Ok() && bits.size() == 3 && bits[2] && !bits[1], "std::vector 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; +} diff --git a/tests/ShouldRoundTripJson/main.cpp b/tests/ShouldRoundTripJson/main.cpp new file mode 100644 index 0000000..e04a1e9 --- /dev/null +++ b/tests/ShouldRoundTripJson/main.cpp @@ -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::quiet_NaN()).Dump() == "\"NaN\"", "NaN is written as a string"); + Check(Value(-std::numeric_limits::infinity()).Dump() == "\"-Infinity\"", "-Infinity is written as a string"); + Check(Value(std::numeric_limits::max()).Kind() == ValueKind::Number, "a uint64 above the int64 range becomes a number"); + Check(Value::Parse("9223372036854775807").AsInteger() == std::numeric_limits::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; +}