Cli::Run is the whole client but its transport, so tests run it in process against a real Session; main will hand it the QUIC connection. - Commands: ls, status, cmds, help, call, get, set, pause, resume, step, wait, trace, log (with --follow), screenshot, quit, debugger, guide and version, with -i, --json and --timeout. - call types key=value from the command's argument schema (slot=1 is a number, name=1 a string, at.x=3 nests), so agents need no JSON for simple calls; a value that cannot fit its field is a usage error before anything is sent. - The instance comes from discovery; one running instance needs no -i. The record's protocol is checked before any request: a mismatch exits 4, says which side is newer and prints the app's own client. Other sources only warn. - A request with no reply is diagnosed: the app has exited, stopped in a debugger (with the attach command), or no longer calls Pump (with what to change). Exit codes: 2 usage, 3 no instance, 4 version, 5 timeout, 6 the app's error. - Output is short by default, arrays cut at 20 with a note; --json prints everything. `guide` is the full reference for agents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
513 lines
21 KiB
C++
513 lines
21 KiB
C++
// 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");
|
|
// 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<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>>{});
|
|
}
|
|
};
|
|
}
|