Crafter.Review/implementations/Crafter.Review-Text.cpp

1611 lines
68 KiB
C++
Raw Normal View History

// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <arpa/inet.h>
#include <sys/socket.h>
module Crafter.Review:Text_impl;
import :Text;
import :Unicode;
import :Json;
import std;
namespace Crafter::Review::Dyn {
namespace {
template <typename Table>
bool InRuns(const Table& table, char32_t c) {
const auto found = std::ranges::upper_bound(table, c, {}, [](const Unicode::Run& run) { return run.first; });
if (found == table.begin()) return false;
return c <= std::prev(found)->last;
}
template <typename Table>
std::string_view Mapped(const Table& table, char32_t c) {
const auto found = std::ranges::lower_bound(table, c, {}, [](const Unicode::Mapping& mapping) { return mapping.from; });
if (found == table.end() || found->from != c) return {};
return found->to;
}
void AppendUtf8(std::string& out, char32_t c) {
if (c < 0x80) {
out.push_back(static_cast<char>(c));
} else if (c < 0x800) {
out.push_back(static_cast<char>(0xC0 | (c >> 6)));
out.push_back(static_cast<char>(0x80 | (c & 0x3F)));
} else if (c < 0x10000) {
out.push_back(static_cast<char>(0xE0 | (c >> 12)));
out.push_back(static_cast<char>(0x80 | ((c >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (c & 0x3F)));
} else {
out.push_back(static_cast<char>(0xF0 | (c >> 18)));
out.push_back(static_cast<char>(0x80 | ((c >> 12) & 0x3F)));
out.push_back(static_cast<char>(0x80 | ((c >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (c & 0x3F)));
}
}
// One code point from `bytes` at `at`: the code point and the bytes
// it took, or U+FFFD and the length of the maximal subpart of an
// ill-formed sequence, which one U+FFFD replaces.
std::pair<char32_t, std::size_t> Next(std::string_view bytes, std::size_t at) {
const auto lead = static_cast<std::uint8_t>(bytes[at]);
if (lead < 0x80) return {lead, 1};
std::size_t need = 0;
char32_t value = 0;
std::uint8_t low = 0x80;
std::uint8_t high = 0xBF;
if (lead >= 0xC2 && lead <= 0xDF) {
need = 1;
value = lead & 0x1F;
} else if (lead >= 0xE0 && lead <= 0xEF) {
need = 2;
value = lead & 0x0F;
if (lead == 0xE0) low = 0xA0;
if (lead == 0xED) high = 0x9F;
} else if (lead >= 0xF0 && lead <= 0xF4) {
need = 3;
value = lead & 0x07;
if (lead == 0xF0) low = 0x90;
if (lead == 0xF4) high = 0x8F;
} else {
return {0xFFFD, 1};
}
std::size_t used = 1;
for (std::size_t i = 0; i < need; ++i) {
if (at + used >= bytes.size()) return {0xFFFD, used};
const auto next = static_cast<std::uint8_t>(bytes[at + used]);
if (next < low || next > high) return {0xFFFD, used};
value = (value << 6) | (next & 0x3F);
++used;
low = 0x80;
high = 0xBF;
}
return {value, used};
}
bool IsAsciiDigit(char32_t c) {
return c >= '0' && c <= '9';
}
// ParseInt()'s and ParseFloat()'s first step: Unicode whitespace
// stripped, then every decimal digit made ASCII and every space a plain
// one.
std::optional<std::string> AsciiNumber(std::string_view text) {
std::u32string decoded = Decode(Strip(text));
std::string out;
for (const char32_t c : decoded) {
if (c < 0x80) {
out.push_back(static_cast<char>(c));
} else if (const std::int32_t digit = DecimalValue(c); digit >= 0) {
out.push_back(static_cast<char>('0' + digit));
} else if (IsSpace(c)) {
out.push_back(' ');
} else {
return std::nullopt;
}
}
return out;
}
// A run of digits with single underscores between them, as ParseInt()
// and ParseFloat() take them; the digits alone.
std::optional<std::string> Digits(std::string_view text) {
if (text.empty() || text.front() == '_' || text.back() == '_') return std::nullopt;
std::string digits;
char previous = '\0';
for (const char c : text) {
if (c == '_') {
if (previous == '_') return std::nullopt;
} else if (c >= '0' && c <= '9') {
digits.push_back(c);
} else {
return std::nullopt;
}
previous = c;
}
return digits;
}
std::string ToLowerAscii(std::string_view text) {
std::string out(text);
for (char& c : out) {
if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a');
}
return out;
}
// NormPath() on a string that is known to be a path.
std::vector<std::string> PathComponents(std::string_view path) {
std::vector<std::string> out;
for (const std::string& piece : Split(path, "/")) {
if (!piece.empty()) out.push_back(piece);
}
return out;
}
}
std::u32string Decode(std::string_view utf8) {
std::u32string out;
out.reserve(utf8.size());
for (std::size_t at = 0; at < utf8.size();) {
const auto [c, used] = Next(utf8, at);
out.push_back(c);
at += used;
}
return out;
}
std::string Encode(std::u32string_view text) {
std::string out;
out.reserve(text.size());
for (const char32_t c : text) AppendUtf8(out, c);
return out;
}
std::string Encode(char32_t c) {
std::string out;
AppendUtf8(out, c);
return out;
}
bool IsUtf8(std::string_view bytes) {
for (std::size_t at = 0; at < bytes.size();) {
const auto [c, used] = Next(bytes, at);
if (c == 0xFFFD) {
// A real U+FFFD in the input is three valid bytes.
if (used != 3 || bytes.substr(at, 3) != "\xEF\xBF\xBD") return false;
}
at += used;
}
return true;
}
std::string DecodeReplace(std::string_view bytes) {
if (IsUtf8(bytes)) return std::string(bytes);
return Encode(Decode(bytes));
}
std::size_t Length(std::string_view text) {
std::size_t count = 0;
for (std::size_t at = 0; at < text.size(); ++count) at += Next(text, at).second;
return count;
}
std::string Slice(std::string_view text, std::int64_t start, std::optional<std::int64_t> end) {
const std::u32string decoded = Decode(text);
const auto size = static_cast<std::int64_t>(decoded.size());
auto clip = [size](std::int64_t index) {
if (index < 0) index += size;
return std::clamp<std::int64_t>(index, 0, size);
};
const std::int64_t from = clip(start);
const std::int64_t to = end ? clip(*end) : size;
if (to <= from) return {};
return Encode(std::u32string_view(decoded).substr(static_cast<std::size_t>(from), static_cast<std::size_t>(to - from)));
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
std::pair<char32_t, std::size_t> CodePointAt(std::string_view text, std::size_t at) {
return Next(text, at);
}
std::pair<std::size_t, std::size_t> SliceBounds(std::size_t size, std::int64_t start, std::optional<std::int64_t> end) {
const auto count = static_cast<std::int64_t>(size);
auto clip = [count](std::int64_t index) {
if (index < 0) index += count;
return std::clamp<std::int64_t>(index, 0, count);
};
const std::int64_t from = clip(start);
const std::int64_t to = end ? clip(*end) : count;
return {static_cast<std::size_t>(from), static_cast<std::size_t>(std::max(from, to))};
}
namespace {
template <typename Predicate>
std::size_t RunOf(std::string_view text, std::size_t at, Predicate matches) {
std::size_t end = at;
while (end < text.size()) {
const auto [c, used] = Next(text, end);
if (!matches(c)) break;
end += used;
}
return end - at;
}
}
std::size_t DecimalRun(std::string_view text, std::size_t at) {
return RunOf(text, at, [](char32_t c) { return IsDecimal(c); });
}
std::size_t SpaceRun(std::string_view text, std::size_t at) {
return RunOf(text, at, [](char32_t c) { return IsSpace(c); });
}
std::size_t NonSpaceRun(std::string_view text, std::size_t at) {
return RunOf(text, at, [](char32_t c) { return !IsSpace(c); });
}
bool IsSpace(char32_t c) {
return std::ranges::binary_search(Unicode::Space, c);
}
bool IsLineBreak(char32_t c) {
return std::ranges::binary_search(Unicode::LineBreak, c);
}
bool IsDecimal(char32_t c) {
return DecimalValue(c) >= 0;
}
std::int32_t DecimalValue(char32_t c) {
if (IsAsciiDigit(c)) return static_cast<std::int32_t>(c - '0');
const auto found = std::ranges::upper_bound(Unicode::Decimal, c, {}, [](const Unicode::Run& run) { return run.first; });
if (found == Unicode::Decimal.begin()) return -1;
const Unicode::Run& run = *std::prev(found);
if (c > run.last) return -1;
return static_cast<std::int32_t>((c - run.first) % 10);
}
bool IsPrintable(char32_t c) {
return InRuns(Unicode::Printable, c);
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
bool IsWord(char32_t c) {
return InRuns(Unicode::Word, c);
}
char IgnoreCaseLetter(char32_t c) {
if (c >= 'a' && c <= 'z') return static_cast<char>(c);
if (c >= 'A' && c <= 'Z') return static_cast<char>(c - 'A' + 'a');
const auto found = std::ranges::lower_bound(Unicode::IgnoreCase, c, {}, [](const Unicode::Fold& fold) { return fold.from; });
if (found == Unicode::IgnoreCase.end() || found->from != c) return '\0';
return found->letter;
}
std::string LStrip(std::string_view text) {
std::size_t at = 0;
while (at < text.size()) {
const auto [c, used] = Next(text, at);
if (!IsSpace(c)) break;
at += used;
}
return std::string(text.substr(at));
}
std::string RStrip(std::string_view text) {
const std::u32string decoded = Decode(text);
std::size_t end = decoded.size();
while (end > 0 && IsSpace(decoded[end - 1])) --end;
if (end == decoded.size()) return std::string(text);
return Encode(std::u32string_view(decoded).substr(0, end));
}
std::string Strip(std::string_view text) {
return RStrip(LStrip(text));
}
std::string LStrip(std::string_view text, std::string_view chars) {
const std::u32string set = Decode(chars);
const std::u32string decoded = Decode(text);
std::size_t start = 0;
while (start < decoded.size() && set.contains(decoded[start])) ++start;
return Encode(std::u32string_view(decoded).substr(start));
}
std::string RStrip(std::string_view text, std::string_view chars) {
const std::u32string set = Decode(chars);
const std::u32string decoded = Decode(text);
std::size_t end = decoded.size();
while (end > 0 && set.contains(decoded[end - 1])) --end;
return Encode(std::u32string_view(decoded).substr(0, end));
}
std::string Strip(std::string_view text, std::string_view chars) {
return RStrip(LStrip(text, chars), chars);
}
std::vector<std::string> Split(std::string_view text) {
std::vector<std::string> out;
std::string current;
bool inWord = false;
for (std::size_t at = 0; at < text.size();) {
const auto [c, used] = Next(text, at);
if (IsSpace(c)) {
if (inWord) out.push_back(std::move(current));
current.clear();
inWord = false;
} else {
current.append(text.substr(at, used));
inWord = true;
}
at += used;
}
if (inWord) out.push_back(std::move(current));
return out;
}
std::vector<std::string> Split(std::string_view text, std::string_view separator, std::int64_t maxSplit) {
if (separator.empty()) throw std::invalid_argument("empty separator");
std::vector<std::string> out;
std::size_t start = 0;
while (maxSplit < 0 || static_cast<std::int64_t>(out.size()) < maxSplit) {
const std::size_t found = text.find(separator, start);
if (found == std::string_view::npos) break;
out.emplace_back(text.substr(start, found - start));
start = found + separator.size();
}
out.emplace_back(text.substr(start));
return out;
}
std::vector<std::string> RSplit(std::string_view text, std::string_view separator, std::int64_t maxSplit) {
if (separator.empty()) throw std::invalid_argument("empty separator");
std::vector<std::string> out;
std::size_t end = text.size();
while (maxSplit < 0 || static_cast<std::int64_t>(out.size()) < maxSplit) {
if (end < separator.size()) break;
const std::size_t found = text.rfind(separator, end - separator.size());
if (found == std::string_view::npos) break;
out.emplace_back(text.substr(found + separator.size(), end - found - separator.size()));
end = found;
}
out.emplace_back(text.substr(0, end));
std::ranges::reverse(out);
return out;
}
std::vector<std::string> SplitLines(std::string_view text, bool keepEnds) {
std::vector<std::string> out;
std::size_t start = 0;
std::size_t at = 0;
while (at < text.size()) {
const auto [c, used] = Next(text, at);
if (!IsLineBreak(c)) {
at += used;
continue;
}
std::size_t end = at + used;
if (c == '\r' && end < text.size() && text[end] == '\n') ++end;
out.emplace_back(text.substr(start, (keepEnds ? end : at) - start));
start = end;
at = end;
}
if (start < text.size()) out.emplace_back(text.substr(start));
return out;
}
std::string Lower(std::string_view text) {
std::string out;
out.reserve(text.size());
for (std::size_t at = 0; at < text.size();) {
const auto [c, used] = Next(text, at);
if (c < 0x80) {
out.push_back(static_cast<char>(c >= 'A' && c <= 'Z' ? c - 'A' + 'a' : c));
} else if (const std::string_view to = Mapped(Unicode::Lower, c); !to.empty()) {
out.append(to);
} else {
AppendUtf8(out, c);
}
at += used;
}
return out;
}
std::string Upper(std::string_view text) {
std::string out;
out.reserve(text.size());
for (std::size_t at = 0; at < text.size();) {
const auto [c, used] = Next(text, at);
if (c < 0x80) {
out.push_back(static_cast<char>(c >= 'a' && c <= 'z' ? c - 'a' + 'A' : c));
} else if (const std::string_view to = Mapped(Unicode::Upper, c); !to.empty()) {
out.append(to);
} else {
AppendUtf8(out, c);
}
at += used;
}
return out;
}
std::size_t Count(std::string_view text, std::string_view sub) {
if (sub.empty()) return Length(text) + 1;
std::size_t count = 0;
for (std::size_t at = text.find(sub); at != std::string_view::npos; at = text.find(sub, at + sub.size())) ++count;
return count;
}
std::string Replace(std::string_view text, std::string_view from, std::string_view to) {
if (from.empty()) return std::string(text);
std::string out;
std::size_t start = 0;
for (std::size_t at = text.find(from); at != std::string_view::npos; at = text.find(from, start)) {
out.append(text.substr(start, at - start));
out.append(to);
start = at + from.size();
}
out.append(text.substr(start));
return out;
}
std::string Join(std::span<const std::string> parts, std::string_view separator) {
std::string out;
for (std::size_t i = 0; i < parts.size(); ++i) {
if (i > 0) out.append(separator);
out.append(parts[i]);
}
return out;
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
std::string LJust(std::string_view text, std::size_t width) {
const std::size_t length = Length(text);
std::string out(text);
if (length < width) out.append(width - length, ' ');
return out;
}
std::string ReadText(const std::filesystem::path& path) {
std::ifstream in(path, std::ios::binary);
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
if (!in || std::filesystem::is_directory(path)) {
throw Error("FileNotFoundError", std::format("[Errno 2] No such file or directory: {}", Repr(std::string_view(path.string()))));
}
const std::string bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
if (const std::optional<std::string> error = Utf8DecodeError(bytes)) throw Error("UnicodeDecodeError", *error);
return UniversalNewlines(bytes);
}
std::optional<std::string> Utf8DecodeError(std::string_view bytes) {
// The decoder's reasons: a bad lead byte is "invalid start byte"; the
// k-th continuation byte out of range is "invalid continuation byte"
// over k bytes; the input ending inside a sequence is "unexpected end
// of data" over the rest of it.
for (std::size_t at = 0; at < bytes.size();) {
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
const auto lead = static_cast<std::uint8_t>(bytes[at]);
std::size_t need = 0;
std::uint8_t low = 0x80;
std::uint8_t high = 0xBF;
if (lead < 0x80) {
++at;
continue;
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
if (lead >= 0xC2 && lead <= 0xDF) {
need = 1;
} else if (lead >= 0xE0 && lead <= 0xEF) {
need = 2;
if (lead == 0xE0) low = 0xA0;
if (lead == 0xED) high = 0x9F;
} else if (lead >= 0xF0 && lead <= 0xF4) {
need = 3;
if (lead == 0xF0) low = 0x90;
if (lead == 0xF4) high = 0x8F;
}
std::string_view reason;
std::size_t length = 1;
if (need == 0) {
reason = "invalid start byte";
} else {
for (std::size_t k = 1; k <= need; ++k) {
if (at + k >= bytes.size()) {
reason = "unexpected end of data";
length = bytes.size() - at;
break;
}
const auto next = static_cast<std::uint8_t>(bytes[at + k]);
if (next < low || next > high) {
reason = "invalid continuation byte";
length = k;
break;
}
low = 0x80;
high = 0xBF;
}
}
if (reason.empty()) {
at += need + 1;
continue;
}
if (length == 1) return std::format("'utf-8' codec can't decode byte 0x{:02x} in position {}: {}", lead, at, reason);
return std::format("'utf-8' codec can't decode bytes in position {}-{}: {}", at, at + length - 1, reason);
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
return std::nullopt;
}
namespace {
struct JsonFailure {
std::string_view message;
std::size_t position;
};
bool IsJsonSpace(char32_t c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
bool IsJsonDigit(char32_t c) {
return c >= '0' && c <= '9';
}
std::int32_t HexDigit(char32_t c) {
if (c >= '0' && c <= '9') return static_cast<std::int32_t>(c - '0');
if (c >= 'a' && c <= 'f') return static_cast<std::int32_t>(c - 'a' + 10);
if (c >= 'A' && c <= 'F') return static_cast<std::int32_t>(c - 'A' + 10);
return -1;
}
// A JSON scanner that fails where and why the recorded messages say,
// step for step: a string, an object, an array, a number, then any
// value. "Expecting value" is a position where no value starts.
class JsonScanner {
public:
explicit JsonScanner(std::u32string_view text) : s(text) {}
std::optional<JsonFailure> Value(std::size_t idx, std::size_t& next) {
if (idx >= s.size()) return JsonFailure{"Expecting value", idx};
switch (s[idx]) {
case '"':
return String(idx + 1, next);
case '{':
return Object(idx + 1, next);
case '[':
return Array(idx + 1, next);
case 'n':
if (Word(idx, U"null", next)) return std::nullopt;
break;
case 't':
if (Word(idx, U"true", next)) return std::nullopt;
break;
case 'f':
if (Word(idx, U"false", next)) return std::nullopt;
break;
case 'N':
if (Word(idx, U"NaN", next)) return std::nullopt;
break;
case 'I':
if (Word(idx, U"Infinity", next)) return std::nullopt;
break;
case '-':
if (Word(idx, U"-Infinity", next)) return std::nullopt;
break;
default:
break;
}
return Number(idx, next);
}
private:
std::u32string_view s;
bool Word(std::size_t idx, std::u32string_view word, std::size_t& next) const {
if (s.substr(idx, word.size()) != word) return false;
next = idx + word.size();
return true;
}
std::size_t SkipSpace(std::size_t idx) const {
while (idx < s.size() && IsJsonSpace(s[idx])) ++idx;
return idx;
}
std::optional<JsonFailure> String(std::size_t end, std::size_t& next) const {
const std::size_t begin = end - 1;
const std::size_t len = s.size();
while (true) {
std::size_t at = end;
char32_t c = 0;
for (; at < len; ++at) {
c = s[at];
if (c == '"' || c == '\\') break;
if (c <= 0x1F) return JsonFailure{"Invalid control character at", at};
}
if (at >= len) return JsonFailure{"Unterminated string starting at", begin};
++at;
if (c == '"') {
next = at;
return std::nullopt;
}
if (at == len) return JsonFailure{"Unterminated string starting at", begin};
c = s[at];
if (c != 'u') {
end = at + 1;
if (!std::u32string_view(U"\"\\/bfnrt").contains(c)) return JsonFailure{"Invalid \\escape", end - 2};
continue;
}
++at;
end = at + 4;
if (end >= len) return JsonFailure{"Invalid \\uXXXX escape", at - 1};
std::uint32_t unit = 0;
for (; at < end; ++at) {
const std::int32_t digit = HexDigit(s[at]);
if (digit < 0) return JsonFailure{"Invalid \\uXXXX escape", end - 5};
unit = (unit << 4) | static_cast<std::uint32_t>(digit);
}
if (unit >= 0xD800 && unit <= 0xDBFF && end + 6 < len && s[at] == '\\' && s[at + 1] == 'u') {
at += 2;
end += 6;
std::uint32_t second = 0;
for (; at < end; ++at) {
const std::int32_t digit = HexDigit(s[at]);
if (digit < 0) return JsonFailure{"Invalid \\uXXXX escape", end - 5};
second = (second << 4) | static_cast<std::uint32_t>(digit);
}
if (second < 0xDC00 || second > 0xDFFF) end -= 6;
}
}
}
std::optional<JsonFailure> Object(std::size_t idx, std::size_t& next) {
idx = SkipSpace(idx);
if (idx >= s.size() || s[idx] != '}') {
while (true) {
if (idx >= s.size() || s[idx] != '"') return JsonFailure{"Expecting property name enclosed in double quotes", idx};
std::size_t after = 0;
if (std::optional<JsonFailure> failure = String(idx + 1, after)) return failure;
idx = SkipSpace(after);
if (idx >= s.size() || s[idx] != ':') return JsonFailure{"Expecting ':' delimiter", idx};
idx = SkipSpace(idx + 1);
if (std::optional<JsonFailure> failure = Value(idx, after)) return failure;
idx = SkipSpace(after);
if (idx < s.size() && s[idx] == '}') break;
if (idx >= s.size() || s[idx] != ',') return JsonFailure{"Expecting ',' delimiter", idx};
const std::size_t comma = idx;
idx = SkipSpace(idx + 1);
if (idx < s.size() && s[idx] == '}') return JsonFailure{"Illegal trailing comma before end of object", comma};
}
}
next = idx + 1;
return std::nullopt;
}
std::optional<JsonFailure> Array(std::size_t idx, std::size_t& next) {
idx = SkipSpace(idx);
if (idx >= s.size() || s[idx] != ']') {
while (true) {
std::size_t after = 0;
if (std::optional<JsonFailure> failure = Value(idx, after)) return failure;
idx = SkipSpace(after);
if (idx < s.size() && s[idx] == ']') break;
if (idx >= s.size() || s[idx] != ',') return JsonFailure{"Expecting ',' delimiter", idx};
const std::size_t comma = idx;
idx = SkipSpace(idx + 1);
if (idx < s.size() && s[idx] == ']') return JsonFailure{"Illegal trailing comma before end of array", comma};
}
}
next = idx + 1;
return std::nullopt;
}
std::optional<JsonFailure> Number(std::size_t start, std::size_t& next) const {
const std::size_t len = s.size();
std::size_t idx = start;
if (s[idx] == '-') {
++idx;
if (idx >= len) return JsonFailure{"Expecting value", start};
}
if (s[idx] >= '1' && s[idx] <= '9') {
++idx;
while (idx < len && IsJsonDigit(s[idx])) ++idx;
} else if (s[idx] == '0') {
++idx;
} else {
return JsonFailure{"Expecting value", start};
}
if (idx + 1 < len && s[idx] == '.' && IsJsonDigit(s[idx + 1])) {
idx += 2;
while (idx < len && IsJsonDigit(s[idx])) ++idx;
}
if (idx + 1 < len && (s[idx] == 'e' || s[idx] == 'E')) {
const std::size_t exponent = idx;
++idx;
if (idx + 1 < len && (s[idx] == '-' || s[idx] == '+')) ++idx;
while (idx < len && IsJsonDigit(s[idx])) ++idx;
if (!IsJsonDigit(s[idx - 1])) idx = exponent;
}
next = idx;
return std::nullopt;
}
};
}
std::optional<std::string> JsonDecodeError(std::string_view text) {
const std::u32string s = Decode(text);
JsonScanner scanner(s);
std::size_t start = 0;
while (start < s.size() && IsJsonSpace(s[start])) ++start;
std::size_t end = 0;
std::optional<JsonFailure> failure;
if (s.starts_with(U'\uFEFF')) {
failure = JsonFailure{"Unexpected UTF-8 BOM (decode using utf-8-sig)", 0};
} else {
failure = scanner.Value(start, end);
}
if (!failure) {
while (end < s.size() && IsJsonSpace(s[end])) ++end;
if (end != s.size()) failure = JsonFailure{"Extra data", end};
}
if (!failure) return std::nullopt;
const std::size_t pos = failure->position;
const std::u32string_view before = std::u32string_view(s).substr(0, pos);
const auto line = std::ranges::count(before, U'\n') + 1;
const std::size_t lastNewline = before.rfind(U'\n');
const std::size_t column = lastNewline == std::u32string_view::npos ? pos + 1 : pos - lastNewline;
return std::format("{}: line {} column {} (char {})", failure->message, line, column, pos);
}
Json LoadJsonFile(const std::filesystem::path& path) {
const std::string text = ReadText(path);
if (const std::optional<std::string> error = JsonDecodeError(text)) throw Error("JSONDecodeError", *error);
return ParseLoose(text);
}
std::string UniversalNewlines(std::string_view text) {
if (!text.contains('\r')) return std::string(text);
std::string out;
out.reserve(text.size());
for (std::size_t i = 0; i < text.size(); ++i) {
if (text[i] == '\r') {
out.push_back('\n');
if (i + 1 < text.size() && text[i + 1] == '\n') ++i;
} else {
out.push_back(text[i]);
}
}
return out;
}
std::string FloatRepr(double value) {
if (std::isnan(value)) return "nan";
if (std::isinf(value)) return value > 0 ? "inf" : "-inf";
// The shortest round-trip digits, in to_chars' scientific form, then
// laid out: fixed from 1e-4 up to 1e16, with an exponent otherwise.
std::array<char, 64> buffer {};
const auto [end, error] = std::to_chars(buffer.data(), buffer.data() + buffer.size(), value, std::chars_format::scientific);
const std::string_view scientific(buffer.data(), end);
const bool negative = scientific.starts_with('-');
const std::string_view body = negative ? scientific.substr(1) : scientific;
const std::size_t e = body.find('e');
std::string digits;
for (const char c : body.substr(0, e)) {
if (c != '.') digits.push_back(c);
}
std::int32_t exponent = 0;
std::from_chars(body.data() + e + 1 + (body[e + 1] == '+' ? 1 : 0), body.data() + body.size(), exponent);
while (digits.size() > 1 && digits.back() == '0') digits.pop_back();
const std::int32_t decimalPoint = exponent + 1;
std::string out = negative ? "-" : "";
if (decimalPoint <= -4 || decimalPoint > 16) {
out += digits.substr(0, 1);
if (digits.size() > 1) out += std::format(".{}", digits.substr(1));
const std::int32_t shown = decimalPoint - 1;
out += std::format("e{}{:02}", shown < 0 ? '-' : '+', shown < 0 ? -shown : shown);
return out;
}
if (decimalPoint <= 0) {
out += std::format("0.{}{}", std::string(static_cast<std::size_t>(-decimalPoint), '0'), digits);
} else if (static_cast<std::size_t>(decimalPoint) >= digits.size()) {
out += std::format("{}{}.0", digits, std::string(static_cast<std::size_t>(decimalPoint) - digits.size(), '0'));
} else {
out += std::format("{}.{}", digits.substr(0, static_cast<std::size_t>(decimalPoint)), digits.substr(static_cast<std::size_t>(decimalPoint)));
}
return out;
}
double Round(double value, std::int32_t digits) {
if (!std::isfinite(value)) return value;
// to_chars with a precision rounds the exact binary value correctly,
// half to even on an exact tie.
std::array<char, 400> buffer {};
const auto [end, error] = std::to_chars(buffer.data(), buffer.data() + buffer.size(), value, std::chars_format::fixed, digits);
if (error != std::errc()) return value;
double rounded = value;
std::from_chars(buffer.data(), end, rounded);
return rounded;
}
std::int64_t RoundToInt(double value) {
return static_cast<std::int64_t>(std::nearbyint(value));
}
std::optional<std::int64_t> ParseInt(std::string_view text) {
const std::optional<std::string> ascii = AsciiNumber(text);
if (!ascii || ascii->empty()) return std::nullopt;
std::string_view body = *ascii;
bool negative = false;
if (body.front() == '+' || body.front() == '-') {
negative = body.front() == '-';
body.remove_prefix(1);
}
const std::optional<std::string> digits = Digits(body);
if (!digits) return std::nullopt;
std::int64_t value = 0;
for (const char c : *digits) {
if (value > (std::numeric_limits<std::int64_t>::max() - (c - '0')) / 10) return std::nullopt;
value = value * 10 + (c - '0');
}
return negative ? -value : value;
}
std::optional<double> ParseFloat(std::string_view text) {
const std::optional<std::string> ascii = AsciiNumber(text);
if (!ascii || ascii->empty()) return std::nullopt;
std::string_view body = *ascii;
bool negative = false;
if (body.front() == '+' || body.front() == '-') {
negative = body.front() == '-';
body.remove_prefix(1);
}
const std::string word = ToLowerAscii(body);
if (word == "inf" || word == "infinity") return negative ? -std::numeric_limits<double>::infinity() : std::numeric_limits<double>::infinity();
if (word == "nan") return std::numeric_limits<double>::quiet_NaN();
// digits [. digits] [e [sign] digits], one of the two digit runs
// present, underscores only between digits.
const std::size_t exponentAt = body.find_first_of("eE");
const std::string_view mantissa = body.substr(0, exponentAt);
const std::size_t dot = mantissa.find('.');
const std::string_view whole = mantissa.substr(0, dot);
const std::string_view fraction = dot == std::string_view::npos ? std::string_view() : mantissa.substr(dot + 1);
if (whole.empty() && fraction.empty()) return std::nullopt;
std::string normal = negative ? "-" : "";
if (!whole.empty()) {
const std::optional<std::string> digits = Digits(whole);
if (!digits) return std::nullopt;
normal += *digits;
}
if (dot != std::string_view::npos) {
normal += '.';
if (!fraction.empty()) {
const std::optional<std::string> digits = Digits(fraction);
if (!digits) return std::nullopt;
normal += *digits;
}
}
if (exponentAt != std::string_view::npos) {
std::string_view exponent = body.substr(exponentAt + 1);
normal += 'e';
if (!exponent.empty() && (exponent.front() == '+' || exponent.front() == '-')) {
normal += exponent.front();
exponent.remove_prefix(1);
}
const std::optional<std::string> digits = Digits(exponent);
if (!digits) return std::nullopt;
normal += *digits;
}
// strtod, not from_chars: an exponent out of range is inf or 0.0, not
// an error. The process runs in the "C" locale.
return std::strtod(normal.c_str(), nullptr);
}
std::string IntError(std::string_view text) {
return std::format("invalid literal for int() with base 10: {}", Repr(text));
}
std::string FloatError(std::string_view text) {
return std::format("could not convert string to float: {}", Repr(text));
}
std::string Repr(std::string_view text) {
const char quote = text.contains('\'') && !text.contains('"') ? '"' : '\'';
std::string out(1, quote);
for (std::size_t at = 0; at < text.size();) {
const auto [c, used] = Next(text, at);
if (c == static_cast<char32_t>(quote) || c == '\\') {
out.push_back('\\');
out.push_back(static_cast<char>(c));
} else if (c == '\t') {
out += "\\t";
} else if (c == '\n') {
out += "\\n";
} else if (c == '\r') {
out += "\\r";
} else if (c < 0x20 || c == 0x7F) {
out += std::format("\\x{:02x}", static_cast<std::uint32_t>(c));
} else if (c < 0x7F) {
out.push_back(static_cast<char>(c));
} else if (IsPrintable(c)) {
AppendUtf8(out, c);
} else if (c <= 0xFF) {
out += std::format("\\x{:02x}", static_cast<std::uint32_t>(c));
} else if (c <= 0xFFFF) {
out += std::format("\\u{:04x}", static_cast<std::uint32_t>(c));
} else {
out += std::format("\\U{:08x}", static_cast<std::uint32_t>(c));
}
at += used;
}
out.push_back(quote);
return out;
}
std::string ReprValue(const Json& value) {
if (value.is_null()) return "None";
if (value.is_boolean()) return value.get<bool>() ? "True" : "False";
if (value.is_number_float()) return FloatRepr(value.get<double>());
if (value.is_number_unsigned()) return std::to_string(value.get<std::uint64_t>());
if (value.is_number_integer()) return std::to_string(value.get<std::int64_t>());
if (value.is_string()) return Repr(std::string_view(value.get_ref<const std::string&>()));
if (value.is_array()) {
std::string out = "[";
bool first = true;
for (const Json& item : value) {
if (!first) out += ", ";
first = false;
out += ReprValue(item);
}
return std::format("{}]", out);
}
std::string out = "{";
bool first = true;
for (auto it = value.begin(); it != value.end(); ++it) {
if (!first) out += ", ";
first = false;
out += std::format("{}: {}", Repr(std::string_view(it.key())), ReprValue(it.value()));
}
return std::format("{}}}", out);
}
namespace {
// One element of a translated shell pattern.
struct GlobToken {
enum class Kind { Star, Any, Literal, Class, Never };
Kind kind;
char32_t literal = 0;
bool negate = false;
std::vector<std::pair<char32_t, char32_t>> ranges;
};
// A bracket expression, from the text between [ and ] — escapes and
// all — read as a regular expression reads a character class.
GlobToken ClassFrom(const std::u32string& content) {
GlobToken token { .kind = GlobToken::Kind::Class };
std::u32string_view rest(content);
if (rest.starts_with(U'!')) {
token.negate = true;
rest.remove_prefix(1);
}
auto item = [&rest]() {
char32_t c = rest.front();
rest.remove_prefix(1);
if (c == U'\\' && !rest.empty()) {
c = rest.front();
rest.remove_prefix(1);
}
return c;
};
while (!rest.empty()) {
const char32_t first = item();
if (rest.size() >= 2 && rest.front() == U'-') {
rest.remove_prefix(1);
const char32_t last = item();
token.ranges.emplace_back(first, last);
} else {
token.ranges.emplace_back(first, first);
}
}
return token;
}
std::u32string EscapeClassChunk(std::u32string_view chunk, bool hyphens) {
std::u32string out;
for (const char32_t c : chunk) {
if (c == U'\\' || (hyphens && c == U'-')) out.push_back(U'\\');
out.push_back(c);
}
return out;
}
// A shell pattern, translated token by token.
std::vector<GlobToken> Translate(const std::u32string& pattern) {
std::vector<GlobToken> tokens;
const std::size_t n = pattern.size();
std::size_t i = 0;
while (i < n) {
const char32_t c = pattern[i++];
if (c == U'*') {
if (tokens.empty() || tokens.back().kind != GlobToken::Kind::Star) tokens.push_back({ .kind = GlobToken::Kind::Star });
while (i < n && pattern[i] == U'*') ++i;
} else if (c == U'?') {
tokens.push_back({ .kind = GlobToken::Kind::Any });
} else if (c == U'[') {
std::size_t j = i;
if (j < n && pattern[j] == U'!') ++j;
if (j < n && pattern[j] == U']') ++j;
while (j < n && pattern[j] != U']') ++j;
if (j >= n) {
tokens.push_back({ .kind = GlobToken::Kind::Literal, .literal = U'[' });
continue;
}
std::u32string stuff = pattern.substr(i, j - i);
if (!stuff.contains(U'-')) {
stuff = EscapeClassChunk(stuff, false);
} else {
std::vector<std::u32string> chunks;
std::size_t start = i;
std::size_t k = pattern[i] == U'!' ? i + 2 : i + 1;
while (true) {
const std::size_t found = k < j ? pattern.find(U'-', k) : std::u32string::npos;
if (found == std::u32string::npos || found >= j) break;
chunks.push_back(pattern.substr(start, found - start));
start = found + 1;
k = found + 3;
}
const std::u32string chunk = pattern.substr(start, j - start);
if (!chunk.empty()) {
chunks.push_back(chunk);
} else {
chunks.back() += U'-';
}
// A reversed range is dropped: it matches nothing.
for (std::size_t m = chunks.size() - 1; m > 0; --m) {
if (chunks[m - 1].back() > chunks[m].front()) {
chunks[m - 1] = chunks[m - 1].substr(0, chunks[m - 1].size() - 1) + chunks[m].substr(1);
chunks.erase(chunks.begin() + static_cast<std::ptrdiff_t>(m));
}
}
stuff.clear();
for (std::size_t m = 0; m < chunks.size(); ++m) {
if (m > 0) stuff += U'-';
stuff += EscapeClassChunk(chunks[m], true);
}
}
i = j + 1;
if (stuff.empty()) {
tokens.push_back({ .kind = GlobToken::Kind::Never });
} else if (stuff == U"!") {
tokens.push_back({ .kind = GlobToken::Kind::Any });
} else {
tokens.push_back(ClassFrom(stuff));
}
} else {
tokens.push_back({ .kind = GlobToken::Kind::Literal, .literal = c });
}
}
return tokens;
}
bool Matches(const GlobToken& token, char32_t c) {
switch (token.kind) {
case GlobToken::Kind::Any:
return true;
case GlobToken::Kind::Literal:
return token.literal == c;
case GlobToken::Kind::Class: {
const bool in = std::ranges::any_of(token.ranges, [c](const auto& range) { return c >= range.first && c <= range.second; });
return in != token.negate;
}
default:
return false;
}
}
}
bool FnMatch(std::string_view name, std::string_view pattern) {
const std::vector<GlobToken> tokens = Translate(Decode(pattern));
const std::u32string text = Decode(name);
// The classic two-pointer match: every token but * consumes exactly
// one character, so backtracking only ever returns to the last star.
std::size_t t = 0;
std::size_t s = 0;
std::optional<std::size_t> starToken;
std::size_t starText = 0;
while (s < text.size()) {
if (t < tokens.size() && tokens[t].kind == GlobToken::Kind::Star) {
starToken = t++;
starText = s;
} else if (t < tokens.size() && Matches(tokens[t], text[s])) {
++t;
++s;
} else if (starToken) {
t = *starToken + 1;
s = ++starText;
} else {
return false;
}
}
while (t < tokens.size() && tokens[t].kind == GlobToken::Kind::Star) ++t;
return t == tokens.size();
}
std::optional<std::string> SplitUrl::Hostname() const {
std::string hostinfo = netloc.substr(netloc.rfind('@') == std::string::npos ? 0 : netloc.rfind('@') + 1);
std::string hostname;
if (const std::size_t open = hostinfo.find('['); open != std::string::npos) {
const std::string bracketed = hostinfo.substr(open + 1);
hostname = bracketed.substr(0, bracketed.find(']'));
} else {
hostname = hostinfo.substr(0, hostinfo.find(':'));
}
if (hostname.empty()) return std::nullopt;
// A scoped IPv6 address's zone is left as written.
const std::size_t percent = hostname.find('%');
if (percent == std::string::npos) return Lower(hostname);
return Lower(hostname.substr(0, percent)) + hostname.substr(percent);
}
std::optional<std::string> SplitUrl::Username() const {
const std::size_t at = netloc.rfind('@');
if (at == std::string::npos) return std::nullopt;
const std::string userinfo = netloc.substr(0, at);
return userinfo.substr(0, userinfo.find(':'));
}
std::optional<std::string> SplitUrl::Password() const {
const std::size_t at = netloc.rfind('@');
if (at == std::string::npos) return std::nullopt;
const std::string userinfo = netloc.substr(0, at);
const std::size_t colon = userinfo.find(':');
if (colon == std::string::npos) return std::nullopt;
return userinfo.substr(colon + 1);
}
std::optional<std::int32_t> SplitUrl::Port() const {
std::string hostinfo = netloc.substr(netloc.rfind('@') == std::string::npos ? 0 : netloc.rfind('@') + 1);
std::string port;
if (const std::size_t open = hostinfo.find('['); open != std::string::npos) {
const std::string bracketed = hostinfo.substr(open + 1);
const std::size_t close = bracketed.find(']');
if (close != std::string::npos) {
const std::string after = bracketed.substr(close + 1);
if (const std::size_t colon = after.find(':'); colon != std::string::npos) port = after.substr(colon + 1);
}
} else if (const std::size_t colon = hostinfo.find(':'); colon != std::string::npos) {
port = hostinfo.substr(colon + 1);
}
if (port.empty()) return std::nullopt;
if (!std::ranges::all_of(port, [](char c) { return c >= '0' && c <= '9'; })) {
throw std::invalid_argument(std::format("Port could not be cast to integer value as {}", Repr(port)));
}
std::int64_t value = 0;
for (const char c : port) {
value = value * 10 + (c - '0');
if (value > 65535) break;
}
if (value > 65535) throw std::invalid_argument("Port out of range 0-65535");
return static_cast<std::int32_t>(value);
}
namespace {
// ipaddress.ip_address's verdict on what sits between brackets, as
// far as urlsplit needs one: an IPv4 address is refused, anything that
// is not an IPv6 address is invalid.
void CheckBracketedHost(const std::string& host) {
if (host.starts_with('v')) {
// \Av[a-fA-F0-9]+\..+\z
std::size_t i = 1;
while (i < host.size() && std::isxdigit(static_cast<std::uint8_t>(host[i]))) ++i;
if (i == 1 || i >= host.size() || host[i] != '.' || i + 1 >= host.size()) {
throw std::invalid_argument("IPvFuture address is invalid");
}
return;
}
std::array<unsigned char, 16> address {};
std::string withoutZone = host.substr(0, host.find('%'));
if (inet_pton(AF_INET, withoutZone.c_str(), address.data()) == 1) {
throw std::invalid_argument("An IPv4 address cannot be in brackets");
}
if (inet_pton(AF_INET6, withoutZone.c_str(), address.data()) != 1) {
throw std::invalid_argument(std::format("{} does not appear to be an IPv4 or IPv6 address", Repr(host)));
}
}
}
SplitUrl UrlSplit(std::string_view input) {
std::string url;
for (const char c : input) {
if (c != '\t' && c != '\r' && c != '\n') url.push_back(c);
}
std::size_t lead = 0;
while (lead < url.size() && static_cast<std::uint8_t>(url[lead]) <= 0x20) ++lead;
url.erase(0, lead);
SplitUrl parts;
const std::size_t colon = url.find(':');
auto isAlpha = [](char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); };
auto isSchemeChar = [&](char c) { return isAlpha(c) || (c >= '0' && c <= '9') || c == '+' || c == '-' || c == '.'; };
if (colon != std::string::npos && colon > 0 && isAlpha(url[0])
&& std::ranges::all_of(std::string_view(url).substr(0, colon), isSchemeChar)) {
parts.scheme = ToLowerAscii(std::string_view(url).substr(0, colon));
url.erase(0, colon + 1);
}
if (url.starts_with("//")) {
std::size_t end = url.find_first_of("/?#", 2);
if (end == std::string::npos) end = url.size();
parts.netloc = url.substr(2, end - 2);
url.erase(0, end);
const bool open = parts.netloc.contains('[');
const bool close = parts.netloc.contains(']');
if (open != close) throw std::invalid_argument("Invalid IPv6 URL");
if (open && close) {
// _check_bracketed_netloc: the brackets may sit in the user
// part, and then the host is checked as an address all the same.
const std::string hostAndPort = parts.netloc.substr(parts.netloc.rfind('@') == std::string::npos ? 0 : parts.netloc.rfind('@') + 1);
const std::size_t bracket = hostAndPort.find('[');
if (bracket == std::string::npos) {
CheckBracketedHost(hostAndPort.substr(0, hostAndPort.find(':')));
} else {
if (bracket != 0) throw std::invalid_argument("Invalid IPv6 URL");
const std::string bracketed = hostAndPort.substr(1);
const std::size_t closing = bracketed.find(']');
const std::string after = closing == std::string::npos ? std::string() : bracketed.substr(closing + 1);
if (!after.empty() && !after.starts_with(':')) throw std::invalid_argument("Invalid IPv6 URL");
CheckBracketedHost(bracketed.substr(0, closing));
}
}
}
if (const std::size_t hash = url.find('#'); hash != std::string::npos) {
parts.fragment = url.substr(hash + 1);
url.erase(hash);
}
if (const std::size_t question = url.find('?'); question != std::string::npos) {
parts.query = url.substr(question + 1);
url.erase(question);
}
parts.path = std::move(url);
return parts;
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
std::string UrlUnsplit(std::string_view scheme, std::string_view netloc, std::string_view path, std::string_view query, std::string_view fragment) {
// The schemes that get `//` before an empty netloc.
constexpr std::array<std::string_view, 27> UsesNetloc = {
"", "ftp", "http", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "https", "shttp", "snews", "prospero",
"rtsp", "rtsps", "rtspu", "rsync", "svn", "svn+ssh", "sftp", "nfs", "git", "git+ssh", "ws", "wss", "itms-services",
};
bool hasNetloc = !netloc.empty();
if (!hasNetloc && !scheme.empty() && std::ranges::contains(UsesNetloc, scheme) && (path.empty() || path.starts_with('/'))) {
hasNetloc = true;
}
std::string url(path);
if (hasNetloc) {
if (!url.empty() && !url.starts_with('/')) url.insert(0, "/");
url = std::format("//{}{}", netloc, url);
} else if (url.starts_with("//")) {
url.insert(0, "//");
}
if (!scheme.empty()) url = std::format("{}:{}", scheme, url);
if (!query.empty()) url = std::format("{}?{}", url, query);
if (!fragment.empty()) url = std::format("{}#{}", url, fragment);
return url;
}
std::string Unquote(std::string_view text) {
if (!text.contains('%')) return std::string(text);
// Percent sequences become bytes; runs of bytes are decoded as UTF-8
// with bad bytes replaced; everything else is kept as it is.
std::string out;
std::string bytes;
auto hexValue = [](char c) -> std::int32_t {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
for (std::size_t i = 0; i < text.size();) {
if (text[i] == '%' && i + 2 < text.size() && hexValue(text[i + 1]) >= 0 && hexValue(text[i + 2]) >= 0) {
bytes.push_back(static_cast<char>(hexValue(text[i + 1]) * 16 + hexValue(text[i + 2])));
i += 3;
continue;
}
if (!bytes.empty()) {
out += DecodeReplace(bytes);
bytes.clear();
}
out.push_back(text[i]);
++i;
}
if (!bytes.empty()) out += DecodeReplace(bytes);
return out;
}
NaturalKey Natural(std::string_view name) {
NaturalKey key;
std::string text;
std::int64_t number = 0;
bool inDigits = false;
for (std::size_t at = 0; at < name.size();) {
const auto [c, used] = Next(name, at);
const std::int32_t digit = DecimalValue(c);
if (digit >= 0) {
if (!inDigits) {
key.parts.emplace_back(text);
text.clear();
number = 0;
inDigits = true;
}
number = number * 10 + digit;
} else {
if (inDigits) {
key.parts.emplace_back(number);
inDigits = false;
}
text.append(name.substr(at, used));
}
at += used;
}
if (inDigits) {
key.parts.emplace_back(number);
text.clear();
}
key.parts.emplace_back(text);
return key;
}
std::string NormPath(std::string_view path) {
if (path.empty()) return ".";
std::size_t leading = 0;
while (leading < path.size() && path[leading] == '/') ++leading;
// POSIX gives exactly two leading slashes a meaning of their own.
const std::string prefix = leading == 0 ? "" : (leading == 2 ? "//" : "/");
std::vector<std::string> kept;
for (const std::string& component : PathComponents(path)) {
if (component == ".") continue;
if (component == "..") {
if (!kept.empty() && kept.back() != "..") {
kept.pop_back();
} else if (prefix.empty()) {
kept.push_back(component);
}
continue;
}
kept.push_back(component);
}
std::string out = prefix + Join(kept, "/");
return out.empty() ? std::string(".") : out;
}
std::string AbsPath(std::string_view path) {
if (path.starts_with('/')) return NormPath(path);
return NormPath(JoinPath(std::filesystem::current_path().string(), path));
}
std::string RelPath(std::string_view path, std::string_view start) {
if (path.empty()) throw std::invalid_argument("no path specified");
const std::vector<std::string> from = PathComponents(AbsPath(start.empty() ? "." : start));
const std::vector<std::string> to = PathComponents(AbsPath(path));
std::size_t common = 0;
while (common < from.size() && common < to.size() && from[common] == to[common]) ++common;
std::vector<std::string> relative(from.size() - common, "..");
relative.insert(relative.end(), to.begin() + static_cast<std::ptrdiff_t>(common), to.end());
if (relative.empty()) return ".";
return Join(relative, "/");
}
std::string BaseName(std::string_view path) {
return std::string(path.substr(path.rfind('/') == std::string_view::npos ? 0 : path.rfind('/') + 1));
}
std::string DirName(std::string_view path) {
const std::size_t slash = path.rfind('/');
if (slash == std::string_view::npos) return "";
std::string head(path.substr(0, slash + 1));
if (!std::ranges::all_of(head, [](char c) { return c == '/'; })) {
while (!head.empty() && head.back() == '/') head.pop_back();
}
return head;
}
std::string JoinPath(std::string_view a, std::string_view b) {
if (b.starts_with('/')) return std::string(b);
if (a.empty() || a.ends_with('/')) return std::string(a) + std::string(b);
return std::format("{}/{}", std::string(a), std::string(b));
}
std::string ErrnoText(std::int32_t code) {
return std::format("[Errno {}] {}", code, std::strerror(code));
}
feat(pipeline): the pure pipeline, checked against its goldens The modules that turn a frozen range into passes and passes into a verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the config and the pool both read them), :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL half of :PgWire. Units, jobs, results, the config and the claude stream stay JSON from end to end, so every key keeps its place; :Text gives JSON values truthiness, get, ==, membership and Str(), and throws Dyn::Error under a name for each kind of unexpected data. Exactness where the bytes are seen: - the stream classifiers are hand-written scanners with case-insensitive folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at \n; the SQL call detection takes `<exe> sql` with any path before it; - config problems carry the JSON scanner's and the UTF-8 decoder's own texts, positions included; - a float total is Neumaier's compensated sum, and the build passes -ffp-contract=off: -march=native would fuse a*b+c; - `crafter-review inventory` and `config` print what the goldens record, byte for byte (the fixture and 14 configs, broken ones included); a message names this binary as the command to run. tests/Goldens: the recorded answers over the fixtures next to them (the commits fixture, a rich versioned series with CRLF, a folded subject, Markdown headings in and out of fences, the code-key rewrites); the suite compares 2304 results as DumpFile writes them, so a key out of order fails too. All agree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
Error::Error(std::string_view kind, const std::string& message) : std::runtime_error(message), kind(kind) {}
const std::string& Error::Kind() const {
return kind;
}
bool Truthy(const Json& value) {
switch (value.type()) {
case Json::value_t::null:
return false;
case Json::value_t::boolean:
return value.get<bool>();
case Json::value_t::number_integer:
return value.get<std::int64_t>() != 0;
case Json::value_t::number_unsigned:
return value.get<std::uint64_t>() != 0;
case Json::value_t::number_float:
// NaN is true: it is not 0.0.
return value.get<double>() != 0.0;
case Json::value_t::string:
return !value.get_ref<const std::string&>().empty();
case Json::value_t::array:
case Json::value_t::object:
return !value.empty();
default:
return true;
}
}
const Json& Or(const Json& value, const Json& fallback) {
return Truthy(value) ? value : fallback;
}
const Json& EmptyDict() {
static const Json empty = Json::object();
return empty;
}
const Json& EmptyList() {
static const Json empty = Json::array();
return empty;
}
bool Has(const Json& dict, std::string_view key) {
return dict.is_object() && dict.find(key) != dict.end();
}
const Json& Get(const Json& dict, std::string_view key) {
static const Json none;
if (!dict.is_object()) throw Error("AttributeError", std::format("'{}' object has no attribute 'get'", TypeName(dict)));
const auto found = dict.find(key);
return found == dict.end() ? none : *found;
}
const Json& At(const Json& dict, std::string_view key) {
if (dict.is_object()) {
const auto found = dict.find(key);
if (found == dict.end()) throw Error("KeyError", Repr(key));
return *found;
}
if (dict.is_array()) throw Error("TypeError", "list indices must be integers or slices, not str");
if (dict.is_string()) throw Error("TypeError", "string indices must be integers, not 'str'");
throw Error("TypeError", std::format("'{}' object is not subscriptable", TypeName(dict)));
}
std::string_view TypeName(const Json& value) {
switch (value.type()) {
case Json::value_t::null:
return "NoneType";
case Json::value_t::boolean:
return "bool";
case Json::value_t::number_integer:
case Json::value_t::number_unsigned:
return "int";
case Json::value_t::number_float:
return "float";
case Json::value_t::string:
return "str";
case Json::value_t::array:
return "list";
case Json::value_t::object:
return "dict";
default:
return "bytes";
}
}
bool IsInt(const Json& value) {
return value.is_boolean() || value.is_number_integer();
}
bool IsNumber(const Json& value) {
return value.is_boolean() || value.is_number();
}
std::string Str(const Json& value) {
if (value.is_string()) return value.get<std::string>();
return ReprValue(value);
}
std::string StrOr(const Json& value, std::string_view fallback) {
return Truthy(value) ? Str(value) : std::string(fallback);
}
namespace {
// An int of a JSON value: a bool is 0 or 1. Unsigned values beyond
// the signed range keep their own type in Equal().
std::int64_t IntOf(const Json& value) {
if (value.is_boolean()) return value.get<bool>() ? 1 : 0;
if (value.is_number_unsigned()) return static_cast<std::int64_t>(value.get<std::uint64_t>());
return value.get<std::int64_t>();
}
// An integer equals a float exactly, never by rounding the integer to
// a double.
bool IntEqualsFloat(const Json& integer, double f) {
if (!std::isfinite(f) || f != std::floor(f)) return false;
if (integer.is_number_unsigned() && integer.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
if (f < 0.0 || f >= 18446744073709551616.0) return false;
return static_cast<std::uint64_t>(f) == integer.get<std::uint64_t>();
}
if (f < -9223372036854775808.0 || f >= 9223372036854775808.0) return false;
return static_cast<std::int64_t>(f) == IntOf(integer);
}
}
bool Equal(const Json& a, const Json& b) {
if (IsNumber(a) && IsNumber(b)) {
if (a.is_number_float() && b.is_number_float()) return a.get<double>() == b.get<double>();
if (a.is_number_float()) return IntEqualsFloat(b, a.get<double>());
if (b.is_number_float()) return IntEqualsFloat(a, b.get<double>());
const bool bigA = a.is_number_unsigned() && a.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max());
const bool bigB = b.is_number_unsigned() && b.get<std::uint64_t>() > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max());
if (bigA || bigB) return bigA && bigB && a.get<std::uint64_t>() == b.get<std::uint64_t>();
return IntOf(a) == IntOf(b);
}
if (a.type() != b.type()) return false;
if (a.is_array()) {
if (a.size() != b.size()) return false;
for (std::size_t i = 0; i < a.size(); ++i) {
if (!Equal(a[i], b[i])) return false;
}
return true;
}
if (a.is_object()) {
if (a.size() != b.size()) return false;
for (auto it = a.begin(); it != a.end(); ++it) {
const auto other = b.find(it.key());
if (other == b.end() || !Equal(it.value(), *other)) return false;
}
return true;
}
return a == b;
}
bool In(const Json& item, const Json& container) {
if (container.is_array()) {
return std::ranges::any_of(container, [&](const Json& element) { return Equal(item, element); });
}
if (container.is_object()) {
if (item.is_array() || item.is_object()) throw Error("TypeError", std::format("unhashable type: '{}'", TypeName(item)));
return item.is_string() && container.find(item.get_ref<const std::string&>()) != container.end();
}
if (container.is_string()) {
if (!item.is_string()) throw Error("TypeError", std::format("'in <string>' requires string as left operand, not {}", TypeName(item)));
return container.get_ref<const std::string&>().contains(item.get_ref<const std::string&>());
}
throw Error("TypeError", std::format("argument of type '{}' is not a container or iterable", TypeName(container)));
}
Json Add(const Json& a, const Json& b) {
if (!IsNumber(a) || !IsNumber(b)) {
throw Error("TypeError", std::format("unsupported operand type(s) for +: '{}' and '{}'", TypeName(a), TypeName(b)));
}
if (a.is_number_float() || b.is_number_float()) {
auto asDouble = [](const Json& value) { return value.is_number_float() ? value.get<double>() : static_cast<double>(IntOf(value)); };
return Json(asDouble(a) + asDouble(b));
}
return Json(IntOf(a) + IntOf(b));
}
double Sum(std::span<const double> values) {
if (values.empty()) return 0.0;
// sum() starts from the int 0; 0 + x is x (and 0.0 for -0.0).
double total = 0.0 + values.front();
double compensation = 0.0;
for (const double x : values.subspan(1)) {
const double t = total + x;
if (std::fabs(total) >= std::fabs(x)) {
compensation += (total - t) + x;
} else {
compensation += (x - t) + total;
}
total = t;
}
if (compensation != 0.0 && std::isfinite(compensation)) total += compensation;
return total;
}
}