/
nv-lang
/
nova
Обзор
Документация
Войти
/
nv-lang
/
nova
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
std/src/encoding/json.nv
1 135 строк
47 KB
Evgeniy Golovin
fix(221.1, №433): Lexer.char_at возвращает Option, не бросает !! на конце ввода
07 авг 2026, 18:02
07 авг 2026, 18:02
34f69a3
Код
Авторство
О чём код?
//! Plan 45 Ф.35.3 — JSON parser/serializer per RFC 8259. //! //! Independent implementation — not a port. Covers all JSON types (null, //! bool, number, string, array, object), escape sequences (including //! surrogate pairs for emoji), any number format (integer/fraction/ //! exponent). Deliberately unsupported: comments, trailing commas, //! single quotes, NaN/Infinity, unquoted keys — those are JSON5/JSONC, //! separate formats. //! //! Round-trip property: `Json.parse(v.to_str())!! == v` for any `v`. //! //! # Examples //! //! ```nova //! let v = Json.parse("{\"name\": \"alice\", \"age\": 30}")!! //! match v.object() { //! Some(m) => assert(m.get("name") == Some(Str("alice"))) //! None => assert(false) //! } //! ``` module encoding.json // json.nv использует HashMap[K, V] в return types и fn-body literals // (Object variant + Json.parse). HashMap живёт в std/collections/hashmap.nv // и НЕ в std.prelude. Без явного `import` peer_files codegen-prepass // не подтягивает hashmap.nv → HashMap не регистрируется в // `generic_type_templates` → fn-signature emission для `object()` эмитит // erased `NovaOpt_nova_int` вместо concrete `NovaOpt_Nova_HashMap_p`, // body-pass позже видит concrete тип → mismatch CC-FAIL. // // Fix: explicit `import` — peer_files pipeline загружает hashmap.nv, // и prepass регистрирует HashMap как concrete generic template. Это // правильное (а не workaround) решение: json.nv действительно // ИСПОЛЬЗУЕТ HashMap, поэтому ДОЛЖЕН его импортировать // (explicit-imports principle). import std.collections.hash_map.{HashMap} // Plan p320 Фаза 3 (реестр 221.1 №319): `JsonObject` used to hand-roll its // own "ordered map" (`key_order []str` + `HashMap[str, JsonValue]`) — now a // thin alias over the reusable `std.collections.index_map.IndexMap[K, V]` // (Фаза 2, №320-unblocked). `import`ed explicitly for the same reason // `HashMap` above is — peer_files codegen prepass needs it registered in // `generic_type_templates` before `JsonObject`'s alias target resolves. import std.collections.index_map.{IndexMap} // [M-json-encode-record-field-order-nondeterministic] (реестр 221.1 №148): // `HashMap[str, JsonValue]` не хранит порядок вставки, а итерация хеш-таблицы // НЕДЕТЕРМИНИРОВАНА между процессами (`nova_str_hash` зовёт рандомизированный // per-process siphash-seed — HashDoS-защита, `nova_rt.h` — трогать НЕЛЬЗЯ). // // Plan p320 Фаза 3 (реестр 221.1 №319, было `[M-jsonobject-key-dup- // orderedmap]`): `JsonObject` USED TO hand-roll its own ordered-map shape // (`key_order []str` + `HashMap[str, JsonValue]`, duplicating every VALUE — // once in the hash bucket, once effectively addressed via key_order) — now a // thin `alias` over the reusable `std.collections.index_map.IndexMap[str, // JsonValue]` (Фаза 2). `JsonValue.Object`'s payload is `IndexMap[str, // JsonValue]` DIRECTLY (see the `enum` below) — `JsonObject` is kept ONLY as // a public, readable alias name for existing call sites // (`JsonValue.Object(obj)`, doc comments) — D52 alias form, zero-cost (same // C repr as the aliased type). // // Honest note on method delegation: a `type X alias Y` alias is transparent // for the TYPE CHECKER (assignability — `ro v: Y = x` where `x: X` type- // checks) but NOT for METHOD NAME RESOLUTION — `x.some_method()` where // `some_method` is declared on `Y`, not `X`, does NOT resolve through the // alias (confirmed empirically before writing this: a bare alias with no // re-declared methods sends `.insert(...)` into an unrelated same-named // method on a completely different type in the same compile-unit instead of // erroring OR forwarding). So every method `JsonObject` used to expose is // re-declared here as a THIN delegate — re-type `@`/args into // `IndexMap[str, JsonValue]` via a local binding (free, same repr), call the // real implementation, done. Not a copy of the implementation — every line // of actual map logic lives ONLY in `std/src/collections/index_map/core.nv`. /// Ordered JSON-object map — see the module-level Фаза 3 note above. Iteration /// (`@iter()`, `for (k, v) in obj`) always follows insertion order — NOT hash /// order. Re-inserting an existing key updates the value and does NOT move /// the key's position (like `HashMap.insert` / `IndexMap.insert`). #stable(since = "0.1") type JsonObject alias IndexMap[str, JsonValue] /// Create an empty `JsonObject`. #stable(since = "0.1") export fn JsonObject.new() -> Self => IndexMap[str, JsonValue].new() /// Number of key/value pairs in the object. #stable(since = "0.1") export fn JsonObject @len() -> int { ro m IndexMap[str, JsonValue] = @ m.len() } /// `true` if the object is empty. #stable(since = "0.1") export fn JsonObject @is_empty() -> bool { ro m IndexMap[str, JsonValue] = @ m.is_empty() } /// Look up a value by key. #stable(since = "0.1") export fn JsonObject @get(key str) -> Option[JsonValue] { ro m IndexMap[str, JsonValue] = @ m.get(key) } /// Check whether a key is present. #stable(since = "0.1") export fn JsonObject @contains(key str) -> bool { ro m IndexMap[str, JsonValue] = @ m.contains(key) } /// Insert/update the `(key, value)` pair. Returns `Some(old_value)` if the /// key already existed (the value is overwritten and the insertion-order /// position is NOT changed — like `HashMap.insert`), otherwise `None` (the /// key is appended to the end of the insertion order). #stable(since = "0.1") export fn JsonObject mut @insert(key str, value JsonValue) -> Option[JsonValue] { mut m IndexMap[str, JsonValue] = @ ro old = m.insert(key, value) @ = m old } /// Keys in insertion order. #stable(since = "0.1") export fn JsonObject @keys() -> []str { ro m IndexMap[str, JsonValue] = @ m.keys() } /// Order-independent structural equality (D109/D237 `Equal`-impl) — /// mirrors `HashMap[K, V] @equal` / `IndexMap[K, V] @equal`. Semantic /// equality of JSON objects does not depend on field order (only /// SERIALIZATION order is deterministic — a separate property). #stable(since = "0.1") export fn JsonObject @equal(other JsonObject) -> bool { ro m IndexMap[str, JsonValue] = @ ro om IndexMap[str, JsonValue] = other m.equal(om) } /// Iterator over `(key, value)` pairs IN INSERTION ORDER (not hash order) — /// a thin delegate re-typed to `IndexMapIter[str, JsonValue]` (Фаза 2); kept /// under the `JsonObjectIter` name for source-compat with existing callers. type JsonObjectIter alias IndexMapIter[str, JsonValue] #stable(since = "0.1") export fn JsonObject @iter() -> JsonObjectIter { ro m IndexMap[str, JsonValue] = @ m.iter() } export fn JsonObjectIter mut @next() -> Option[(str, JsonValue)] { mut it IndexMapIter[str, JsonValue] = @ ro r = it.next() @ = it r } /// JsonValue — a recursive sum type for an arbitrary JSON document. /// /// Supports D55 record-coercion: a `{ "k": v }` literal in a `JsonValue` /// position is coerced into `Object`. Supports structural equality /// (round-trip tests rely on `==`). /// /// Plan p320 Фаза 4 (реестр 221.1 №319, confirmed 2026-08-04, once the /// generic sum-lift fix landed — реестр №320): a BARE `IndexMap[str, /// JsonValue]` value at a `JsonValue`-expected RETURN or CALL-ARG position /// now auto-wraps into `Object(...)` WITHOUT calling `.object()` at all — /// `Object` is `JsonValue`'s only unary variant whose payload's `WrapKind` /// is `Named("IndexMap")`, unambiguous among the other unary variants /// (`Bool`/`Num`/`Str`/`Array` are `Bool`/`Float`/`Str`/`Array(..)` kinds). /// `.object()` is KEPT as an explicit, readable, `#stable` name (not /// retracted) — the auto-wrap is an ergonomic ADDITION, not a replacement: /// /// ```nova /// fn wrap(m IndexMap[str, JsonValue]) -> ro JsonValue => m // auto-wraps into Object(m) /// ro v2 = JsonValue.object(m) // still valid, still #stable /// ``` /// /// **Known gaps (Фаза 4, NOT fixed — reported, not attempted, out of №320's /// scope):** (1) LET-INIT position (`ro v JsonValue = m`) does NOT work — /// broken at the codegen level for ANY generic-sum bare-ctor result assigned /// into a let-binding, even hand-written, not specific to sum-lift or to /// `JsonValue`. (2) CALL-ARG auto-wrap does not fire (silently — wrong /// runtime result, not a compile error) when the source is an UNANNOTATED /// `let` built via a turbofish static constructor (`mut m = IndexMap[str, /// JsonValue].new()`) — give the `let` an explicit type annotation to work /// around it. See `spec_tests/conformance/standalone/ /// p320_phase4_jsonvalue_sumlift_pos.nv` for the full repro/diagnosis of /// both gaps. #stable(since = "0.1") export type JsonValue enum | Null | Bool(bool) | Num(f64) | Str(str) | Array([]JsonValue) | Object(IndexMap[str, JsonValue]) /// `ParseJsonError` — categories of JSON-parser errors with position for diagnostics. /// /// D30 convention (`Parse<TypeName>Error`). Every variant carries `line` and /// `col` (1-based) — for IDE squiggles or CLI error messages. #stable(since = "0.1") export type ParseJsonError enum | UnexpectedChar { line int, col int, found char } | UnexpectedEof { line int, col int } | InvalidEscape { line int, col int, seq str } | InvalidUnicode { line int, col int, hex str } | InvalidNumber { line int, col int, text str } | DuplicateKey { line int, col int, key str } | TrailingContent { line int, col int } /// Construct JSON null. Shortcut for `JsonValue.Null`. #stable(since = "0.1") export fn JsonValue.null() -> Self => Null /// Construct JSON boolean. Shortcut for `JsonValue.Bool(b)`. #stable(since = "0.1") export fn JsonValue.bool(b bool) -> Self => Bool(b) /// Construct JSON number (any f64). Uses the full f64 range — for /// integers > 2^53 precision is lost. #stable(since = "0.1") export fn JsonValue.num(n f64) -> Self => Num(n) /// Construct JSON string. Encoding — UTF-8. #stable(since = "0.1") export fn JsonValue.str(s str) -> Self => Str(s) /// Construct JSON array from `[]JsonValue`. #stable(since = "0.1") export fn JsonValue.array(items []JsonValue) -> Self => Array(items) /// Construct a JSON object from `IndexMap[str, JsonValue]`. /// /// **BEHAVIOR CHANGE (Plan p320 Фаза 3, реестр 221.1 №319):** before this /// window, this constructor took a `HashMap[str, JsonValue]` (unordered) and /// emitted keys SORTED (deterministic, but NOT the caller's insertion /// order). `IndexMap[str, JsonValue]` already carries insertion order — no /// sorting is needed or performed anymore, and the SERIALIZED key order is /// now the CALLER's insertion order (matches `JsonValue.Object(obj)` used /// directly, and the record serializer's DECLARATION-order behavior, /// `encoding.serde.json`). A caller who relied on the OLD sorted-key output /// of `.object()` on a `HashMap` sees different — but still deterministic — /// output after migrating to `IndexMap`; migrate call sites deliberately, /// not silently (nova-http/nova-polaris — see the plan window's report). #stable(since = "0.1") export fn JsonValue.object(fields IndexMap[str, JsonValue]) -> Self => Object(fields) // ────────────────────────────────────────────────────────────────────────── // Геттеры / предикаты // ────────────────────────────────────────────────────────────────────────── /// True if the value is a JSON null. #stable(since = "0.1") export fn JsonValue @is_null() -> bool => match @ { Null => true, _ => false } /// True if the value is a JSON boolean. #stable(since = "0.1") export fn JsonValue @is_bool() -> bool => match @ { Bool(_) => true, _ => false } /// True if the value is a JSON number. #stable(since = "0.1") export fn JsonValue @is_num() -> bool => match @ { Num(_) => true, _ => false } /// True if the value is a JSON string. #stable(since = "0.1") export fn JsonValue @is_str() -> bool => match @ { Str(_) => true, _ => false } /// True if the value is a JSON array. #stable(since = "0.1") export fn JsonValue @is_array() -> bool => match @ { Array(_) => true, _ => false } /// True if the value is a JSON object. #stable(since = "0.1") export fn JsonValue @is_object() -> bool => match @ { Object(_) => true, _ => false } /// Extract a bool. `None` if the type is not Bool — no panic, no throw. /// /// # Examples /// ```nova /// assert(Json.parse("true")!!.bool() == Some(true)) /// assert(Json.parse("42")!!.bool() == None) /// ``` #stable(since = "0.1") export fn JsonValue @bool() -> Option[bool] => match @ { Bool(b) => Some(b), _ => None } /// Extract `f64`. `None` if the type is not Num. #stable(since = "0.1") export fn JsonValue @num() -> Option[f64] => match @ { Num(n) => Some(n), _ => None } /// Extract `str`. `None` if the type is not Str. #stable(since = "0.1") export fn JsonValue @str() -> Option[str] => match @ { Str(s) => Some(s), _ => None } /// Extract an array slice. `None` if the type is not Array. #stable(since = "0.1") export fn JsonValue @array() -> Option[[]JsonValue] => match @ { Array(xs) => Some(xs), _ => None } /// Extract an object map. `None` if the type is not Object. #stable(since = "0.1") export fn JsonValue @object() -> Option[JsonObject] => match @ { Object(m) => Some(m), _ => None } // ────────────────────────────────────────────────────────────────────────── // Lexer — token type и tokenization // ────────────────────────────────────────────────────────────────────────── type Token enum | LBrace // { | RBrace // } | LBracket // [ | RBracket // ] | Comma // , | Colon // : | TrueTok | FalseTok | NullTok | NumTok(f64) | StrTok(str) | EofTok // [M-181-json-trailing-content] обход: неопознанный ведущий символ // лексируется в sentinel-токен (с символом) вместо fail-а лексера — // prefetch, завершающий value, УСПЕВАЕТ, и top-level trailing-content // чек (`Json.parse`) классифицирует мусор ПОСЛЕ полного value как // `TrailingContent`; mid-structure потребители конвертят его в // `UnexpectedChar` через catch-all arms (таксономия сохранена). | BadTok(char) type TokenWithPos { ro tok Token ro line int ro col int } // Lexer state — прогресс по входной строке + позиция для диагностики. type Lexer { ro input str mut pos int // байтовая позиция (для slice) mut line int // 1-based mut col int // 1-based } fn Lexer.new(input str) -> Self => { input, pos: 0, line: 1, col: 1 } // @pos — ВСЕГДА байтовый курсор (не codepoint). `@peek()` идёт по байтам, // рассинхронизации с байтовыми срезами `@input[start..@pos]` нет на // non-ASCII входе. JSON-структура (whitespace/punctuation/keywords/numbers) — // всегда ASCII, поэтому байт-как-char валиден для всех сравнений вне строк; // multibyte байты попадают только в @read_string, который их не декодирует // (см. там). // // `@peek()`/@advance() возвращают Option[u8] — честный сырой байт, не // codepoint. (Раньше они маскировали байт под char через // `(byte as int).to_char() ?? ' '` — для UTF-8 лид-байта это был // ФАНТОМНЫЙ Latin-1 символ, не настоящий codepoint; а `?? ' '` был // мёртвым sentinel — байт 0..255 всегда валиден как codepoint.) // // Стиль сравнений — байт-константы (см. блок `const B_*` ниже), НЕ inline // `b == ('x' as u8)`. Изначально выбирался inline char-литерал каст // (разрешён для CHAR-ЛИТЕРАЛА, прецедент std/encoding/base64.nv), но он // ломает компиляцию: match-arm guard, чьё условие заканчивается ГОЛЫМ // parenthesized-выражением прямо перед `=>` (напр. // `Some(b) if b == ('{' as u8) => ...`), компилятор ложно распознаёт как // ретрактированный legacy-lambda `(x) => body` — компиляторный гэп. // `IDENT(args) => ...` (вызов функции/константа без скобок) не триггерит — // отсюда именованные `const B_* u8` вместо inline-каста: guard заканчивается // голым идентификатором, не `)`. // // Места, где реально нужен char (диагностика — BadTok/UnexpectedChar/ // InvalidEscape/InvalidUnicode): ASCII байт (< 0x80, гарантирован guard'ом) // → тотальный `(b as int).to_char()!!`; байт >= 0x80 (потенциальный лид-байт // multibyte UTF-8) → НАСТОЯЩИЙ codepoint через @char_at (не Latin-1-фантом, // см. там). // Байтовые константы структурного JSON (всегда ASCII) — единый стиль // сравнений на весь файл (см. выше). Значение и ASCII- // комментарий на каждой строке. const B_SPACE u8 = 0x20 // ' ' const B_TAB u8 = 0x09 // '\t' const B_LF u8 = 0x0A // '\n' const B_CR u8 = 0x0D // '\r' const B_LBRACE u8 = 0x7B // '{' const B_RBRACE u8 = 0x7D // '}' const B_LBRACKET u8 = 0x5B // '[' const B_RBRACKET u8 = 0x5D // ']' const B_COMMA u8 = 0x2C // ',' const B_COLON u8 = 0x3A // ':' const B_QUOTE u8 = 0x22 // '"' const B_BACKSLASH u8 = 0x5C // '\\' const B_SLASH u8 = 0x2F // '/' const B_MINUS u8 = 0x2D // '-' const B_PLUS u8 = 0x2B // '+' const B_DOT u8 = 0x2E // '.' const B_DIGIT_0 u8 = 0x30 // '0' const B_DIGIT_1 u8 = 0x31 // '1' const B_DIGIT_9 u8 = 0x39 // '9' const B_LOWER_A u8 = 0x61 // 'a' const B_LOWER_B u8 = 0x62 // 'b' (\b escape) const B_LOWER_E u8 = 0x65 // 'e' const B_LOWER_F u8 = 0x66 // 'f' (keyword "false" 1st byte, \f escape) const B_LOWER_N u8 = 0x6E // 'n' (keyword "null" 1st byte, \n escape) const B_LOWER_R u8 = 0x72 // 'r' (\r escape — не путать с B_CR) const B_LOWER_T u8 = 0x74 // 't' (keyword "true" 1st byte, \t escape) const B_LOWER_U u8 = 0x75 // 'u' (\u escape) const B_UPPER_A u8 = 0x41 // 'A' const B_UPPER_E u8 = 0x45 // 'E' const B_UPPER_F u8 = 0x46 // 'F' // Текущий байт (Option, None на конце входа). O(1). Честный байт — не char // (см. выше). fn Lexer @peek() -> Option[u8] { ro bytes = @input.bytes() if @pos >= bytes.len() { None } else { Some(bytes[@pos]) } } // Общая бухгалтерия byte-cursor: @pos += 1 байт + line/col. col считается по // codepoint — инкремент только на лидирующих байтах (не continuation // 10xxxxxx), т.е. multibyte codepoint двигает col на 1, не на N байт. fn Lexer mut @bump(b u8) -> () { if b == 0x0A { @line += 1 @col = 1 } else if (b as int & 0xC0) != 0x80 { @col += 1 } @pos += 1 } fn Lexer mut @advance() -> Option[u8] { ro bytes = @input.bytes() if @pos >= bytes.len() { return None } ro b = bytes[@pos] @bump(b) Some(b) } // Настоящий (не Latin-1-фантом) codepoint на байтовой позиции `p` — только // для диагностики (BadTok/UnexpectedChar/InvalidEscape/InvalidUnicode), где // нужен реальный символ. Требование вызывающего: `p` — граница codepoint // (все вызывающие места лексера — сразу после ASCII-выровненных // @peek()/@advance() шагов). O(1): срез `@input[p..]` + первый codepoint // публичного `chars()`-lens (НЕ приватные `decode_utf8`/`decode_at` из // runtime.string/std.unicode — folder-private, чужой модуль). // // №433: возвращает `Option[char]`, НЕ `char` — `.chars().next()` даёт `None` // не только когда `p` уже за концом входа, но и когда на `p` начинается // ОБОРВАННАЯ multibyte UTF-8-последовательность (лид-байт есть, а // continuation-байтов до конца входа не хватает — обрыв ввода посреди // non-ASCII символа). Раньше здесь стоял `!!`: `Json.parse`/`try_from` // (`-> Result[...]`) на таком входе не возвращали `Err`, а роняли // программу — обман контракта Result. Каждый вызывающий теперь обязан // сам решить, в какую `ParseJsonError` превратить `None` (см. call sites // ниже) — это ОШИБКА РАЗБОРА, а не отказ рантайма. fn Lexer @char_at(p int) -> Option[char] => @input[p..].chars().next() fn Lexer mut @skip_whitespace() -> () { mut done = false while !done { match @peek() { Some(b) if b == B_SPACE || b == B_TAB || b == B_LF || b == B_CR => { @advance() } _ => { done = true } } } } // Прочитать следующий токен. Возвращает Result[TokenWithPos, ParseJsonError] // (Result-everywhere). Lexer.@advance/@peek дают Option (не fallible). fn Lexer mut @next_token() -> Result[TokenWithPos, ParseJsonError] { @skip_whitespace() // record-pattern binding snapshot — 2 of 4 Lexer fields (`input`, // `pos` omitted), so the explicit `..` is required (partial-list rule). ro {line, col, ..} = @ match @peek() { None => Ok({ tok: EofTok, line, col }) Some(b) if b == B_LBRACE => { @advance(); Ok({ tok: LBrace, line, col }) } Some(b) if b == B_RBRACE => { @advance(); Ok({ tok: RBrace, line, col }) } Some(b) if b == B_LBRACKET => { @advance(); Ok({ tok: LBracket, line, col }) } Some(b) if b == B_RBRACKET => { @advance(); Ok({ tok: RBracket, line, col }) } Some(b) if b == B_COMMA => { @advance(); Ok({ tok: Comma, line, col }) } Some(b) if b == B_COLON => { @advance(); Ok({ tok: Colon, line, col }) } Some(b) if b == B_QUOTE => Ok({ tok: StrTok(@read_string()?), line, col }) Some(b) if b == B_LOWER_T => @read_keyword("true", TrueTok, line, col) Some(b) if b == B_LOWER_F => @read_keyword("false", FalseTok, line, col) Some(b) if b == B_LOWER_N => @read_keyword("null", NullTok, line, col) Some(b) if is_num_start(b) => Ok({ tok: NumTok(@read_number()?), line, col }) // [M-181-json-trailing-content] обход: неопознанный ведущий символ → // BadTok sentinel (не жёсткий lex Err). Prefetch, завершающий value, // тогда УСПЕВАЕТ, и trailing-мусор после полного top-level value // классифицируется `TrailingContent` (Json.parse), а BadTok, // достигнутый mid-value/structure, превращается в `UnexpectedChar` // catch-all arms'ами парсера (таксономия неизменна). // Настоящий codepoint (не Latin-1-фантом) через @char_at — @pos ещё // не сдвинут (peek, не advance), позиция валидна. // // №433: `@char_at` может дать `None` (оборванная multibyte- // последовательность на этой позиции) — это ошибка разбора // (`UnexpectedEof`), не отказ. Some(_) => match @char_at(@pos) { Some(bad_char) => { @advance() Ok({ tok: BadTok(bad_char), line, col }) } None => Err(UnexpectedEof { @line, @col }) } } } // Лексер байт-based — сравнения напрямую по u8, без char ASCII-core // делегации (у u8 таких методов нет; char-методы работают на char, не на u8). fn is_num_start(b u8) -> bool => b == B_MINUS || is_digit(b) fn is_digit(b u8) -> bool => b >= B_DIGIT_0 && b <= B_DIGIT_9 fn is_hex_digit(b u8) -> bool => is_digit(b) || (b >= B_LOWER_A && b <= B_LOWER_F) || (b >= B_UPPER_A && b <= B_UPPER_F) // Преобразовать одиночный hex-digit байт в int значение (0..15). // Контракт: вызывающий гарантирует is_hex_digit(b) = true. fn hex_digit_val(b u8) -> int { if b >= B_DIGIT_0 && b <= B_DIGIT_9 { (b as int) - (B_DIGIT_0 as int) } else if b >= B_LOWER_A && b <= B_LOWER_F { 10 + (b as int) - (B_LOWER_A as int) } else { 10 + (b as int) - (B_UPPER_A as int) } } fn Lexer mut @read_keyword(kw str, t Token, line int, col int) -> Result[TokenWithPos, ParseJsonError] { // `kw` — короткий ASCII-литерал ("true"/"false"/"null"); байтовая // итерация (`kw.bytes()`) эквивалентна char-итерации для чистого ASCII // и даёт `u8` напрямую — без char→u8 каста runtime-переменной // (каст `as u8` разрешён только для CharLit, не для `char` из `kw.chars()`). for expected in kw.bytes() { match @peek() { Some(b) if b == expected => { @advance() } // Настоящий codepoint байта-расхождения (не Latin-1-фантом); // @pos ещё на байте (peek, не advance). №433: `None` (обрыв // multibyte-последовательности здесь) — тоже ошибка разбора. Some(_) => return match @char_at(@pos) { Some(c) => Err(UnexpectedChar { @line, @col, found: c }) None => Err(UnexpectedEof { @line, @col }) } // Реальный EOF — нет байта для диагностики; ' ' — placeholder // (не выбор этой правки: found:char требует значение, а // UnexpectedEof — отдельный вариант ошибки, вне текущего скоупа). None => return Err(UnexpectedChar { @line, @col, found: ' ' }) } } Ok({ tok: t, line, col }) } // Чтение JSON-строки в кавычках. Возвращает декодированный str. // // Копим БАЙТЫ напрямую, не через @advance()'s Option[char] — вход уже // валидный UTF-8, decode/re-encode не нужен (и был бы некорректен: // byte 0xC3 — часть multibyte-последовательности, а не codepoint U+00C3). // Литеральные runs между escape-последовательностями копируются одним // batch-append (не char-by-char конкатенацией — убирает второй O(n²) // паттерн, `buf = buf + c.to_str()`). // `buf` — type-level consume (StringBuilder), обязан быть потреблён на // КАЖДОМ exit-point. Ранний `return Err(..)` посреди цикла нарушил бы это — // поэтому ошибка копится в `err` и цикл всегда доходит до одного финального // `buf.into_str()`; Err/Ok решается ПОСЛЕ потребления. fn Lexer mut @read_string() -> Result[str, ParseJsonError] { @advance() // открывающая " consume buf = StringBuilder.new() ro bytes = @input.bytes() mut run_start = @pos mut err Option[ParseJsonError] = None mut done = false while !done { if @pos >= bytes.len() { err = Some(UnexpectedEof { @line, @col }) done = true } else { ro b = bytes[@pos] if b == 0x22 { // '"' — конец строки if @pos > run_start { buf.append(bytes[run_start..@pos]) } @bump(b) done = true } else if b == 0x5C { // '\\' — escape if @pos > run_start { buf.append(bytes[run_start..@pos]) } @bump(b) match @read_escape() { Ok(s) => { buf.append(s); run_start = @pos } Err(e) => { err = Some(e); done = true } } } else { @bump(b) } } } ro s = buf.into_str() match err { Some(e) => Err(e) None => Ok(s) } } // Обработка escape sequences. После '\\' — следующий символ. fn Lexer mut @read_escape() -> Result[str, ParseJsonError] { match @advance() { None => Err(UnexpectedEof { @line, @col }) Some(b) if b == B_QUOTE => Ok("\"") Some(b) if b == B_BACKSLASH => Ok("\\") Some(b) if b == B_SLASH => Ok("/") Some(b) if b == B_LOWER_B => Ok(str.from_codepoint(0x08)) // \b → backspace Some(b) if b == B_LOWER_F => Ok(str.from_codepoint(0x0C)) // \f → form feed Some(b) if b == B_LOWER_N => Ok("\n") Some(b) if b == B_LOWER_R => Ok("\r") Some(b) if b == B_LOWER_T => Ok("\t") Some(b) if b == B_LOWER_U => @read_unicode_escape() // Неизвестный escape — настоящий codepoint (не Latin-1-фантом); // @advance() уже сдвинул @pos на 1 байт, символ начинался на @pos-1. // №433: `None` (обрыв multibyte-последовательности на этом байте, // т.е. escape оборван посреди non-ASCII символа) — `UnexpectedEof`, // не отказ. Some(_) => match @char_at(@pos - 1) { Some(c) => Err(InvalidEscape { @line, @col, seq: "\\${c}" }) None => Err(UnexpectedEof { @line, @col }) } } } // \uXXXX — 4 hex digits. Проверяем surrogate pair для чисел >= 0xD800. fn Lexer mut @read_unicode_escape() -> Result[str, ParseJsonError] { // Инвариант срезов hex-диагностики ниже: слайс [x_start..x_start+4] берётся // ТОЛЬКО после успешного @read_hex_quad()? — успех = ровно 4 байта прочитаны // с этой позиции; OOB здесь означал бы рассинхрон курсора и обязан паниковать // (никаких clamp — тихий зажим замаскировал бы баг). ro hi_start = @pos ro code = @read_hex_quad()? // Surrogate pair: high (D800-DBFF) + low (DC00-DFFF). if code >= 0xD800 && code <= 0xDBFF { // Ожидаем \u low-surrogate. ro next = @advance() ro next2 = @advance() if next != Some(B_BACKSLASH) || next2 != Some(B_LOWER_U) { return Err(InvalidUnicode { @line, @col, hex: @input[hi_start..hi_start+4] }) } ro lo_start = @pos ro low = @read_hex_quad()? if low < 0xDC00 || low > 0xDFFF { return Err(InvalidUnicode { @line, @col, hex: @input[lo_start..lo_start+4] }) } ro codepoint = 0x10000 + ((code - 0xD800) * 0x400) + (low - 0xDC00) Ok(str.from_codepoint(codepoint)) } else if code >= 0xDC00 && code <= 0xDFFF { // Lone low-surrogate — невалидно. Err(InvalidUnicode { @line, @col, hex: @input[hi_start..hi_start+4] }) } else { Ok(str.from_codepoint(code)) } } fn Lexer mut @read_hex_quad() -> Result[int, ParseJsonError] { ro start = @pos mut code = 0 for i in 0..4 { match @advance() { None => return Err(UnexpectedEof { @line, @col }) Some(b) if is_hex_digit(b) => { code = code * 16 + hex_digit_val(b) } Some(_) => return Err(InvalidUnicode { @line, @col, hex: @input[start..@pos] }) } } Ok(code) } // Чтение числа: optional '-', integer part, optional '.fraction', // optional 'e[+-]exp'. Возвращает f64. fn Lexer mut @read_number() -> Result[f64, ParseJsonError] { // record-pattern binding snapshot with rename — 3 of 4 Lexer // fields (`input` omitted), so `..` is required (partial-list rule). ro {pos: start, line: start_line, col: start_col, ..} = @ // Знак if @peek() == Some(B_MINUS) { @advance() } // Integer part: либо '0', либо [1-9][0-9]* match @peek() { Some(b) if b == B_DIGIT_0 => { @advance() } Some(b) if b >= B_DIGIT_1 && b <= B_DIGIT_9 => { @advance() while digit_here(@peek()) { @advance() } } _ => return Err(InvalidNumber { line: start_line, col: start_col, text: @input[start..@pos] }) } // Fraction: .[0-9]+ if @peek() == Some(B_DOT) { @advance() if !digit_here(@peek()) { return Err(InvalidNumber { line: start_line, col: start_col, text: @input[start..@pos] }) } while digit_here(@peek()) { @advance() } } // Exponent: [eE][+-]?[0-9]+ match @peek() { Some(b) if b == B_LOWER_E || b == B_UPPER_E => { @advance() match @peek() { Some(b2) if b2 == B_PLUS || b2 == B_MINUS => { @advance() } _ => () } if !digit_here(@peek()) { return Err(InvalidNumber { line: start_line, col: start_col, text: @input[start..@pos] }) } while digit_here(@peek()) { @advance() } } _ => () } ro text = @input[start..@pos] match text.to_f64() { Ok(n) => Ok(n) Err(_) => Err(InvalidNumber { line: start_line, col: start_col, text }) } } fn digit_here(b Option[u8]) -> bool => match b { Some(x) => is_digit(x), None => false } // ────────────────────────────────────────────────────────────────────────── // Parser — recursive descent // ────────────────────────────────────────────────────────────────────────── type Parser { mut lex Lexer mut cur TokenWithPos } fn Parser.new(input str) -> Result[Parser, ParseJsonError] { mut lex = Lexer.new(input) ro cur = lex.next_token()? Ok({ lex, cur }) } fn Parser mut @advance() -> Result[TokenWithPos, ParseJsonError] { ro prev = @cur @cur = @lex.next_token()? Ok(prev) } fn Parser mut @parse_value() -> Result[JsonValue, ParseJsonError] { ro tp = @cur match tp.tok { NullTok => { @advance()?; Ok(Null) } TrueTok => { @advance()?; Ok(Bool(true)) } FalseTok => { @advance()?; Ok(Bool(false)) } NumTok(n) => { @advance()?; Ok(Num(n)) } StrTok(s) => { @advance()?; Ok(Str(s)) } LBracket => @parse_array() LBrace => @parse_object() EofTok => Err(UnexpectedEof { line: tp.line, col: tp.col }) _ => Err(UnexpectedChar { line: tp.line, col: tp.col, found: tok_first_char(tp.tok) }) } } // Helper для диагностики — превращает токен в первый символ для // сообщения об ошибке. fn tok_first_char(t Token) -> char => match t { LBrace => '{' RBrace => '}' LBracket => '[' RBracket => ']' Comma => ',' Colon => ':' BadTok(c) => c // surface the actual bad char _ => '?' } fn Parser mut @parse_array() -> Result[JsonValue, ParseJsonError] { @advance()? // [ mut items []JsonValue = [] // Пустой массив if @cur.tok == RBracket { @advance()? return Ok(Array(items)) } items.push(@parse_value()?) mut done = false while !done { match @cur.tok { Comma => { @advance()? items.push(@parse_value()?) } RBracket => { @advance()? done = true } _ => return Err(UnexpectedChar { line: @cur.line, col: @cur.col, found: tok_first_char(@cur.tok) }) } } Ok(Array(items)) } fn Parser mut @parse_object() -> Result[JsonValue, ParseJsonError] { @advance()? // { mut fields = JsonObject.new() // Пустой объект if @cur.tok == RBrace { @advance()? return Ok(Object(fields)) } @parse_member(fields)? mut done = false while !done { match @cur.tok { Comma => { @advance()? @parse_member(fields)? } RBrace => { @advance()? done = true } _ => return Err(UnexpectedChar { line: @cur.line, col: @cur.col, found: tok_first_char(@cur.tok) }) } } Ok(Object(fields)) } fn Parser mut @parse_member(mut fields JsonObject) -> Result[(), ParseJsonError] { // ключ — string token ro key_tp = @cur ro key = match key_tp.tok { StrTok(s) => s _ => return Err(UnexpectedChar { line: key_tp.line, col: key_tp.col, found: tok_first_char(key_tp.tok) }) } @advance()? // ':' match @cur.tok { Colon => { @advance()? } _ => return Err(UnexpectedChar { line: @cur.line, col: @cur.col, found: tok_first_char(@cur.tok) }) } // value ro value = @parse_value()? // RFC 8259: behavior on duplicate keys is implementation-defined. // Мы выбираем строгое поведение — DuplicateKey error. Это AI-friendly: // duplicate почти всегда ошибка. if fields.contains(key) { return Err(DuplicateKey { line: key_tp.line, col: key_tp.col, key }) } fields.insert(key, value) Ok(()) } // ────────────────────────────────────────────────────────────────────────── // Public API: парсинг // ────────────────────────────────────────────────────────────────────────── /// Parse a JSON document from a string. Main entry point. /// /// Returns `Result[JsonValue, ParseJsonError]` (D325 — Result-everywhere). /// At the call site: `Json.parse(s)?` (propagation) / `Json.parse(s)!!` (throw) / /// `match` (branching on the error). /// /// # Examples /// ```nova /// let v = Json.parse("[1, 2, 3]")!! /// assert(v.is_array()) /// ``` #stable(since = "0.1") export fn Json.parse(s str) -> Result[JsonValue, ParseJsonError] { // [M-lint-findings-static-conversion] mut p = Parser.new(s)? ro v = p.parse_value()? // Trailing content (после value должен быть только EOF). match p.cur.tok { EofTok => Ok(v) _ => Err(TrailingContent { line: p.cur.line, col: p.cur.col }) } } /// `try_from` — an alias of [`Json.parse`] per the conversion-constructor /// naming convention (D325 — fallible → Result). Behavior is identical to `Json.parse`. #stable(since = "0.1") export fn JsonValue.try_from(s str) -> Result[JsonValue, ParseJsonError] => // [M-lint-findings-static-conversion] Json.parse(s) // ────────────────────────────────────────────────────────────────────────── // Сериализация: @to_str() -> str // ────────────────────────────────────────────────────────────────────────── /// `to_str()` — serialize into compact JSON (single line, no indentation, D410). /// /// Interpolation (`"${v}"`) and `v.to_str()` use this same method via the /// compiler Display-fallback. For multi-line formatting use /// [`pretty`]. /// /// # Examples /// ```nova /// let v = Array([Num(1.0), Str("a")]) /// assert(v.to_str() == "[1,\"a\"]") /// ``` #stable(since = "0.1") export fn JsonValue @to_str() -> str => match @ { Null => "null" Bool(b) => if b { "true" } else { "false" } Num(n) => format_num(n) Str(s) => format_string(s) Array(xs) => format_array(xs) Object(m) => format_object(m) } fn format_num(n f64) -> str { // Целые числа — без .0 в выводе. JSON допускает и "1" и "1.0", // но для round-trip с целыми хорошо иметь короткую форму. if n.is_finite() && n == n.trunc() && n.abs() < 1e16 { // целое в безопасном диапазоне для f64 (2^53) (n as i64).to_str() } else { n.to_str() } } fn format_string(s str) -> str { consume buf = StringBuilder.new() buf.append("\"") for c in s.chars() { buf.append(escape_char(c)) } buf.append("\"") buf } fn escape_char(c char) -> str => match c { '"' => "\\\"" '\\' => "\\\\" '\n' => "\\n" '\r' => "\\r" '\t' => "\\t" c if (c as int) == 0x08 => "\\b" c if (c as int) == 0x0C => "\\f" c if (c as int) < 0x20 => unicode_escape(c as int) _ => c.to_str() } fn unicode_escape(code int) -> str { ro hex = to_hex_4(code) "\\u${hex}" } fn to_hex_4(n int) -> str { ro chars = ['0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'] ro h0 = chars[(n / 0x1000) % 16] ro h1 = chars[(n / 0x100) % 16] ro h2 = chars[(n / 0x10) % 16] ro h3 = chars[n % 16] "${h0}${h1}${h2}${h3}" } fn format_array(xs []JsonValue) -> str { consume buf = StringBuilder.new() buf.append("[") mut first = true for x in xs { if !first { buf.append(",") } buf.append(x.to_str()) first = false } buf.append("]") buf } fn format_object(m JsonObject) -> str { consume buf = StringBuilder.new() buf.append("{") mut first = true // JsonObject.@iter() идёт в порядке ВСТАВКИ (`@key_order`), не в // hash-порядке — отсюда детерминизм поля-порядка в выводе // (см. JsonObject выше). for (k, v) in m { if !first { buf.append(",") } buf.append(format_string(k)) buf.append(":") buf.append(v.to_str()) first = false } buf.append("}") buf } // ────────────────────────────────────────────────────────────────────────── // Pretty-print с отступами // ────────────────────────────────────────────────────────────────────────── /// Pretty-print with 2-space indentation for logs and debugging. /// /// A distinct method, not part of `to_str()`: the compact form (`@to_str`) /// is canonical for round-trips; pretty output is for humans (style rule 3: /// public surface is methods, not free functions). /// /// # Examples /// ```nova /// let mut m = JsonObject.new() /// m.insert("name", Str("alice")) /// let s = Object(m).to_str_pretty() /// assert(s.contains("\n")) /// ``` #stable(since = "0.1") export fn JsonValue @to_str_pretty() -> str => pretty_at(@, 0) /// THE ONE required `Display` primitive for `JsonValue` /// — `${v:#}` (alternate/pretty-flag) renders the same output as /// [`to_str_pretty`]; any other spec (bare `${v}`, `.N` precision, etc.) /// falls back to the compact `@to_str()` — `#` is the only axis this type /// reacts to. This IS `@display`, unconditionally dispatched for every /// `${v[:SPEC]}` site (bare included), not a rich-spec-only opt-in. /// /// # Examples /// ```nova /// let mut m = JsonObject.new() /// m.insert("name", Str("alice")) /// let v = Object(m) /// assert("${v:#}" == v.to_str_pretty()) /// assert("${v}" == v.to_str()) /// ``` #stable(since = "0.1") #impl(Display) fn JsonValue @display(mut f Fmt) -> () { if f.alternate() { f.write(@to_str_pretty().bytes()) } else { f.write(@to_str()) } } fn pretty_at(v JsonValue, depth int) -> str => match v { Array(xs) if xs.len() == 0 => "[]" Array(xs) => pretty_array(xs, depth) Object(m) if m.len() == 0 => "{}" Object(m) => pretty_object(m, depth) _ => v.to_str() } fn pretty_array(xs []JsonValue, depth int) -> str { ro inner_indent = indent(depth + 1) ro outer_indent = indent(depth) consume buf = StringBuilder.new() buf.append("[\n") mut first = true for x in xs { if !first { buf.append(",\n") } buf.append(inner_indent) buf.append(pretty_at(x, depth + 1)) first = false } buf.append("\n") buf.append(outer_indent) buf.append("]") buf } fn pretty_object(m JsonObject, depth int) -> str { ro inner_indent = indent(depth + 1) ro outer_indent = indent(depth) consume buf = StringBuilder.new() buf.append("{\n") mut first = true // JsonObject.@iter() — порядок вставки (см. format_object выше). for (k, v) in m { if !first { buf.append(",\n") } buf.append(inner_indent) buf.append(format_string(k)) buf.append(": ") buf.append(pretty_at(v, depth + 1)) first = false } buf.append("\n") buf.append(outer_indent) buf.append("}") buf } fn indent(depth int) -> str { consume buf = StringBuilder.new() for i in 0..depth { buf.append(" ") } buf }