diff --git a/implementations/Crafter.Debug-Condition.cpp b/implementations/Crafter.Debug-Condition.cpp new file mode 100644 index 0000000..391591a --- /dev/null +++ b/implementations/Crafter.Debug-Condition.cpp @@ -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 left; + std::shared_ptr 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; + + // 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& paths) : text(text), paths(paths) {} + + std::shared_ptr Document() { + auto node = Or(0); + Skip(); + if (!AtEnd()) Fail("unexpected text; join conditions with && or ||"); + return node; + } + + private: + std::string_view text; + std::vector& 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 Combine(Node::Op op, std::shared_ptr left, std::shared_ptr right) { + auto node = std::make_shared(); + node->op = op; + node->left = std::move(left); + node->right = std::move(right); + return node; + } + + std::shared_ptr 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 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 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 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->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 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 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 + 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 Condition::Evaluate(const Resolver& resolve) const { + return ::Evaluate(*root, resolve); +} diff --git a/implementations/Crafter.Debug-Registry.cpp b/implementations/Crafter.Debug-Registry.cpp new file mode 100644 index 0000000..9b09eb8 --- /dev/null +++ b/implementations/Crafter.Debug-Registry.cpp @@ -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 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 + 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 MemberNames(const Value& object) { + std::vector names; + for (const auto& [key, member] : object.Members()) names.push_back(key); + return names; + } + + std::string JoinNames(std::span 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 + 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 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(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(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 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> Registry::Commands(std::string_view glob) const { + std::lock_guard lock(mutex); + std::vector> matched; + for (const auto& [name, entry] : commands) { + if (GlobMatch(glob, name)) matched.push_back(entry); + } + return matched; +} + +std::vector> Registry::Watches(std::string_view glob) const { + std::lock_guard lock(mutex); + std::vector> matched; + for (const auto& [path, entry] : watches) { + if (GlobMatch(glob, path)) matched.push_back(entry); + } + return matched; +} + +std::vector Registry::SamplePaths(std::string_view glob) const { + std::lock_guard lock(mutex); + std::vector matched; + for (const auto& [path, series] : samples) { + if (GlobMatch(glob, path)) matched.push_back(path); + } + return matched; +} + +std::vector 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(found->second.points.begin(), found->second.points.end()); +} + +Resolved Registry::Resolve(std::string_view path) const { + std::shared_ptr watch; + std::optional 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 Registry::Set(std::string_view path, const Value& value) const { + std::shared_ptr 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 Registry::SuggestCommand(std::string_view name) const { + std::lock_guard lock(mutex); + std::vector 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 Registry::SuggestPath(std::string_view path) const { + std::lock_guard lock(mutex); + std::vector 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); +} diff --git a/implementations/Crafter.Debug-Timeline.cpp b/implementations/Crafter.Debug-Timeline.cpp new file mode 100644 index 0000000..c09ed61 --- /dev/null +++ b/implementations/Crafter.Debug-Timeline.cpp @@ -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(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(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 Timeline::Query(const TimelineQuery& query) const { + std::vector 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(query.last)); + return matched; +} + +std::uint64_t Timeline::LastSequence() const { + std::lock_guard lock(mutex); + return nextSequence - 1; +} + +std::vector Timeline::CheckSites() const { + std::lock_guard lock(mutex); + std::vector 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 callback) { + std::lock_guard lock(mutex); + listener = callback ? std::make_shared>(std::move(callback)) : nullptr; +} + +void Timeline::Notify(const TimelineEntry& entry) const { + std::shared_ptr> current; + { + std::lock_guard lock(mutex); + current = listener; + } + if (current) (*current)(entry); +} diff --git a/interfaces/Crafter.Debug-Condition.cppm b/interfaces/Crafter.Debug-Condition.cppm new file mode 100644 index 0000000..a18b49e --- /dev/null +++ b/interfaces/Crafter.Debug-Condition.cppm @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The conditions `crafter-debug wait` and `pause --when` take: comparisons of +// watch paths with literals and with each other, combined with && || ! and +// parentheses. +// +// fort1.core.hp <= 0 +// scene == "Game" && !debug.paused +// bodies[3].pos.y < -10 || (lives == 0 && score > highScore) +// +// A lone operand tests truthiness: true, a nonzero number, a non-empty +// string, array or object. Numbers compare by value, strings in byte order; +// == and != work on any two values, and ordering a string against a number +// is an error rather than false, so a typo in a condition is reported +// instead of waiting forever. Parsed once when it arrives, evaluated on the +// Pump thread every frame. +export module Crafter.Debug:Condition; +import std; +import Crafter.Debug.Core; + +export namespace Crafter::Debug { + class ConditionError : public std::runtime_error { + public: + std::size_t offset; + ConditionError(std::size_t offset, std::string_view what); + }; + + class Condition { + public: + // Throws ConditionError, with the offset of the problem. + static Condition Parse(std::string_view text); + + // Looks a path up: a value, or why there is none. + using Resolver = std::function(std::string_view path)>; + // True or false, or why it cannot be decided (an unknown path, or + // operands that cannot be ordered). + std::expected Evaluate(const Resolver& resolve) const; + + const std::string& Text() const { return text; } + // The paths the condition reads, in order of appearance. + std::span Paths() const { return paths; } + + struct Node; + + private: + std::string text; + std::shared_ptr root; + std::vector paths; + }; +} diff --git a/interfaces/Crafter.Debug-Registry.cppm b/interfaces/Crafter.Debug-Registry.cppm new file mode 100644 index 0000000..a7a79f6 --- /dev/null +++ b/interfaces/Crafter.Debug-Registry.cppm @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// What an app offers the agent: commands it can call, watches it can read and +// set, and samples it can trace. +// +// struct AimArgs { Debug::Required slot; float yaw = 0; float pitch = 0; }; +// Debug::Command("Player_SetAim", "Aim the slot's turret, in degrees.", +// [](const AimArgs& a) -> Debug::Result<> { ... }); +// Debug::Watch("physics.gravity", Physics::gravity, { .min = -50, .max = 0, .help = "m/s^2" }); +// Debug::Sample("xpbd.residual", residual); +// +// A command handler takes nothing, or one argument type that has a Codec (an +// aggregate, usually, or Value for raw JSON), and returns void, a value with +// a Codec, Result or Async. Arguments are decoded before the handler +// runs, so a mistyped call is answered with every mistake in it and never +// reaches the handler. Free functions register for the program's lifetime; +// a Group registers under a prefix and unregisters with its owner. +// +// Thread safety: registering, unregistering and listing are safe from any +// thread. Handlers, getters and setters are only ever called by the session, +// on the thread that calls Pump, so they read app state without locking. +// Sample may be called from any thread. +export module Crafter.Debug:Registry; +import std; +import Crafter.Debug.Core; + +export namespace Crafter::Debug { + struct WatchOptions { + // Bounds a set must respect, for arithmetic watches. + std::optional min; + std::optional max; + // Shown by `crafter-debug help` and `get`: units, meaning. + std::string help; + // A watch of a variable is writable unless this is set. + bool readOnly = false; + }; +} + +// The type-erased registry. Not for app code: the templates below fill it. +export namespace Crafter::Debug::Detail { + // What calling a handler produced: a result now, or a poller that the + // session calls once per Pump until it yields (the handler returned + // an Async). + struct CommandOutcome { + std::optional> done; + std::function>()> pending; + }; + + struct CommandEntry { + std::string name; + std::string description; + // Describe() of the argument type, or null for a command that takes none. + Value args; + // Describe() of the result type, or null for one that returns nothing. + Value returns; + std::function invoke; + std::uint64_t owner = 0; + }; + + struct WatchEntry { + std::string path; + Value type; + std::string help; + std::function get; + // Empty for a read-only watch. Returns an error message, or nothing. + std::function(const Value& value)> set; + std::uint64_t owner = 0; + }; + + struct SamplePoint { + std::uint64_t frame; + Value value; + }; + + // A path resolved to a value: from a watch's getter or a sample's + // latest point, then into it along whatever the path names past the + // watch ("bodies[3].pos" against the watch "bodies"). + struct Resolved { + Value value; + std::string error; + bool Ok() const { return error.empty(); } + }; + + class Registry { + public: + static Registry& Instance(); + + std::uint64_t NewOwner(); + void AddCommand(CommandEntry entry); + void AddWatch(WatchEntry entry); + void AddSample(std::string_view path, Value value, std::uint64_t frame, std::uint64_t owner); + void RemoveOwner(std::uint64_t owner); + + std::shared_ptr FindCommand(std::string_view name) const; + // Sorted by name; `glob` filters ("*" any run, "?" one character). + std::vector> Commands(std::string_view glob = "*") const; + std::vector> Watches(std::string_view glob = "*") const; + std::vector SamplePaths(std::string_view glob = "*") const; + std::vector SampleHistory(std::string_view path) const; + + // Call on the thread that calls Pump: it runs getters. + Resolved Resolve(std::string_view path) const; + // Sets the watch the path names, or a part of its value: "fort1.core.pos.x" + // reads the watch "fort1.core.pos", replaces x and writes it back. + // Returns an error message, or nothing. Call on the Pump thread. + std::optional Set(std::string_view path, const Value& value) const; + + // The closest known command, watch or sample name to `name`, for + // "did you mean" in errors. + std::optional SuggestCommand(std::string_view name) const; + std::optional SuggestPath(std::string_view path) const; + + private: + mutable std::mutex mutex; + std::map, std::less<>> commands; + std::map, std::less<>> watches; + struct Series { + std::deque points; + std::uint64_t owner = 0; + }; + std::map> samples; + std::atomic nextOwner{ 1 }; + }; + + // "*" matches any run of characters, "?" any one. + bool GlobMatch(std::string_view glob, std::string_view text); + + // The frame Sample and the timeline stamp what they record with; the + // session sets it each Pump. + std::uint64_t CurrentFrame(); + void SetFrame(std::uint64_t frame); + + template + struct FunctionTraits; + + template + struct FunctionTraits> { + using Return = R; + static constexpr std::size_t arity = sizeof...(A); + template + using Arg = std::tuple_element_t>; + }; + + // The value type a handler's return produces: void, T, Result, Async. + template + struct Produced { + using Type = R; + }; + template + struct Produced> { + using Type = T; + }; + template + struct Produced> { + using Type = T; + }; + + template + inline constexpr bool isAsync = false; + template + inline constexpr bool isAsync> = true; + + template + inline constexpr bool isResult = false; + template + inline constexpr bool isResult> = true; + + inline CommandOutcome Fail(std::string code, std::string message) { + return CommandOutcome{ .done = Result(Error{ std::move(code), std::move(message) }), .pending = {} }; + } + + template + Result ToValue(Result&& result) { + if (!result.Ok()) return result.GetError(); + if constexpr (std::is_void_v) { + return Value(); + } else { + return Debug::Encode(result.Get()); + } + } + + // Calls the handler through `call`, turning whatever it returns, or + // throws, into an outcome. A handler's exception must not unwind into + // the app's frame loop. + template + CommandOutcome Run(Call&& call) { + using R = std::invoke_result_t; + try { + if constexpr (std::is_void_v) { + call(); + return CommandOutcome{ .done = Result(Value()), .pending = {} }; + } else if constexpr (isAsync) { + using T = typename Produced::Type; + return CommandOutcome{ + .done = std::nullopt, + .pending = [async = call()]() mutable -> std::optional> { + std::optional> polled = async.Poll(); + if (!polled) return std::nullopt; + return ToValue(std::move(*polled)); + }, + }; + } else if constexpr (isResult) { + return CommandOutcome{ .done = ToValue(call()), .pending = {} }; + } else { + return CommandOutcome{ .done = Result(Debug::Encode(call())), .pending = {} }; + } + } catch (const std::exception& error) { + return Fail("Exception", std::format("the handler threw: {}", error.what())); + } catch (...) { + return Fail("Exception", "the handler threw something that is not a std::exception"); + } + } + + template + CommandEntry MakeCommand(std::string name, std::string_view description, F handler, std::uint64_t owner) { + using Traits = FunctionTraits; + static_assert(Traits::arity <= 1, "a command handler takes no parameters, or one argument struct"); + using R = typename Traits::Return; + using P = typename Produced::Type; + CommandEntry entry; + entry.name = std::move(name); + entry.description = std::string(description); + entry.owner = owner; + if constexpr (!std::is_void_v

) entry.returns = Debug::Describe>(); + if constexpr (Traits::arity == 0) { + entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome { + if (!params.IsNull() && !(params.IsObject() && params.Size() == 0)) { + return Fail("InvalidParams", "this command takes no arguments"); + } + return Run([&] { return handler(); }); + }; + } else { + using Args = std::remove_cvref_t>; + entry.args = Debug::Describe(); + entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome { + Args args{}; + DecodeContext context; + if constexpr (Aggregate) { + Debug::Decode(params.IsNull() ? Value::MakeObject() : params, args, context); + } else { + Debug::Decode(params, args, context); + } + if (!context.Ok()) return Fail("InvalidParams", context.Report()); + return Run([&] { return handler(args); }); + }; + } + return entry; + } + + template + std::optional CheckBounds(const T& value, const WatchOptions& options) { + if constexpr (std::is_arithmetic_v && !std::is_same_v) { + const double number = static_cast(value); + if ((options.min && number < *options.min) || (options.max && number > *options.max)) { + return std::format("{} is outside {}..{}", number, options.min ? std::format("{}", *options.min) : std::string(), + options.max ? std::format("{}", *options.max) : std::string()); + } + } + return std::nullopt; + } + + template + Value WatchType(const WatchOptions& options) { + Value type = Debug::Describe(); + if (options.min) type.Set("minimum", Value(*options.min)); + if (options.max) type.Set("maximum", Value(*options.max)); + return type; + } + + template + WatchEntry MakeVariableWatch(std::string path, T& variable, const WatchOptions& options, std::uint64_t owner) { + WatchEntry entry; + entry.path = std::move(path); + entry.type = WatchType(options); + entry.help = options.help; + entry.owner = owner; + entry.get = [&variable] { return Debug::Encode(variable); }; + if constexpr (!std::is_const_v) { + if (!options.readOnly) { + entry.set = [&variable, options](const Value& value) -> std::optional { + T decoded = variable; + DecodeContext context; + Debug::Decode(value, decoded, context); + if (!context.Ok()) return context.Report(); + if (auto bounds = CheckBounds(decoded, options)) return bounds; + variable = std::move(decoded); + return std::nullopt; + }; + } + } + return entry; + } + + template + WatchEntry MakeComputedWatch(std::string path, G getter, std::optional setter, const WatchOptions& options, std::uint64_t owner) { + using T = std::remove_cvref_t>; + WatchEntry entry; + entry.path = std::move(path); + entry.type = WatchType(options); + entry.help = options.help; + entry.owner = owner; + entry.get = [getter]() mutable { return Debug::Encode(getter()); }; + if (setter && !options.readOnly) { + entry.set = [getter, setter = std::move(*setter), options](const Value& value) mutable -> std::optional { + T decoded = getter(); + DecodeContext context; + Debug::Decode(value, decoded, context); + if (!context.Ok()) return context.Report(); + if (auto bounds = CheckBounds(decoded, options)) return bounds; + using SR = std::invoke_result_t; + if constexpr (std::is_void_v) { + setter(decoded); + } else { + static_assert(std::is_same_v>, "a watch setter returns void or Debug::Result<>"); + Result<> result = setter(decoded); + if (!result.Ok()) return std::format("{}: {}", result.GetError().code, result.GetError().message); + } + return std::nullopt; + }; + } + return entry; + } + + std::string Joined(std::string_view prefix, std::string_view name); + + struct NoSetter { + template + void operator()(const T&) const {} + }; +} + +export namespace Crafter::Debug { + // Registers a command for the program's lifetime. See the comment at the top. + template + void Command(std::string_view name, std::string_view description, F&& handler) { + Detail::Registry::Instance().AddCommand(Detail::MakeCommand(std::string(name), description, std::forward(handler), 0)); + } + + // Watches a variable: `get` reads it and `set` writes it, on the Pump + // thread. The variable must outlive the program, or use a Group. + template + requires (!std::invocable) + void Watch(std::string_view path, T& variable, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(std::string(path), variable, options, 0)); + } + + // Watches what a getter returns; read-only. + template + requires std::invocable + void Watch(std::string_view path, G getter, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional(), options, 0)); + } + + // Watches a getter and writes through a setter taking the getter's type + // and returning void or Result<>. + template + requires std::invocable + void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional(std::move(setter)), options, 0)); + } + + // Records a value under `path`, stamped with the current frame, from any + // thread: for locals in hot code that no watch can reach. `get` reads the + // latest, `trace` the history. + template + void Sample(std::string_view path, const T& value) { + Detail::Registry::Instance().AddSample(path, Debug::Encode(value), Detail::CurrentFrame(), 0); + } + + // Registrations under a prefix that end with the Group: hold one as a + // member of what the commands and watches reach into. + // + // class Fort { + // Core core; + // Debug::Group debug; + // public: + // explicit Fort(std::int32_t slot) : debug(std::format("fort{}", slot)) { + // debug.Watch("core.hp", core.hp); + // } + // }; + // + // Neither copyable nor movable, and so neither is a class that holds one: + // its watches refer to the members they were given, and a moved object + // would leave them pointing at the old one. Keep such objects in place + // (std::unique_ptr, std::deque, std::list) rather than in a std::vector. + class Group { + public: + explicit Group(std::string prefix); + ~Group(); + Group(const Group&) = delete; + Group& operator=(const Group&) = delete; + + const std::string& Prefix() const { return prefix; } + + template + void Command(std::string_view name, std::string_view description, F&& handler) { + Detail::Registry::Instance().AddCommand(Detail::MakeCommand(Detail::Joined(prefix, name), description, std::forward(handler), owner)); + } + + template + requires (!std::invocable) + void Watch(std::string_view path, T& variable, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(Detail::Joined(prefix, path), variable, options, owner)); + } + + template + requires std::invocable + void Watch(std::string_view path, G getter, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional(), options, owner)); + } + + template + requires std::invocable + void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) { + Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional(std::move(setter)), options, owner)); + } + + template + void Sample(std::string_view path, const T& value) { + Detail::Registry::Instance().AddSample(Detail::Joined(prefix, path), Debug::Encode(value), Detail::CurrentFrame(), owner); + } + + private: + std::string prefix; + std::uint64_t owner = 0; + }; +} diff --git a/interfaces/Crafter.Debug-Timeline.cppm b/interfaces/Crafter.Debug-Timeline.cppm new file mode 100644 index 0000000..c981e7d --- /dev/null +++ b/interfaces/Crafter.Debug-Timeline.cppm @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The timeline: what the app logged, the checks that failed, the events it +// emitted, and what the agent did, in one sequence stamped with frame +// numbers and source locations, so `crafter-debug log` shows cause next to +// effect: +// +// 1790 AGENT call Player_Fire {} +// 1838 WARN physics brace 17 strain 4.2 > limit Forts3D-Brace.cpp:212 +// +// Debug::Log("net", "peer {} joined slot {}", peerId, slot); // also LogWarn, LogError +// Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 }); +// if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return; +// +// The format string is checked at compile time, as std::format's is, and the +// caller's location is captured without a macro (macros don't cross +// `import`). Every function here is safe from any thread. The timeline +// keeps the last 8192 entries. +export module Crafter.Debug:Timeline; +import std; +import Crafter.Debug.Core; +import :Registry; + +export namespace Crafter::Debug { + enum class EntryKind : std::uint8_t { + Log, + Warn, + Error, + Check, + Event, + Agent, + Pause, + Stdio, + }; + + // "log", "warn", "error", "check", "event", "agent", "pause", "stdio". + std::string_view EntryKindName(EntryKind kind); + + struct TimelineEntry { + // 0 for a check failure that was counted but not stored (see Check). + std::uint64_t sequence = 0; + std::uint64_t frame = 0; + // Since the timeline started, on the steady clock. + double seconds = 0; + EntryKind kind = EntryKind::Log; + // The log category, or an event's name. + std::string category; + std::string message; + std::string file; + std::uint32_t line = 0; + // An event's payload. + Value payload; + // For a check: its site's failures so far, this one included. + std::uint64_t count = 1; + }; + + // A format string and where it was written, captured at the call site. + template + struct LocFmt { + std::format_string format; + std::source_location location; + + template + requires std::convertible_to + consteval LocFmt(const S& text, std::source_location where = std::source_location::current()) : format(text), location(where) {} + }; +} + +export namespace Crafter::Debug::Detail { + // 0 for information (log, event, agent, pause, stdio), 1 for warn, 2 for + // error and check: what `log --level` compares against. + std::uint8_t Level(EntryKind kind); + + struct TimelineQuery { + // Only entries after this sequence number: what a follower has seen. + std::uint64_t afterSequence = 0; + std::optional sinceFrame; + // The newest this many of the matches; 0 for all of them. + std::size_t last = 0; + std::uint8_t minLevel = 0; + // A glob on the category; empty for any. + std::string category; + // A substring of the message or the category; empty for any. + std::string grep; + // Empty for any kind. + std::vector kinds; + }; + + struct CheckSite { + std::string file; + std::uint32_t line = 0; + std::string category; + std::uint64_t failures = 0; + }; + + class Timeline { + public: + static Timeline& Instance(); + + // Stamps the entry with its sequence, frame and time, stores it and + // tells the listener. Returns its sequence number. + std::uint64_t Append(TimelineEntry entry); + // A failed Check. Counted per site; stored while the site has failed + // at most ten times and then at every power of two, so a check that + // fails every frame cannot push everything else out. The listener + // hears every failure, stored or not. + void RecordCheck(std::string_view category, std::string message, const std::source_location& location); + + std::vector Query(const TimelineQuery& query) const; + std::uint64_t LastSequence() const; + std::vector CheckSites() const; + + // Called with every stored entry and every check failure, on the + // thread that recorded it, after the timeline's lock is released: + // the session uses it to pause on a check and to wake followers. + void SetListener(std::function listener); + + private: + mutable std::mutex mutex; + std::deque entries; + std::uint64_t nextSequence = 1; + struct SiteCount { + std::string category; + std::uint64_t failures = 0; + }; + std::map, SiteCount> checks; + std::shared_ptr> listener; + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + + void Notify(const TimelineEntry& entry) const; + }; + + TimelineEntry MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload = {}); +} + +export namespace Crafter::Debug { + template + void Log(std::string_view category, LocFmt...> format, A&&... args) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Log, category, std::format(format.format, std::forward(args)...), format.location)); + } + + template + void LogWarn(std::string_view category, LocFmt...> format, A&&... args) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Warn, category, std::format(format.format, std::forward(args)...), format.location)); + } + + template + void LogError(std::string_view category, LocFmt...> format, A&&... args) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Error, category, std::format(format.format, std::forward(args)...), format.location)); + } + + // A soft assert: returns `ok`, and when it is false records the failure + // (the message is only formatted then, so a Check in hot code is cheap). + // An agent can make a failure pause the app (`pause --on-check`) or stop + // it in an attached debugger (`--trap`). + template + bool Check(bool ok, std::string_view category, LocFmt...> format, A&&... args) { + if (!ok) Detail::Timeline::Instance().RecordCheck(category, std::format(format.format, std::forward(args)...), format.location); + return ok; + } + + // A structured event, which `crafter-debug wait --event ` waits for. + template + void Emit(std::string_view event, const T& payload, std::source_location location = std::source_location::current()) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location, Debug::Encode(payload))); + } + + inline void Emit(std::string_view event, std::source_location location = std::source_location::current()) { + Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location)); + } +} diff --git a/interfaces/Crafter.Debug.cppm b/interfaces/Crafter.Debug.cppm index b98a747..b69e9a1 100644 --- a/interfaces/Crafter.Debug.cppm +++ b/interfaces/Crafter.Debug.cppm @@ -1,11 +1,15 @@ // SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -// 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. +// Runtime debugging for AI agents: what an app imports. Commands, watches +// and samples (:Registry), the timeline with Log, Check and Emit +// (:Timeline), and the conditions waits and pauses use (:Condition), over +// the core shared with the client. The session follows in a later phase. export module Crafter.Debug; export import Crafter.Debug.Core; +export import :Registry; +export import :Timeline; +export import :Condition; import std; export namespace Crafter::Debug { diff --git a/project.cpp b/project.cpp index 24b0d02..905ed69 100644 --- a/project.cpp +++ b/project.cpp @@ -27,12 +27,21 @@ namespace { return fs::path(std::source_location::current().file_name()).parent_path(); } - constexpr std::array tests = { + // Tests of the core, linked against it alone. + constexpr std::array coreTests = { "ShouldRoundTripJson", "ShouldRoundTripAggregates", "ShouldReportDecodeErrorsWithPaths", "ShouldDescribeDefaultsAndRequired", }; + + // Tests of the app library. + constexpr std::array libraryTests = { + "ShouldInvokeCommands", + "ShouldResolveWatchPaths", + "ShouldRecordTimeline", + "ShouldEvaluateConditions", + }; } extern "C" Configuration CrafterBuildProject(std::span args) { @@ -85,15 +94,25 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.dependencies = { &core, &client }; { - std::array interfaces = { "interfaces/Crafter.Debug" }; - std::array implementations = {}; + std::array interfaces = { + "interfaces/Crafter.Debug-Registry", + "interfaces/Crafter.Debug-Timeline", + "interfaces/Crafter.Debug-Condition", + "interfaces/Crafter.Debug", + }; + std::array implementations = { + "implementations/Crafter.Debug-Registry", + "implementations/Crafter.Debug-Timeline", + "implementations/Crafter.Debug-Condition", + }; 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 }); + for (const std::string_view test : coreTests) cfg.AddTest(test).Dependencies({ &core }); + for (const std::string_view test : libraryTests) cfg.AddTest(test).Dependencies({ &cfg }); } return cfg; } diff --git a/tests/ShouldEvaluateConditions/main.cpp b/tests/ShouldEvaluateConditions/main.cpp new file mode 100644 index 0000000..4f79567 --- /dev/null +++ b/tests/ShouldEvaluateConditions/main.cpp @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The conditions `wait` and `pause --when` take: comparisons, && || ! and +// parentheses with the usual precedence, truthiness of a lone operand, +// strings in either quote, paths with indices, and short-circuiting. A +// condition that cannot be decided says why instead of being false, since a +// silently false condition makes `wait` hang: an unknown path, ordering a +// string against a number. Malformed text is refused with its offset, and a +// lone = is caught as the typo it is. +import std; +import Crafter.Debug; +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; + } + + std::expected Resolve(std::string_view path) { + static const Value state = Value::Parse(R"({"hp": 0, "scene": "Game", "paused": false, "bodies": [{"y": 2}, {"y": -12}], "name": "", "lives": 3})"); + if (path.starts_with("bodies[")) { + const std::size_t index = static_cast(path[7] - '0'); + return state.Find("bodies")->Items()[index].Find("y") ? *state.Find("bodies")->Items()[index].Find("y") : Value(); + } + if (const Value* value = state.Find(path)) return *value; + return std::unexpected(std::format("no watch '{}'", path)); + } + + std::expected Evaluate(std::string_view text) { + return Condition::Parse(text).Evaluate(Resolve); + } + + bool Is(std::string_view text, bool expected) { + const auto result = Evaluate(text); + return result.has_value() && *result == expected; + } + + std::size_t RefusedAt(std::string_view text) { + try { + (void)Condition::Parse(text); + } catch (const ConditionError& error) { + return error.offset; + } + return std::string_view::npos; + } +} + +int main() { + Check(Is("hp <= 0", true), "a comparison with a literal"); + Check(Is("scene == \"Game\" && !paused", true), "&& and ! combine"); + Check(Is("scene == 'Menu' || lives >= 3", true), "|| and single-quoted strings"); + Check(Is("hp > 0 || lives > 5", false), "false || false is false"); + Check(Is("!(hp == 0 && lives == 3)", false), "parentheses group"); + Check(Is("hp == 1 || hp == 0 && lives == 4", false), "&& binds tighter than ||"); + Check(Is("bodies[1].y < -10", true), "a path with an index, against a negative number"); + Check(Is("lives", true) && Is("name", false) && Is("paused", false), "a lone operand tests truthiness"); + Check(Is("lives == 3.0", true), "an integer equals the same number"); + Check(Is("hp > 0 && missing == 1", false), "&& short-circuits past an unknown path"); + + const auto unknown = Evaluate("missing > 1"); + Check(!unknown && unknown.error() == "no watch 'missing'", "an unknown path is an error, not false"); + const auto ordering = Evaluate("scene < 3"); + Check(!ordering && ordering.error().find("cannot order") != std::string::npos && ordering.error().find("scene < 3") != std::string::npos, + "ordering a string against a number is an error that quotes the comparison"); + Check(Is("scene == 3", false), "== between different kinds is false, not an error"); + + Check(RefusedAt("hp = 0") == 3, "a lone = is refused where it stands"); + Check(RefusedAt("(hp == 0") == 8, "an unclosed parenthesis is refused at the end"); + Check(RefusedAt("hp == 0 lives") == 8, "two conditions without && or || are refused"); + Check(RefusedAt("hp == ") == 6, "a missing operand is refused"); + + const Condition parsed = Condition::Parse("fort1.core.hp <= 0 && bodies[3].pos.y > 1"); + Check(parsed.Paths().size() == 2 && parsed.Paths()[0] == "fort1.core.hp" && parsed.Paths()[1] == "bodies[3].pos.y", "a condition lists the paths it reads"); + + return failures == 0 ? 0 : 1; +} diff --git a/tests/ShouldInvokeCommands/main.cpp b/tests/ShouldInvokeCommands/main.cpp new file mode 100644 index 0000000..f1c3bee --- /dev/null +++ b/tests/ShouldInvokeCommands/main.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Commands registered with Debug::Command are found by name and invoked with +// JSON parameters decoded into their argument struct: defaults fill what the +// call leaves out, a bad call is refused with InvalidParams before the +// handler runs, a throwing handler becomes an Exception error instead of +// unwinding into the app, Result errors pass through with their code, an +// Async result is polled until it yields, and a Group's commands disappear +// with the Group. Guards what replaces 3DForts' hand-written dispatch table. +import std; +import Crafter.Debug; +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; + } + + struct AimArgs { + Required slot; + float yaw = 10; + float pitch = 0; + }; + + struct Aimed { + std::int32_t slot = 0; + float yaw = 0; + }; + + Detail::CommandOutcome Call(std::string_view name, std::string_view params) { + const auto command = Detail::Registry::Instance().FindCommand(name); + if (!command) throw std::runtime_error(std::format("no command {}", name)); + return command->invoke(Value::Parse(params)); + } + + bool Failed(const Detail::CommandOutcome& outcome, std::string_view code) { + return outcome.done && !outcome.done->Ok() && outcome.done->GetError().code == code; + } +} + +int main() { + std::int32_t fired = 0; + Command("Player_Fire", "Fire the weapon.", [&] { ++fired; }); + Command("Player_SetAim", "Aim the turret, in degrees.", [](const AimArgs& args) -> Result { + if (*args.slot < 0) return Error{ "NoSlot", "slots start at 0" }; + return Aimed{ *args.slot, args.yaw }; + }); + Command("Game_Score", "The score.", [] { return 42; }); + Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); }); + std::int32_t polls = 0; + Command("Sim_StateHash", "A hash that takes frames.", [&]() -> Async { + return Async([&]() -> std::optional { + if (++polls < 3) return std::nullopt; + return std::string("cafe"); + }); + }); + + const auto fire = Call("Player_Fire", "{}"); + Check(fire.done && fire.done->Ok() && fired == 1, "a void command runs and succeeds"); + Check(Failed(Call("Player_Fire", R"({"x":1})"), "InvalidParams"), "a command without arguments refuses some"); + + const auto aim = Call("Player_SetAim", R"({"slot": 2})"); + Check(aim.done && aim.done->Ok() && aim.done->Get().Find("yaw")->AsNumber() == 10 && aim.done->Get().Find("slot")->AsInteger() == 2, + "a field left out takes its default, and the result is encoded"); + const auto mistyped = Call("Player_SetAim", R"({"yw": 1})"); + Check(Failed(mistyped, "InvalidParams") && mistyped.done->GetError().message.find("did you mean 'yaw'") != std::string::npos + && mistyped.done->GetError().message.find("slot: missing") != std::string::npos, + "a mistyped call is refused with every mistake in it"); + const auto refused = Call("Player_SetAim", R"({"slot": -1})"); + Check(Failed(refused, "NoSlot") && refused.done->GetError().message == "slots start at 0", "a Result error keeps its code and message"); + + const auto score = Call("Game_Score", "null"); + Check(score.done && score.done->Ok() && score.done->Get().AsInteger() == 42, "a plain return value is encoded"); + + const auto crash = Call("Game_Crash", "{}"); + Check(Failed(crash, "Exception") && crash.done->GetError().message.find("boom") != std::string::npos, "an exception becomes an Exception error"); + + auto hash = Call("Sim_StateHash", "{}"); + Check(!hash.done && hash.pending, "an Async result is pending"); + std::optional> polled; + std::int32_t pumps = 0; + while (!polled && pumps < 10) { + polled = hash.pending(); + ++pumps; + } + Check(polled && polled->Ok() && polled->Get().AsString() == "cafe" && pumps == 3, "an Async result is polled until it yields"); + + const auto described = Detail::Registry::Instance().FindCommand("Player_SetAim"); + Check(described->args.Find("fields")->Size() == 3 && described->returns.Find("fields")->Size() == 2 && described->description == "Aim the turret, in degrees.", + "a command describes its arguments and its result"); + Check(Detail::Registry::Instance().FindCommand("Player_Fire")->args.IsNull(), "a command without arguments describes none"); + Check(Detail::Registry::Instance().SuggestCommand("Player_SetAm") == std::optional("Player_SetAim"), "a mistyped command name gets a suggestion"); + + { + Group fort("fort1"); + fort.Command("Explode", "Blow it up.", [] {}); + Check(Detail::Registry::Instance().FindCommand("fort1.Explode") != nullptr, "a Group prefixes its commands"); + } + Check(Detail::Registry::Instance().FindCommand("fort1.Explode") == nullptr, "a Group's commands go with it"); + Check(Detail::Registry::Instance().Commands("Player_*").size() == 2, "commands can be listed by glob"); + + bool threw = false; + try { + Command("has space", "", [] {}); + } catch (const std::invalid_argument&) { + threw = true; + } + Check(threw, "a name with whitespace is refused at registration"); + + return failures == 0 ? 0 : 1; +} diff --git a/tests/ShouldRecordTimeline/main.cpp b/tests/ShouldRecordTimeline/main.cpp new file mode 100644 index 0000000..1c8c939 --- /dev/null +++ b/tests/ShouldRecordTimeline/main.cpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The timeline records Log, LogWarn, LogError, Check and Emit with frame, +// category and source location, and answers the queries `crafter-debug log` +// makes: by level, category glob, substring, frame and sequence, newest N. +// A check that fails every frame is counted every time but stored only +// sparsely, so it cannot push everything else out, while the listener the +// session pauses from still hears every failure. Appends from many threads +// keep one increasing sequence. +import std; +import Crafter.Debug; +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; + } + + struct MatchEnded { + std::int32_t winnerSlot = 0; + }; + + Detail::Timeline& timeline() { + return Detail::Timeline::Instance(); + } +} + +int main() { + std::atomic heard = 0; + timeline().SetListener([&](const TimelineEntry&) { heard.fetch_add(1); }); + + Detail::SetFrame(10); + Log("net", "peer {} joined slot {}", "b1", 2); + Detail::SetFrame(11); + LogWarn("physics", "brace {} strain {:.1f}", 17, 4.2); + LogError("render", "lost device"); + Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 }); + + const auto all = timeline().Query({}); + Check(all.size() == 4, std::format("four entries ({})", all.size())); + Check(all[0].message == "peer b1 joined slot 2" && all[0].category == "net" && all[0].frame == 10 && all[0].kind == EntryKind::Log, + "a log entry has its message, category and frame"); + Check(all[0].file.ends_with("main.cpp") && all[0].line > 0, "an entry carries the caller's source location"); + Check(all[1].message == "brace 17 strain 4.2" && all[1].kind == EntryKind::Warn && all[1].frame == 11, "a warning is formatted and stamped"); + Check(all[3].kind == EntryKind::Event && all[3].category == "MatchEnded" && all[3].payload.Find("winnerSlot")->AsInteger() == 1, "an event carries its payload"); + Check(all[1].sequence > all[0].sequence, "sequences increase"); + + Detail::TimelineQuery warnings; + warnings.minLevel = 1; + Check(timeline().Query(warnings).size() == 2, "--level warn keeps warnings and errors"); + Detail::TimelineQuery physics; + physics.category = "phys*"; + Check(timeline().Query(physics).size() == 1, "a category glob filters"); + Detail::TimelineQuery grep; + grep.grep = "device"; + Check(timeline().Query(grep).size() == 1, "a substring filters"); + Detail::TimelineQuery since; + since.sinceFrame = 11; + Check(timeline().Query(since).size() == 3, "--since-frame filters"); + Detail::TimelineQuery after; + after.afterSequence = all[1].sequence; + Check(timeline().Query(after).size() == 2, "afterSequence returns only newer entries"); + Detail::TimelineQuery last; + last.last = 2; + const auto newest = timeline().Query(last); + Check(newest.size() == 2 && newest.back().kind == EntryKind::Event, "last returns the newest entries, in order"); + + heard = 0; + const std::uint64_t before = timeline().LastSequence(); + std::int32_t passed = 0; + for (std::int32_t i = 0; i < 100; ++i) { + if (Crafter::Debug::Check(i % 2 == 1, "physics", "body {} has mass {}", i, -1)) ++passed; + } + Check(passed == 50, "Check returns its condition"); + Detail::TimelineQuery checks; + checks.afterSequence = before; + const auto stored = timeline().Query(checks); + Check(stored.size() == 12, std::format("50 failures at one site store 12 entries ({})", stored.size())); + Check(stored.back().count == 32 && stored.back().kind == EntryKind::Check, "a stored check carries its site's count"); + Check(heard == 50, std::format("the listener hears every failure ({})", heard.load())); + const auto sites = timeline().CheckSites(); + Check(sites.size() == 1 && sites[0].failures == 50 && sites[0].category == "physics", "check sites are counted"); + + const std::uint64_t start = timeline().LastSequence(); + std::vector threads; + for (std::int32_t t = 0; t < 8; ++t) { + threads.emplace_back([t] { + for (std::int32_t i = 0; i < 200; ++i) Log("threads", "thread {} line {}", t, i); + }); + } + for (std::thread& thread : threads) thread.join(); + Detail::TimelineQuery fromThreads; + fromThreads.afterSequence = start; + const auto lines = timeline().Query(fromThreads); + bool increasing = lines.size() == 1600; + for (std::size_t i = 1; i < lines.size(); ++i) increasing = increasing && lines[i].sequence == lines[i - 1].sequence + 1; + Check(increasing, "appends from eight threads keep one increasing sequence"); + + timeline().SetListener(nullptr); + return failures == 0 ? 0 : 1; +} diff --git a/tests/ShouldResolveWatchPaths/main.cpp b/tests/ShouldResolveWatchPaths/main.cpp new file mode 100644 index 0000000..78ea2c2 --- /dev/null +++ b/tests/ShouldResolveWatchPaths/main.cpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Watches and samples resolve by path, including into their values +// ("bodies[1].pos.y" against the watch "bodies"), and set the same way: +// writing one member of a watched struct writes the whole struct back. Sets +// respect bounds and read-only watches, computed watches set through their +// setter (which may refuse), samples keep a frame-stamped history, unknown +// paths get a suggestion, and a Group's watches go with it. +import std; +import Crafter.Debug; +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; + } + + struct Vec3 { + float x = 0; + float y = 0; + float z = 0; + }; + + struct Body { + std::int32_t id = 0; + Vec3 pos; + }; + + float gravity = -9.81F; + const std::int32_t build = 7; + std::vector bodies = { { 1, { 0, 1, 0 } }, { 2, { 3, 4, 5 } } }; + std::int32_t lives = 3; + + Detail::Registry& registry() { + return Detail::Registry::Instance(); + } +} + +int main() { + Watch("physics.gravity", gravity, { .min = -50, .max = 0, .help = "m/s^2" }); + Watch("build", build); + Watch("bodies", bodies); + Watch("lives", [] { return lives; }, [](const std::int32_t& value) -> Result<> { + if (value < 0) return Error{ "Negative", "lives cannot be negative" }; + lives = value; + return {}; + }); + Watch("score", [] { return 1234; }); + + Check(registry().Resolve("physics.gravity").value.AsNumber() == static_cast(-9.81F), "a variable watch reads the variable"); + Check(registry().Resolve("bodies[1].pos.y").value.AsNumber() == 4, "a path reads into a watched value"); + Check(registry().Resolve("bodies[1]").value.Find("id")->AsInteger() == 2, "an index reads an item"); + + Check(!registry().Set("physics.gravity", Value(-3.5)) && gravity == -3.5F, "a set writes the variable"); + const auto bounded = registry().Set("physics.gravity", Value(10)); + Check(bounded && bounded->find("outside") != std::string::npos && gravity == -3.5F, "a set outside the bounds is refused"); + Check(registry().Set("build", Value(8)) == std::optional("'build' is read-only") && build == 7, "a const variable's watch is read-only"); + Check(!registry().Set("bodies[0].pos.x", Value(9)) && bodies[0].pos.x == 9 && bodies[0].pos.y == 1, "a set into a member writes back the whole value"); + const auto wrongType = registry().Set("physics.gravity", Value("down")); + Check(wrongType && wrongType->find("expected a number") != std::string::npos, "a set of the wrong type is refused"); + + Check(!registry().Set("lives", Value(5)) && lives == 5, "a computed watch sets through its setter"); + const auto negative = registry().Set("lives", Value(-1)); + Check(negative && negative->find("lives cannot be negative") != std::string::npos && lives == 5, "a setter can refuse"); + Check(registry().Set("score", Value(1)) == std::optional("'score' is read-only"), "a getter-only watch is read-only"); + + const auto missing = registry().Resolve("physics.gravty"); + Check(!missing.Ok() && missing.error.find("did you mean 'physics.gravity'") != std::string::npos, "an unknown path gets a suggestion"); + const auto badMember = registry().Resolve("bodies[0].pos.w"); + Check(!badMember.Ok() && badMember.error.find("has no member 'w'") != std::string::npos, "an unknown member is named"); + const auto outOfRange = registry().Resolve("bodies[5]"); + Check(!outOfRange.Ok() && outOfRange.error.find("2 items") != std::string::npos, "an index out of range gives the size"); + + for (std::uint64_t frame = 1; frame <= 3; ++frame) { + Detail::SetFrame(frame); + Sample("xpbd.residual", static_cast(frame) / 10); + } + Check(registry().Resolve("xpbd.residual").value.AsNumber() == 0.3, "a sample resolves to its latest value"); + const auto history = registry().SampleHistory("xpbd.residual"); + Check(history.size() == 3 && history.front().frame == 1 && history.back().frame == 3, "a sample keeps a frame-stamped history"); + + Check(registry().Watches("physics.*").size() == 1 && registry().Watches("*").size() == 5, "watches can be listed by glob"); + Check(registry().Watches("physics.gravity").front()->help == "m/s^2" && registry().Watches("physics.gravity").front()->type.Find("maximum") != nullptr, + "a watch carries its help and bounds"); + + { + struct Fort { + std::int32_t hp = 100; + Group debug{ "fort1" }; + Fort() { debug.Watch("core.hp", hp); } + }; + Fort fort; + Check(registry().Resolve("fort1.core.hp").value.AsInteger() == 100, "a Group prefixes its watches"); + } + Check(!registry().Resolve("fort1.core.hp").Ok(), "a Group's watches go with it"); + + return failures == 0 ? 0 : 1; +}