// 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"); // The range only where it tells an agent something: 8 and 16 bits. if constexpr (sizeof(T) < sizeof(std::int32_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) { // A float as the shortest decimal that reads back as it: 0.1f // widened is 0.10000000149011612, which is what an agent would // otherwise read for every float. if constexpr (sizeof(T) < sizeof(double)) { if (std::isfinite(value)) { std::array text{}; const auto written = std::to_chars(text.data(), text.data() + text.size(), value); double shortest = 0; if (written.ec == std::errc() && std::from_chars(text.data(), written.ptr, shortest).ec == std::errc()) return Value(shortest); } } 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>{}); } }; }