feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
89a12bcd61
commit
3500b73ecf
12 changed files with 1829 additions and 7 deletions
314
implementations/Crafter.Debug-Condition.cpp
Normal file
314
implementations/Crafter.Debug-Condition.cpp
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Condition_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Condition;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
struct Condition::Node {
|
||||
enum class Op : std::uint8_t {
|
||||
Or,
|
||||
And,
|
||||
Not,
|
||||
Compare,
|
||||
Truthy,
|
||||
};
|
||||
enum class Comparison : std::uint8_t {
|
||||
Equal,
|
||||
NotEqual,
|
||||
Less,
|
||||
LessEqual,
|
||||
Greater,
|
||||
GreaterEqual,
|
||||
};
|
||||
struct Operand {
|
||||
// A path to resolve, or else a literal.
|
||||
std::string path;
|
||||
Value literal;
|
||||
};
|
||||
|
||||
Op op = Op::Truthy;
|
||||
Comparison comparison = Comparison::Equal;
|
||||
std::shared_ptr<const Node> left;
|
||||
std::shared_ptr<const Node> right;
|
||||
Operand a;
|
||||
Operand b;
|
||||
// The text this node was parsed from, for errors.
|
||||
std::string source;
|
||||
};
|
||||
|
||||
namespace {
|
||||
using Node = Condition::Node;
|
||||
using Outcome = std::expected<bool, std::string>;
|
||||
|
||||
// Nesting deeper than this is refused rather than recursed into.
|
||||
constexpr std::uint32_t maxDepth = 64;
|
||||
|
||||
bool PathStart(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
|
||||
}
|
||||
|
||||
bool PathChar(char c) {
|
||||
return PathStart(c) || (c >= '0' && c <= '9') || c == '-';
|
||||
}
|
||||
|
||||
std::string_view ComparisonText(Node::Comparison comparison) {
|
||||
switch (comparison) {
|
||||
case Node::Comparison::Equal: return "==";
|
||||
case Node::Comparison::NotEqual: return "!=";
|
||||
case Node::Comparison::Less: return "<";
|
||||
case Node::Comparison::LessEqual: return "<=";
|
||||
case Node::Comparison::Greater: return ">";
|
||||
case Node::Comparison::GreaterEqual: return ">=";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
Parser(std::string_view text, std::vector<std::string>& paths) : text(text), paths(paths) {}
|
||||
|
||||
std::shared_ptr<const Node> Document() {
|
||||
auto node = Or(0);
|
||||
Skip();
|
||||
if (!AtEnd()) Fail("unexpected text; join conditions with && or ||");
|
||||
return node;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string_view text;
|
||||
std::vector<std::string>& paths;
|
||||
std::size_t position = 0;
|
||||
|
||||
[[noreturn]] void Fail(std::string_view what) const {
|
||||
throw ConditionError(position, what);
|
||||
}
|
||||
|
||||
bool AtEnd() const {
|
||||
return position >= text.size();
|
||||
}
|
||||
|
||||
void Skip() {
|
||||
while (!AtEnd() && (text[position] == ' ' || text[position] == '\t')) ++position;
|
||||
}
|
||||
|
||||
bool Match(std::string_view token) {
|
||||
Skip();
|
||||
if (text.substr(position, token.size()) != token) return false;
|
||||
position += token.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Combine(Node::Op op, std::shared_ptr<const Node> left, std::shared_ptr<const Node> right) {
|
||||
auto node = std::make_shared<Node>();
|
||||
node->op = op;
|
||||
node->left = std::move(left);
|
||||
node->right = std::move(right);
|
||||
return node;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Or(std::uint32_t depth) {
|
||||
auto left = And(depth);
|
||||
while (Match("||")) left = Combine(Node::Op::Or, std::move(left), And(depth));
|
||||
return left;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> And(std::uint32_t depth) {
|
||||
auto left = Not(depth);
|
||||
while (Match("&&")) left = Combine(Node::Op::And, std::move(left), Not(depth));
|
||||
return left;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Not(std::uint32_t depth) {
|
||||
if (depth > maxDepth) Fail("nested too deeply");
|
||||
Skip();
|
||||
if (!AtEnd() && text[position] == '!' && text.substr(position, 2) != "!=") {
|
||||
++position;
|
||||
return Combine(Node::Op::Not, Not(depth + 1), nullptr);
|
||||
}
|
||||
return Primary(depth);
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Primary(std::uint32_t depth) {
|
||||
if (Match("(")) {
|
||||
auto inner = Or(depth + 1);
|
||||
if (!Match(")")) Fail("expected ')'");
|
||||
return inner;
|
||||
}
|
||||
Skip();
|
||||
const std::size_t start = position;
|
||||
auto node = std::make_shared<Node>();
|
||||
node->a = Operand();
|
||||
if (const auto comparison = ComparisonOperator()) {
|
||||
node->op = Node::Op::Compare;
|
||||
node->comparison = *comparison;
|
||||
node->b = Operand();
|
||||
} else {
|
||||
node->op = Node::Op::Truthy;
|
||||
}
|
||||
node->source = std::string(text.substr(start, position - start));
|
||||
while (!node->source.empty() && node->source.back() == ' ') node->source.pop_back();
|
||||
return node;
|
||||
}
|
||||
|
||||
std::optional<Node::Comparison> ComparisonOperator() {
|
||||
Skip();
|
||||
if (Match("==")) return Node::Comparison::Equal;
|
||||
if (Match("!=")) return Node::Comparison::NotEqual;
|
||||
if (Match("<=")) return Node::Comparison::LessEqual;
|
||||
if (Match(">=")) return Node::Comparison::GreaterEqual;
|
||||
if (Match("<")) return Node::Comparison::Less;
|
||||
if (Match(">")) return Node::Comparison::Greater;
|
||||
if (!AtEnd() && text[position] == '=') Fail("use == to compare");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Node::Operand Operand() {
|
||||
Skip();
|
||||
if (AtEnd()) Fail("expected a path or a value");
|
||||
const char c = text[position];
|
||||
if (c == '"' || c == '\'') return { {}, Value(String(c)) };
|
||||
if ((c >= '0' && c <= '9') || c == '-') return { {}, Number() };
|
||||
if (!PathStart(c)) Fail("expected a path or a value");
|
||||
const std::size_t start = position;
|
||||
while (!AtEnd()) {
|
||||
const char next = text[position];
|
||||
if (PathChar(next) || next == '.') {
|
||||
++position;
|
||||
} else if (next == '[') {
|
||||
const std::size_t close = text.find(']', position);
|
||||
if (close == std::string_view::npos) Fail("expected ']'");
|
||||
position = close + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string_view word = text.substr(start, position - start);
|
||||
if (word == "true") return { {}, Value(true) };
|
||||
if (word == "false") return { {}, Value(false) };
|
||||
if (word == "null") return { {}, Value() };
|
||||
paths.emplace_back(word);
|
||||
return { std::string(word), Value() };
|
||||
}
|
||||
|
||||
std::string String(char quote) {
|
||||
++position;
|
||||
std::string out;
|
||||
while (true) {
|
||||
if (AtEnd()) Fail("unterminated string");
|
||||
const char c = text[position++];
|
||||
if (c == quote) return out;
|
||||
if (c == '\\' && !AtEnd()) {
|
||||
out += text[position++];
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
|
||||
Value Number() {
|
||||
const std::size_t start = position;
|
||||
if (text[position] == '-') ++position;
|
||||
while (!AtEnd() && ((text[position] >= '0' && text[position] <= '9') || text[position] == '.' || text[position] == 'e' || text[position] == 'E'
|
||||
|| ((text[position] == '+' || text[position] == '-') && (text[position - 1] == 'e' || text[position - 1] == 'E')))) {
|
||||
++position;
|
||||
}
|
||||
try {
|
||||
return Value::Parse(text.substr(start, position - start));
|
||||
} catch (const ParseError&) {
|
||||
position = start;
|
||||
Fail("malformed number");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::expected<Value, std::string> Load(const Node::Operand& operand, const Condition::Resolver& resolve) {
|
||||
if (operand.path.empty()) return operand.literal;
|
||||
return resolve(operand.path);
|
||||
}
|
||||
|
||||
bool Truthy(const Value& value) {
|
||||
switch (value.Kind()) {
|
||||
case ValueKind::Null: return false;
|
||||
case ValueKind::Bool: return value.AsBool();
|
||||
case ValueKind::Integer: return value.AsInteger() != 0;
|
||||
case ValueKind::Number: return value.AsNumber() != 0;
|
||||
case ValueKind::String: return !value.AsString().empty();
|
||||
case ValueKind::Array:
|
||||
case ValueKind::Object: return value.Size() != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template<class T>
|
||||
bool Ordered(Node::Comparison comparison, const T& a, const T& b) {
|
||||
switch (comparison) {
|
||||
case Node::Comparison::Less: return a < b;
|
||||
case Node::Comparison::LessEqual: return a <= b;
|
||||
case Node::Comparison::Greater: return a > b;
|
||||
case Node::Comparison::GreaterEqual: return a >= b;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
Outcome Compare(const Node& node, const Value& a, const Value& b) {
|
||||
if (node.comparison == Node::Comparison::Equal) return a == b;
|
||||
if (node.comparison == Node::Comparison::NotEqual) return !(a == b);
|
||||
if (a.IsInteger() && b.IsInteger()) return Ordered(node.comparison, a.AsInteger(), b.AsInteger());
|
||||
if (a.IsNumber() && b.IsNumber()) return Ordered(node.comparison, a.AsNumber(), b.AsNumber());
|
||||
if (a.IsString() && b.IsString()) return Ordered(node.comparison, a.AsString(), b.AsString());
|
||||
return std::unexpected(std::format("cannot order {} against {} in '{}': {} needs two numbers or two strings", a.Dump(), b.Dump(), node.source,
|
||||
ComparisonText(node.comparison)));
|
||||
}
|
||||
|
||||
Outcome Evaluate(const Node& node, const Condition::Resolver& resolve) {
|
||||
switch (node.op) {
|
||||
case Node::Op::Or: {
|
||||
const Outcome left = Evaluate(*node.left, resolve);
|
||||
if (!left || *left) return left;
|
||||
return Evaluate(*node.right, resolve);
|
||||
}
|
||||
case Node::Op::And: {
|
||||
const Outcome left = Evaluate(*node.left, resolve);
|
||||
if (!left || !*left) return left;
|
||||
return Evaluate(*node.right, resolve);
|
||||
}
|
||||
case Node::Op::Not: {
|
||||
const Outcome inner = Evaluate(*node.left, resolve);
|
||||
if (!inner) return inner;
|
||||
return !*inner;
|
||||
}
|
||||
case Node::Op::Truthy: {
|
||||
const auto value = Load(node.a, resolve);
|
||||
if (!value) return std::unexpected(value.error());
|
||||
return Truthy(*value);
|
||||
}
|
||||
case Node::Op::Compare: {
|
||||
const auto a = Load(node.a, resolve);
|
||||
if (!a) return std::unexpected(a.error());
|
||||
const auto b = Load(node.b, resolve);
|
||||
if (!b) return std::unexpected(b.error());
|
||||
return Compare(node, *a, *b);
|
||||
}
|
||||
}
|
||||
return std::unexpected("unknown node");
|
||||
}
|
||||
}
|
||||
|
||||
ConditionError::ConditionError(std::size_t offset, std::string_view what)
|
||||
: std::runtime_error(std::format("{} at offset {}", what, offset)), offset(offset) {}
|
||||
|
||||
Condition Condition::Parse(std::string_view text) {
|
||||
Condition condition;
|
||||
condition.text = std::string(text);
|
||||
Parser parser(condition.text, condition.paths);
|
||||
condition.root = parser.Document();
|
||||
return condition;
|
||||
}
|
||||
|
||||
std::expected<bool, std::string> Condition::Evaluate(const Resolver& resolve) const {
|
||||
return ::Evaluate(*root, resolve);
|
||||
}
|
||||
301
implementations/Crafter.Debug-Registry.cpp
Normal file
301
implementations/Crafter.Debug-Registry.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Registry_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Registry;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
using namespace Crafter::Debug::Detail;
|
||||
|
||||
namespace {
|
||||
std::atomic<std::uint64_t> frame{ 0 };
|
||||
|
||||
// Points kept per sample path, enough for `trace` over a few seconds of
|
||||
// frames without an app logging every frame growing without bound.
|
||||
constexpr std::size_t sampleHistory = 512;
|
||||
|
||||
bool ValidName(std::string_view name) {
|
||||
return !name.empty() && std::ranges::none_of(name, [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; });
|
||||
}
|
||||
|
||||
// The registered name that `path` starts with, longest first, ending at a
|
||||
// step boundary: "bodies" for "bodies[3].pos", not "bod".
|
||||
template<class Map>
|
||||
auto LongestPrefix(const Map& map, std::string_view path) -> decltype(map.end()) {
|
||||
auto best = map.end();
|
||||
std::size_t bestLength = 0;
|
||||
for (auto it = map.begin(); it != map.end(); ++it) {
|
||||
const std::string_view name = it->first;
|
||||
if (!path.starts_with(name)) continue;
|
||||
if (path.size() > name.size() && path[name.size()] != '.' && path[name.size()] != '[') continue;
|
||||
if (name.size() >= bestLength) {
|
||||
best = it;
|
||||
bestLength = name.size();
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::vector<std::string_view> MemberNames(const Value& object) {
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [key, member] : object.Members()) names.push_back(key);
|
||||
return names;
|
||||
}
|
||||
|
||||
std::string JoinNames(std::span<const std::string_view> names) {
|
||||
std::string joined;
|
||||
for (const std::string_view name : names) {
|
||||
if (!joined.empty()) joined += ", ";
|
||||
joined += name;
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
// Follows `rest` (".name" and "[index]" steps) from `root`. `where` names
|
||||
// the root in errors. Returns nullptr and sets `error` when a step fails.
|
||||
template<class V>
|
||||
V* Walk(V& root, std::string_view rest, std::string where, std::string& error) {
|
||||
V* current = &root;
|
||||
while (!rest.empty()) {
|
||||
if (rest.front() == '.') {
|
||||
rest.remove_prefix(1);
|
||||
const std::size_t end = std::min(rest.find('.'), rest.find('['));
|
||||
const std::string_view name = rest.substr(0, end);
|
||||
rest.remove_prefix(name.size());
|
||||
if (!current->IsObject()) {
|
||||
error = std::format("'{}' is {}, so it has no member '{}'", where, KindName(current->Kind()), name);
|
||||
return nullptr;
|
||||
}
|
||||
V* next = current->Find(name);
|
||||
if (next == nullptr) {
|
||||
const std::vector<std::string_view> names = MemberNames(*current);
|
||||
const auto suggestion = Suggest(name, names);
|
||||
error = suggestion ? std::format("'{}' has no member '{}'; did you mean '{}'?", where, name, *suggestion)
|
||||
: std::format("'{}' has no member '{}' (members: {})", where, name, JoinNames(names));
|
||||
return nullptr;
|
||||
}
|
||||
where += '.';
|
||||
where += name;
|
||||
current = next;
|
||||
} else if (rest.front() == '[') {
|
||||
const std::size_t close = rest.find(']');
|
||||
std::size_t index = 0;
|
||||
const std::string_view digits = rest.substr(1, close == std::string_view::npos ? std::string_view::npos : close - 1);
|
||||
const auto [end, failure] = std::from_chars(digits.data(), digits.data() + digits.size(), index);
|
||||
if (close == std::string_view::npos || failure != std::errc() || end != digits.data() + digits.size()) {
|
||||
error = std::format("'{}' is followed by a malformed index: '{}'", where, rest);
|
||||
return nullptr;
|
||||
}
|
||||
rest.remove_prefix(close + 1);
|
||||
if (!current->IsArray()) {
|
||||
error = std::format("'{}' is {}, not an array", where, KindName(current->Kind()));
|
||||
return nullptr;
|
||||
}
|
||||
if (index >= current->Size()) {
|
||||
error = std::format("'{}' has {} items, so [{}] is out of range", where, current->Size(), index);
|
||||
return nullptr;
|
||||
}
|
||||
where += std::format("[{}]", index);
|
||||
current = ¤t->Items()[index];
|
||||
} else {
|
||||
error = std::format("cannot read '{}' after '{}'", rest, where);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t Crafter::Debug::Detail::CurrentFrame() {
|
||||
return frame.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Crafter::Debug::Detail::SetFrame(std::uint64_t value) {
|
||||
frame.store(value, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool Crafter::Debug::Detail::GlobMatch(std::string_view glob, std::string_view text) {
|
||||
std::size_t g = 0;
|
||||
std::size_t t = 0;
|
||||
std::size_t star = std::string_view::npos;
|
||||
std::size_t mark = 0;
|
||||
while (t < text.size()) {
|
||||
if (g < glob.size() && (glob[g] == '?' || glob[g] == text[t])) {
|
||||
++g;
|
||||
++t;
|
||||
} else if (g < glob.size() && glob[g] == '*') {
|
||||
star = g++;
|
||||
mark = t;
|
||||
} else if (star != std::string_view::npos) {
|
||||
g = star + 1;
|
||||
t = ++mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while (g < glob.size() && glob[g] == '*') ++g;
|
||||
return g == glob.size();
|
||||
}
|
||||
|
||||
std::string Crafter::Debug::Detail::Joined(std::string_view prefix, std::string_view name) {
|
||||
if (prefix.empty()) return std::string(name);
|
||||
if (name.empty()) return std::string(prefix);
|
||||
return std::format("{}.{}", prefix, name);
|
||||
}
|
||||
|
||||
Registry& Registry::Instance() {
|
||||
static Registry registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
std::uint64_t Registry::NewOwner() {
|
||||
return nextOwner.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Registry::AddCommand(CommandEntry entry) {
|
||||
if (!ValidName(entry.name)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a command name: it must be non-empty, without whitespace", entry.name));
|
||||
std::string name = entry.name;
|
||||
std::lock_guard lock(mutex);
|
||||
commands.insert_or_assign(std::move(name), std::make_shared<const CommandEntry>(std::move(entry)));
|
||||
}
|
||||
|
||||
void Registry::AddWatch(WatchEntry entry) {
|
||||
if (!ValidName(entry.path)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a watch path: it must be non-empty, without whitespace", entry.path));
|
||||
std::string path = entry.path;
|
||||
std::lock_guard lock(mutex);
|
||||
watches.insert_or_assign(std::move(path), std::make_shared<const WatchEntry>(std::move(entry)));
|
||||
}
|
||||
|
||||
void Registry::AddSample(std::string_view path, Value value, std::uint64_t sampleFrame, std::uint64_t owner) {
|
||||
std::lock_guard lock(mutex);
|
||||
auto found = samples.find(path);
|
||||
if (found == samples.end()) found = samples.emplace(std::string(path), Series{}).first;
|
||||
Series& series = found->second;
|
||||
series.owner = owner;
|
||||
series.points.push_back({ sampleFrame, std::move(value) });
|
||||
if (series.points.size() > sampleHistory) series.points.pop_front();
|
||||
}
|
||||
|
||||
void Registry::RemoveOwner(std::uint64_t owner) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::erase_if(commands, [&](const auto& item) { return item.second->owner == owner; });
|
||||
std::erase_if(watches, [&](const auto& item) { return item.second->owner == owner; });
|
||||
std::erase_if(samples, [&](const auto& item) { return item.second.owner == owner; });
|
||||
}
|
||||
|
||||
std::shared_ptr<const CommandEntry> Registry::FindCommand(std::string_view name) const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = commands.find(name);
|
||||
return found == commands.end() ? nullptr : found->second;
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<const CommandEntry>> Registry::Commands(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::shared_ptr<const CommandEntry>> matched;
|
||||
for (const auto& [name, entry] : commands) {
|
||||
if (GlobMatch(glob, name)) matched.push_back(entry);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<const WatchEntry>> Registry::Watches(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::shared_ptr<const WatchEntry>> matched;
|
||||
for (const auto& [path, entry] : watches) {
|
||||
if (GlobMatch(glob, path)) matched.push_back(entry);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<std::string> Registry::SamplePaths(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string> matched;
|
||||
for (const auto& [path, series] : samples) {
|
||||
if (GlobMatch(glob, path)) matched.push_back(path);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<SamplePoint> Registry::SampleHistory(std::string_view path) const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = samples.find(path);
|
||||
if (found == samples.end()) return {};
|
||||
return std::vector<SamplePoint>(found->second.points.begin(), found->second.points.end());
|
||||
}
|
||||
|
||||
Resolved Registry::Resolve(std::string_view path) const {
|
||||
std::shared_ptr<const WatchEntry> watch;
|
||||
std::optional<SamplePoint> latest;
|
||||
std::string base;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
const auto foundWatch = LongestPrefix(watches, path);
|
||||
const auto foundSample = LongestPrefix(samples, path);
|
||||
const std::size_t watchLength = foundWatch == watches.end() ? 0 : foundWatch->first.size();
|
||||
const std::size_t sampleLength = foundSample == samples.end() ? 0 : foundSample->first.size();
|
||||
if (foundWatch != watches.end() && watchLength >= sampleLength) {
|
||||
watch = foundWatch->second;
|
||||
base = foundWatch->first;
|
||||
} else if (foundSample != samples.end() && !foundSample->second.points.empty()) {
|
||||
latest = foundSample->second.points.back();
|
||||
base = foundSample->first;
|
||||
}
|
||||
}
|
||||
if (!watch && !latest) {
|
||||
const auto suggestion = SuggestPath(path);
|
||||
return { Value(), suggestion ? std::format("no watch or sample '{}'; did you mean '{}'?", path, *suggestion)
|
||||
: std::format("no watch or sample '{}'; list them with: crafter-debug get '*'", path) };
|
||||
}
|
||||
Value root = watch ? watch->get() : std::move(latest->value);
|
||||
std::string error;
|
||||
const Value* reached = Walk(std::as_const(root), path.substr(base.size()), base, error);
|
||||
if (reached == nullptr) return { Value(), error };
|
||||
return { *reached, {} };
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::Set(std::string_view path, const Value& value) const {
|
||||
std::shared_ptr<const WatchEntry> watch;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = LongestPrefix(watches, path);
|
||||
if (found != watches.end()) watch = found->second;
|
||||
}
|
||||
if (!watch) {
|
||||
const auto suggestion = SuggestPath(path);
|
||||
return suggestion ? std::format("no watch '{}'; did you mean '{}'?", path, *suggestion) : std::format("no watch '{}'", path);
|
||||
}
|
||||
if (!watch->set) return std::format("'{}' is read-only", watch->path);
|
||||
const std::string_view rest = path.substr(watch->path.size());
|
||||
if (rest.empty()) return watch->set(value);
|
||||
Value whole = watch->get();
|
||||
std::string error;
|
||||
Value* part = Walk(whole, rest, watch->path, error);
|
||||
if (part == nullptr) return error;
|
||||
*part = value;
|
||||
return watch->set(whole);
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::SuggestCommand(std::string_view name) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [command, entry] : commands) names.push_back(command);
|
||||
if (const auto suggestion = Suggest(name, names)) return std::string(*suggestion);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::SuggestPath(std::string_view path) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [watch, entry] : watches) names.push_back(watch);
|
||||
for (const auto& [sample, series] : samples) names.push_back(sample);
|
||||
if (const auto suggestion = Suggest(path, names)) return std::string(*suggestion);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Group::Group(std::string prefix) : prefix(std::move(prefix)), owner(Registry::Instance().NewOwner()) {}
|
||||
|
||||
Group::~Group() {
|
||||
Registry::Instance().RemoveOwner(owner);
|
||||
}
|
||||
131
implementations/Crafter.Debug-Timeline.cpp
Normal file
131
implementations/Crafter.Debug-Timeline.cpp
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Timeline_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Registry;
|
||||
import :Timeline;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
using namespace Crafter::Debug::Detail;
|
||||
|
||||
namespace {
|
||||
constexpr std::size_t capacity = 8192;
|
||||
|
||||
bool Stored(std::uint64_t failures) {
|
||||
return failures <= 10 || std::has_single_bit(failures);
|
||||
}
|
||||
}
|
||||
|
||||
std::string_view Crafter::Debug::EntryKindName(EntryKind kind) {
|
||||
switch (kind) {
|
||||
case EntryKind::Log: return "log";
|
||||
case EntryKind::Warn: return "warn";
|
||||
case EntryKind::Error: return "error";
|
||||
case EntryKind::Check: return "check";
|
||||
case EntryKind::Event: return "event";
|
||||
case EntryKind::Agent: return "agent";
|
||||
case EntryKind::Pause: return "pause";
|
||||
case EntryKind::Stdio: return "stdio";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::uint8_t Crafter::Debug::Detail::Level(EntryKind kind) {
|
||||
switch (kind) {
|
||||
case EntryKind::Warn: return 1;
|
||||
case EntryKind::Error:
|
||||
case EntryKind::Check: return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
TimelineEntry Crafter::Debug::Detail::MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload) {
|
||||
TimelineEntry entry;
|
||||
entry.kind = kind;
|
||||
entry.category = std::string(category);
|
||||
entry.message = std::move(message);
|
||||
entry.file = location.file_name();
|
||||
entry.line = location.line();
|
||||
entry.payload = std::move(payload);
|
||||
return entry;
|
||||
}
|
||||
|
||||
Timeline& Timeline::Instance() {
|
||||
static Timeline timeline;
|
||||
return timeline;
|
||||
}
|
||||
|
||||
std::uint64_t Timeline::Append(TimelineEntry entry) {
|
||||
entry.frame = CurrentFrame();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
entry.sequence = nextSequence++;
|
||||
entries.push_back(entry);
|
||||
if (entries.size() > capacity) entries.pop_front();
|
||||
}
|
||||
Notify(entry);
|
||||
return entry.sequence;
|
||||
}
|
||||
|
||||
void Timeline::RecordCheck(std::string_view category, std::string message, const std::source_location& location) {
|
||||
TimelineEntry entry = MakeEntry(EntryKind::Check, category, std::move(message), location);
|
||||
entry.frame = CurrentFrame();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
SiteCount& site = checks[{ entry.file, entry.line }];
|
||||
site.category = entry.category;
|
||||
entry.count = ++site.failures;
|
||||
if (Stored(entry.count)) {
|
||||
entry.sequence = nextSequence++;
|
||||
entries.push_back(entry);
|
||||
if (entries.size() > capacity) entries.pop_front();
|
||||
}
|
||||
}
|
||||
Notify(entry);
|
||||
}
|
||||
|
||||
std::vector<TimelineEntry> Timeline::Query(const TimelineQuery& query) const {
|
||||
std::vector<TimelineEntry> matched;
|
||||
std::lock_guard lock(mutex);
|
||||
for (const TimelineEntry& entry : entries) {
|
||||
if (entry.sequence <= query.afterSequence) continue;
|
||||
if (query.sinceFrame && entry.frame < *query.sinceFrame) continue;
|
||||
if (Level(entry.kind) < query.minLevel) continue;
|
||||
if (!query.kinds.empty() && std::ranges::find(query.kinds, entry.kind) == query.kinds.end()) continue;
|
||||
if (!query.category.empty() && !GlobMatch(query.category, entry.category)) continue;
|
||||
if (!query.grep.empty() && entry.message.find(query.grep) == std::string::npos && entry.category.find(query.grep) == std::string::npos) continue;
|
||||
matched.push_back(entry);
|
||||
}
|
||||
if (query.last != 0 && matched.size() > query.last) matched.erase(matched.begin(), matched.end() - static_cast<std::ptrdiff_t>(query.last));
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::uint64_t Timeline::LastSequence() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return nextSequence - 1;
|
||||
}
|
||||
|
||||
std::vector<CheckSite> Timeline::CheckSites() const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<CheckSite> sites;
|
||||
for (const auto& [site, count] : checks) sites.push_back({ site.first, site.second, count.category, count.failures });
|
||||
return sites;
|
||||
}
|
||||
|
||||
void Timeline::SetListener(std::function<void(const TimelineEntry&)> callback) {
|
||||
std::lock_guard lock(mutex);
|
||||
listener = callback ? std::make_shared<const std::function<void(const TimelineEntry&)>>(std::move(callback)) : nullptr;
|
||||
}
|
||||
|
||||
void Timeline::Notify(const TimelineEntry& entry) const {
|
||||
std::shared_ptr<const std::function<void(const TimelineEntry&)>> current;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
current = listener;
|
||||
}
|
||||
if (current) (*current)(entry);
|
||||
}
|
||||
Loading…
Reference in a new issue