334 lines
13 KiB
C++
334 lines
13 KiB
C++
/*
|
|
catcrafts.net
|
|
Copyright (C) 2026 Catcrafts
|
|
|
|
The source code of this website is made available for viewing purposes only.
|
|
No permission is granted to copy, modify, distribute, or create derivative works.
|
|
*/
|
|
|
|
// A small, strict JSON reader.
|
|
//
|
|
// Why not vendor nlohmann/json: Catcrafts.Shared may import `std` and nothing
|
|
// else (see Catcrafts.Shared.cppm for why that boundary is absolute), and
|
|
// json.hpp wants a global module fragment plus exceptions — the wasm build is
|
|
// -fno-exceptions. This reads the one document shape the site actually needs
|
|
// (content/posts.json, generated by CI from the Lemmy API) and refuses
|
|
// everything else loudly.
|
|
//
|
|
// Deliberately NOT a general-purpose parser. No streaming, no comments, no
|
|
// trailing commas, no big-number handling beyond int64. Errors are values,
|
|
// never exceptions, and a malformed document yields an error rather than a
|
|
// partial parse — a half-read post list rendering as a broken page is worse
|
|
// than an empty one.
|
|
|
|
export module Catcrafts.Shared:Json;
|
|
import std;
|
|
|
|
namespace Catcrafts::Json {
|
|
|
|
export enum class Type { Null, Bool, Number, String, Array, Object };
|
|
|
|
export class Value {
|
|
public:
|
|
Type type = Type::Null;
|
|
bool boolean = false;
|
|
double number = 0;
|
|
std::string string;
|
|
std::vector<Value> array;
|
|
// A vector rather than a map: object key order is preserved (useful when
|
|
// re-emitting) and these documents have a handful of keys, so linear
|
|
// lookup beats hashing.
|
|
std::vector<std::pair<std::string, Value>> object;
|
|
|
|
bool IsNull() const { return type == Type::Null; }
|
|
bool IsArray() const { return type == Type::Array; }
|
|
bool IsObject() const { return type == Type::Object; }
|
|
|
|
// Object lookup. Returns nullptr when absent, so callers distinguish
|
|
// "missing" from "present but null" without a second query.
|
|
const Value* Find(std::string_view key) const {
|
|
if (type != Type::Object) return nullptr;
|
|
for (const auto& [k, v] : object) {
|
|
if (k == key) return &v;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
// Typed accessors with a fallback. CI generates the input, so a missing
|
|
// or wrong-typed field is a bug in the generator rather than something a
|
|
// page render should abort over — default and carry on, and let the
|
|
// generator's own validation catch it.
|
|
std::string_view Str(std::string_view key, std::string_view fallback = {}) const {
|
|
const Value* v = Find(key);
|
|
return (v && v->type == Type::String) ? std::string_view(v->string) : fallback;
|
|
}
|
|
std::int64_t Int(std::string_view key, std::int64_t fallback = 0) const {
|
|
const Value* v = Find(key);
|
|
return (v && v->type == Type::Number) ? static_cast<std::int64_t>(v->number) : fallback;
|
|
}
|
|
bool Bool(std::string_view key, bool fallback = false) const {
|
|
const Value* v = Find(key);
|
|
return (v && v->type == Type::Bool) ? v->boolean : fallback;
|
|
}
|
|
};
|
|
|
|
export struct ParseError {
|
|
std::string message;
|
|
std::size_t offset = 0;
|
|
};
|
|
|
|
export using ParseResult = std::expected<Value, ParseError>;
|
|
|
|
namespace {
|
|
|
|
struct Parser {
|
|
std::string_view s;
|
|
std::size_t i = 0;
|
|
|
|
std::unexpected<ParseError> Fail(std::string msg) {
|
|
return std::unexpected(ParseError{ std::move(msg), i });
|
|
}
|
|
|
|
void SkipWhitespace() {
|
|
while (i < s.size()) {
|
|
const char c = s[i];
|
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') ++i;
|
|
else break;
|
|
}
|
|
}
|
|
|
|
bool Literal(std::string_view lit) {
|
|
if (s.size() - i < lit.size()) return false;
|
|
if (s.compare(i, lit.size(), lit) != 0) return false;
|
|
i += lit.size();
|
|
return true;
|
|
}
|
|
|
|
// Appends the UTF-8 encoding of a code point. JSON escapes are UTF-16,
|
|
// so astral characters arrive as a surrogate pair and must be combined
|
|
// before encoding — emitting each half separately produces invalid UTF-8
|
|
// that will render as replacement characters (emoji in post titles are
|
|
// exactly this case).
|
|
static void AppendUtf8(std::string& out, char32_t cp) {
|
|
if (cp <= 0x7F) {
|
|
out += static_cast<char>(cp);
|
|
} else if (cp <= 0x7FF) {
|
|
out += static_cast<char>(0xC0 | (cp >> 6));
|
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
|
} else if (cp <= 0xFFFF) {
|
|
out += static_cast<char>(0xE0 | (cp >> 12));
|
|
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
|
} else {
|
|
out += static_cast<char>(0xF0 | (cp >> 18));
|
|
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
|
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
|
out += static_cast<char>(0x80 | (cp & 0x3F));
|
|
}
|
|
}
|
|
|
|
std::optional<char32_t> Hex4() {
|
|
if (s.size() - i < 4) return std::nullopt;
|
|
char32_t v = 0;
|
|
for (int k = 0; k < 4; ++k) {
|
|
const char c = s[i + k];
|
|
int d;
|
|
if (c >= '0' && c <= '9') d = c - '0';
|
|
else if (c >= 'a' && c <= 'f') d = c - 'a' + 10;
|
|
else if (c >= 'A' && c <= 'F') d = c - 'A' + 10;
|
|
else return std::nullopt;
|
|
v = v * 16 + static_cast<char32_t>(d);
|
|
}
|
|
i += 4;
|
|
return v;
|
|
}
|
|
|
|
std::expected<std::string, ParseError> ParseString() {
|
|
if (i >= s.size() || s[i] != '"') return Fail("expected string");
|
|
++i;
|
|
std::string out;
|
|
while (true) {
|
|
if (i >= s.size()) return Fail("unterminated string");
|
|
const char c = s[i];
|
|
if (c == '"') { ++i; return out; }
|
|
if (c == '\\') {
|
|
++i;
|
|
if (i >= s.size()) return Fail("unterminated escape");
|
|
const char e = s[i++];
|
|
switch (e) {
|
|
case '"': out += '"'; break;
|
|
case '\\': out += '\\'; break;
|
|
case '/': out += '/'; break;
|
|
case 'b': out += '\b'; break;
|
|
case 'f': out += '\f'; break;
|
|
case 'n': out += '\n'; break;
|
|
case 'r': out += '\r'; break;
|
|
case 't': out += '\t'; break;
|
|
case 'u': {
|
|
auto hi = Hex4();
|
|
if (!hi) return Fail("bad \\u escape");
|
|
char32_t cp = *hi;
|
|
if (cp >= 0xD800 && cp <= 0xDBFF) {
|
|
// High surrogate: a low surrogate must follow.
|
|
if (i + 1 < s.size() && s[i] == '\\' && s[i + 1] == 'u') {
|
|
const std::size_t save = i;
|
|
i += 2;
|
|
auto lo = Hex4();
|
|
if (lo && *lo >= 0xDC00 && *lo <= 0xDFFF) {
|
|
cp = 0x10000 + ((cp - 0xD800) << 10) + (*lo - 0xDC00);
|
|
} else {
|
|
i = save;
|
|
cp = 0xFFFD; // lone high surrogate
|
|
}
|
|
} else {
|
|
cp = 0xFFFD;
|
|
}
|
|
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
|
|
cp = 0xFFFD; // stray low surrogate
|
|
}
|
|
AppendUtf8(out, cp);
|
|
break;
|
|
}
|
|
default: return Fail("unknown escape");
|
|
}
|
|
continue;
|
|
}
|
|
// Unescaped control characters are invalid JSON; rejecting them
|
|
// keeps a truncated/corrupted file from parsing as valid.
|
|
if (static_cast<unsigned char>(c) < 0x20) return Fail("control character in string");
|
|
out += c;
|
|
++i;
|
|
}
|
|
}
|
|
|
|
// RFC 8259 number grammar, validated explicitly:
|
|
//
|
|
// number = [ "-" ] int [ frac ] [ exp ]
|
|
// int = "0" / ( digit1-9 *DIGIT )
|
|
// frac = "." 1*DIGIT
|
|
// exp = ("e"/"E") [ "-" / "+" ] 1*DIGIT
|
|
//
|
|
// Scanning the character set and handing the span to from_chars is NOT
|
|
// equivalent: from_chars accepts "01" and "+1", both of which are invalid
|
|
// JSON. Since the point of this parser is to reject corrupted input rather
|
|
// than guess at it, the grammar is checked before conversion.
|
|
std::expected<double, ParseError> ParseNumber() {
|
|
const std::size_t start = i;
|
|
auto digit = [&] { return i < s.size() && s[i] >= '0' && s[i] <= '9'; };
|
|
|
|
if (i < s.size() && s[i] == '-') ++i; // leading '+' is not JSON
|
|
|
|
if (!digit()) return Fail("expected digit");
|
|
if (s[i] == '0') {
|
|
++i;
|
|
// "0" may not be followed by another digit — "01" is invalid.
|
|
if (digit()) return Fail("leading zero");
|
|
} else {
|
|
while (digit()) ++i;
|
|
}
|
|
|
|
if (i < s.size() && s[i] == '.') {
|
|
++i;
|
|
if (!digit()) return Fail("expected digit after '.'");
|
|
while (digit()) ++i;
|
|
}
|
|
|
|
if (i < s.size() && (s[i] == 'e' || s[i] == 'E')) {
|
|
++i;
|
|
if (i < s.size() && (s[i] == '+' || s[i] == '-')) ++i;
|
|
if (!digit()) return Fail("expected digit in exponent");
|
|
while (digit()) ++i;
|
|
}
|
|
|
|
double out = 0;
|
|
const char* b = s.data() + start;
|
|
const char* e = s.data() + i;
|
|
const auto [ptr, ec] = std::from_chars(b, e, out);
|
|
// Out-of-range is the one case the grammar allows but the type can't
|
|
// hold (1e400). Treat it as malformed rather than silently infinite.
|
|
if (ec != std::errc{} || ptr != e) return Fail("number out of range");
|
|
return out;
|
|
}
|
|
|
|
// Recursion is bounded so a hostile or corrupted document can't blow the
|
|
// stack. Real input here nests two levels (array of flat objects).
|
|
ParseResult ParseValue(int depth) {
|
|
if (depth > 32) return Fail("nesting too deep");
|
|
SkipWhitespace();
|
|
if (i >= s.size()) return Fail("unexpected end of input");
|
|
|
|
Value v;
|
|
const char c = s[i];
|
|
|
|
if (c == '"') {
|
|
auto str = ParseString();
|
|
if (!str) return std::unexpected(str.error());
|
|
v.type = Type::String;
|
|
v.string = std::move(*str);
|
|
return v;
|
|
}
|
|
if (c == '{') {
|
|
++i;
|
|
v.type = Type::Object;
|
|
SkipWhitespace();
|
|
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
|
while (true) {
|
|
SkipWhitespace();
|
|
auto key = ParseString();
|
|
if (!key) return std::unexpected(key.error());
|
|
SkipWhitespace();
|
|
if (i >= s.size() || s[i] != ':') return Fail("expected ':'");
|
|
++i;
|
|
auto val = ParseValue(depth + 1);
|
|
if (!val) return std::unexpected(val.error());
|
|
v.object.emplace_back(std::move(*key), std::move(*val));
|
|
SkipWhitespace();
|
|
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
|
if (i < s.size() && s[i] == '}') { ++i; return v; }
|
|
return Fail("expected ',' or '}'");
|
|
}
|
|
}
|
|
if (c == '[') {
|
|
++i;
|
|
v.type = Type::Array;
|
|
SkipWhitespace();
|
|
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
|
while (true) {
|
|
auto item = ParseValue(depth + 1);
|
|
if (!item) return std::unexpected(item.error());
|
|
v.array.push_back(std::move(*item));
|
|
SkipWhitespace();
|
|
if (i < s.size() && s[i] == ',') { ++i; continue; }
|
|
if (i < s.size() && s[i] == ']') { ++i; return v; }
|
|
return Fail("expected ',' or ']'");
|
|
}
|
|
}
|
|
if (Literal("true")) { v.type = Type::Bool; v.boolean = true; return v; }
|
|
if (Literal("false")) { v.type = Type::Bool; v.boolean = false; return v; }
|
|
if (Literal("null")) { v.type = Type::Null; return v; }
|
|
|
|
auto num = ParseNumber();
|
|
if (!num) return std::unexpected(num.error());
|
|
v.type = Type::Number;
|
|
v.number = *num;
|
|
return v;
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// Parse a complete JSON document. Trailing content after the top-level value
|
|
// is an error rather than ignored — it usually means a truncated or
|
|
// concatenated file, and silently accepting the prefix hides that.
|
|
export ParseResult Parse(std::string_view text) {
|
|
Parser p{ text, 0 };
|
|
auto v = p.ParseValue(0);
|
|
if (!v) return v;
|
|
p.SkipWhitespace();
|
|
if (p.i != text.size()) {
|
|
return std::unexpected(ParseError{ "trailing content after JSON value", p.i });
|
|
}
|
|
return v;
|
|
}
|
|
|
|
} // namespace Catcrafts::Json
|