algorithms · advanced · 6d
JSON-парсер с нуля
Напиши рекурсивно-нисходящий парсер JSON по спецификации — токенизатор, диспетчер значений, обработчик escape-последовательностей, декодер чисел — и наблюдай, как каждый крайний случай RFC 8259 превращается в конкретный путь в коде.
Результат
Функция `parse(input)`, которая корректно обрабатывает все типы значений JSON, строковые escape-последовательности включая \uXXXX, все формы чисел, произвольную глубину вложенности и бросает `ParseError` с числовым `position` на любом некорректном вводе.
Этапы
0/6 · 0%- 01Токенизатор: разбить ввод на поток токенов
До разбора структуры нужно классифицировать символы. Токенизатор (лексер) обходит исходную строку посимвольно, пропускает пробельные символы и генерирует плоский поток типизированных токенов: LEFT_BRACE, RIGHT_BRACE, LEFT_BRACKET, RIGHT_BRACKET, COLON, COMMA, STRING, NUMBER, TRUE, FALSE, NULL и EOF. Ключевое решение — какую информацию несёт каждый токен: структурные токены несут только тип и позицию; STRING-токены несут сырую лексему, чтобы декодер escape-последовательностей в следующем этапе мог её переобработать; NUMBER-токены несут сырую лексему, чтобы числовой декодер мог проверить формат до конвертации. Запись символьного смещения каждого токена здесь — вот что делает `position` в твоём `ParseError` честным позже; не откладывай отслеживание позиции. Токенизатор, возвращающий богатый поток токенов, делает рекурсивно-нисходящий парсер следующего этапа тривиальным в написании; токенизатор, возвращающий сырые индексы, превращает парсер в беспорядок.
Критерии готовности- На любой допустимой JSON-строке токенизатор генерирует правильную последовательность типизированных токенов, каждый с `position` (смещением символа во вводе).
- На некорректном вводе (например, одиночный `@` или незавершённая строка) токенизатор бросает `ParseError`, чей `position` указывает на проблемный символ.
Самопроверка
Покажи поток токенов для `{"x":1}` и для `"ab\ncd"` — senior-ревьюер проверяет, что у каждого токена есть позиция, STRING несёт сырую лексему и что незавершённые строки перехватываются в токенизаторе, а не в парсере.
- 02Диспетчер значений: литералы, массивы и объекты
Сердце рекурсивно-нисходящего парсера — функция `parseValue`, которая смотрит на текущий токен и передаёт управление нужному подправилу: если видит LEFT_BRACE — вызывает `parseObject`, если LEFT_BRACKET — `parseArray`, если NULL/TRUE/FALSE — немедленно возвращает литерал, если STRING/NUMBER — делегирует специализированным обработчикам (следующие этапы). `parseObject` потребляет токены по шаблону `{ key : value (, key : value)* }` и рекурсивно вызывает `parseValue` для каждого значения; `parseArray` делает то же для `[ value (, value)* ]`. Рекурсия — это и есть спецификация: JSON-значение определяется как одна из шести форм, а объекты и массивы содержат значения — поэтому они естественным образом возвращаются в `parseValue`. Отсутствующая запятая, ключ не-строка или токен, не являющийся допустимым началом значения, — всё это должно давать `ParseError` с позицией проблемного токена.
Критерии готовности- Корректно разбирает `null`, `true`, `false`, плоский массив `[1,2,3]` и плоский объект `{"a":1}`, возвращая правильные JavaScript/TypeScript значения.
- Бросает `ParseError` с осмысленным `position` на завершающей запятой в массиве или объекте и на ключе объекта, не являющемся строкой.
Самопроверка
Проследи стек вызовов при разборе `[true,{"k":null}]` через `parseValue` → `parseArray` → `parseValue` → `parseObject`; senior-ревьюер проверяет, что рекурсия корректно завершается на EOF и что `parseObject` различает отсутствующее двоеточие и отсутствующую запятую разными сообщениями об ошибке.
- 03Декодер строк: escape-последовательности и \uXXXX
JSON-строки не являются JavaScript-строками: сырая лексема из токенизатора — это последовательность байт в кавычках, и её надо декодировать. RFC 8259 определяет восемь двухсимвольных escape-последовательностей (\", \\, \/, \b, \f, \n, \r, \t), отображаемых в одиночные символы, и шестисимвольную форму \uXXXX, декодирующую кодовую точку Unicode. Форма `\uXXXX` — место, где большинство реализаций ошибается: значение в диапазоне суррогатов (U+D800–U+DFFF) не является самостоятельным символом; высокий суррогат (D800–DBFF) должен немедленно следовать за `\uXXXX` низким суррогатом (DC00–DFFF), образуя суррогатную пару, декодирующуюся в символ дополнительной плоскости через `String.fromCodePoint`. Передача одиночного суррогата в `String.fromCharCode` даёт искажённую строку, не прошедшую round-trip через JSON.stringify. Любая нераспознанная `\x`-последовательность — это ошибка по спецификации, а не сквозная передача. Декодер строк чист: он принимает сырую лексему и возвращает декодированную строку, что делает его независимо тестируемым.
Критерии готовности- Все восемь escape-последовательностей RFC 8259 (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`) декодируются в правильные одиночные символы.
- Последовательность `\uXXXX` декодируется корректно, включая суррогатную пару (например, `\uD83D\uDE00` → эмодзи 😀), а нераспознанная escape-последовательность (например, `\q`) бросает `ParseError`.
Самопроверка
Покажи результат декодирования для `"\t\u0041\uD83D\uDE00"` (tab + 'A' + 😀) — senior-ревьюер проверяет, что суррогатная пара объединяется через `String.fromCodePoint`, а не двумя сломанными суррогатами через `String.fromCharCode`, и что `\q` даёт `ParseError`, а не буквальные символы `\q`.
- 04Декодер чисел: знак, дробная часть и экспонента
У JSON-чисел есть определённая грамматика — `[-](0|[1-9][0-9]*)([.][0-9]+)?([eE][+-]?[0-9]+)?` — и не все строки, которые `parseFloat` в JavaScript радостно съедает, являются допустимыми JSON-числами. `Infinity`, `NaN` и голое `.5` (ведущая точка) не являются JSON. `01` (ведущий ноль перед цифрой) не является JSON. Твой декодер чисел должен валидировать сырую лексему по грамматике JSON перед передачей в `Number()` или `parseFloat`, потому что эти функции мягче спецификации. Крайние случаи для обработки: отрицательный ноль (`-0`), который является допустимым JSON и должен корректно проходить round-trip; очень большие экспоненты (например, `1e308`), которые допустимы в JSON, но могут потерять значимость или переполниться до `Infinity` на стороне JavaScript — твой парсер корректно возвращает `Infinity`, так как это ближайшее представление IEEE 754; и числа, чья десятичная точность превышает точность 64-битного float, где ты принимаешь ближайшее представимое значение и не пытаешься сохранить точную десятичную семантику (для этого потребовался бы BigDecimal).
Критерии готовности- Все допустимые формы JSON-чисел разбираются корректно: целые, отрицательные целые, десятичные (`-1.5`), с экспонентой (`2e10`, `1.5E-3`) и комбинированные (`-1.5e3`).
- Недопустимые формы (`Infinity`, `NaN`, `01`, `.5`) бросают `ParseError`; отрицательный ноль (`-0`) разбирается без исключения и `Object.is(result, -0)` истинно.
Самопроверка
Покажи, что делает твой декодер с `01`, `-0` и `1e999` — senior-ревьюер проверяет, что `01` — это `ParseError` (не 1), `-0` проходит `Object.is(result, -0)`, а `1e999` возвращает `Infinity` без исключения (допустимый JSON, представимый как IEEE 754).
- 05Глубина вложенности, обработка EOF и позиции ошибок
Рекурсивно-нисходящая структура обрабатывает вложенность бесплатно — `parseValue` вызывает `parseObject`, который вызывает `parseValue`, который вызывает `parseArray` и так далее, — но надо убедиться, что рекурсия ограничена, иначе глубоко вложенный ввод обрушит процесс с переполнением стека. Жёсткое ограничение глубины (например, 500 уровней, соответствующее типичному лимиту V8 с запасом) превращает переполнение стека в `ParseError` на правильной позиции. Ошибки EOF — второй класс ошибок позиции: когда парсер ожидает больше токенов и видит EOF, сообщаемая позиция должна быть концом ввода, а не индексом 0. Завершающее содержимое — ошибка: `parse('{}garbage')` должен бросить исключение, потому что корректный JSON-текст — это ровно одно значение без завершающего не-пробельного содержимого. Контракт сообщения об ошибках — то, на что полагаются твои вызывающие для пользовательской диагностики: `ParseError` с `position` равным 0 для каждой ошибки бесполезен.
Критерии готовности- Ввод с 200 уровнями вложенности разбирается без переполнения стека; ввод, превышающий лимит глубины, бросает `ParseError` в точке вложенности.
- Завершающее содержимое (`{}garbage`), незавершённый ввод (`{"a":`) и глубоко вложенные пустые массивы дают `ParseError`, чей `position` указывает на реальный проблемный символ, а не на 0 или конец файла безусловно.
Самопроверка
Запусти парсер на `'{' + '['.repeat(1000)` — senior-ревьюер проверяет, что он бросает `ParseError` с указанием превышения глубины, а не падает с ошибкой размера стека вызовов, и что `position` ненулевой (в точке, где вложенность превысила лимит).
- 06Крайние случаи спецификации: соответствие, round-trip и состязательный ввод
Парсер, проходящий твои собственные happy-path тесты, — это не то же самое, что проходящий RFC 8259. Прогони реализацию через набор тестов на соответствие JSON (например, JSONTestSuite Николя Серио или тестовые векторы статьи «Parsing JSON is a Minefield») и зафиксируй, какие из его случаев 'must-fail' и 'must-pass' ты проходишь. Интересные неудачи — неоднозначные случаи: метки порядка байт в начале ввода, дублирующиеся ключи в объекте (спецификация говорит «должны» быть уникальными, но не «обязаны»), числа с очень длинной целой частью и допустимые одиночные документы, являющиеся просто числом или строкой (не объектом или массивом). Проверь round-trip своей реализации: для каждого разобранного JSON-значения `parse(JSON.stringify(value))` должен возвращать значение, которое `JSON.stringify` отображает обратно в ту же строку. Состязательные вводы — глубоко вложенные вводы, строки со всеми формами escape, объекты с 10k ключами — не должны вызывать квадратичное конкатенирование строк или неограниченное выделение памяти.
Критерии готовности- Твой парсер проходит все случаи 'must-pass' из хотя бы одного публичного набора тестов на соответствие JSON, и ты задокументировал, какие случаи 'must-fail' ты проходишь, а какие принимаешь (с обоснованием для вторых).
- Round-trip выполняется: `parse(JSON.stringify(x))` ≡ `x` для `null`, `true`, `false`, любого конечного числа, любой строки без суррогатных значений и любой вложенной структуры, построенной из них.
Самопроверка
Запусти `parse(JSON.stringify({a: [1, '\u0000', null, true]}))` и покажи, что результат равен оригиналу — senior-ревьюер проверяет, что ты корректно обрабатываешь нулевой байт (`\u0000`) в round-trip и что ты можешь назвать одну неоднозначность RFC 8259 (дублирующиеся ключи, BOM, точность числа) и указать явную политику своего парсера по ней.
Стартер
- README.md
- src/parser.ts
- test/parser.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Корректность токенизатора и грамматики | Разбирает простые объекты и массивы со строковыми ключами и целочисленными значениями; падает на вложенных структурах, не-целых числах или любом escape кроме `\n`. | Токенизатор генерирует типизированный поток токенов с позициями; рекурсивно-нисходящий разбор обрабатывает все шесть типов JSON-значений и произвольную вложенность; `ParseError` бросается на структурных нарушениях (отсутствующая запятая, неверный токен) с ненулевой позицией. | Парсер проходит все случаи must-pass из публичного набора тестов на соответствие JSON, имеет явную политику по неоднозначностям RFC 8259 (дублирующиеся ключи, BOM), и ты можешь показать, как ограничение глубины преобразует переполнение стека в `ParseError` на правильной позиции. |
| Escape-последовательности и форматы чисел | Обрабатывает `\n` и `\t`; передаёт сырые цифры в `parseInt`; не валидирует формат числа по грамматике JSON. | Все восемь двухсимвольных escape-последовательностей RFC 8259 декодируются корректно; одиночные кодовые точки `\uXXXX` декодируются корректно; лексема числа валидируется по грамматике JSON перед конвертацией; отрицательные и десятичные формы работают. | Суррогатные пары (`\uD800\uDC00`) объединяются через `String.fromCodePoint`, а не двумя сломанными вызовами `fromCharCode`; одиночные суррогаты бросают исключение; формы с экспонентой и `-0` обрабатываются; round-trip выполняется для всех не-суррогатных значений, которые производит `JSON.stringify`. |
| Сообщение об ошибках с позицией | Бросает обычный `Error` или возвращает `null` на некорректном вводе; информации о позиции нет; различение типа ошибки требует проверки строки сообщения. | Бросает подкласс `ParseError` со свойством `position` типа number, являющимся смещением символа первого плохого символа; вызывающий код может использовать `instanceof ParseError` для различения ошибок разбора от других ошибок. | Позиция корректна для каждого класса ошибок: незавершённая строка (позиция открывающей кавычки), завершающая запятая (позиция запятой), неизвестная escape-последовательность (позиция обратного слэша), превышение глубины (позиция токена, начинающего слишком глубокий уровень) и завершающее содержимое (позиция первого не-пробельного символа после значения верхнего уровня). |
Эталонный разбор (спойлер)
Почему рекурсивный спуск: грамматика, где каждая продукция начинается с отдельного терминала (объект начинается с `{`, массив с `[`, строка с `"`, число с `-` или цифры, а литералы с `t`/`f`/`n`), является LL(1) — никогда не нужно смотреть более чем на один токен вперёд, чтобы знать, какое правило применять. Рекурсивный спуск отображает грамматические правила непосредственно в функции, заставляя структуру кода отражать структуру грамматики. Альтернативы (комбинаторы парсеров, Earley, LR) добавляют сложность без выигрыша для простой, однозначной грамматики, подобной JSON.
Суррогатные пары в \uXXXX: Unicode имеет 1 114 112 кодовых точек (от U+0000 до U+10FFFF), но UTF-16 (кодировка, которую JavaScript строки используют внутренне) может выражать U+0000–U+FFFF только в одной 16-битной единице. Кодовые точки выше U+FFFF (эмодзи, многие расширения CJK) кодируются как два 16-битных суррогатных полуслова. JSON `\uXXXX` — это 16-битное значение, поэтому символы дополнительной плоскости требуют двух последовательных последовательностей `\uXXXX` — одной в диапазоне высоких суррогатов (D800–DBFF) и одной в диапазоне низких суррогатов (DC00–DFFF). Использование `String.fromCodePoint(combined)` вместо `String.fromCharCode(high) + String.fromCharCode(low)` позволяет избежать создания строки, содержащей непарные суррогаты, что является технически недопустимым UTF-16.
Позиция в сообщениях об ошибках: значение поля `position` настолько хорошо, насколько дисциплинированно оно отслеживается. Две частые ошибки: (1) токенизатор продвигает `pos` после потребления символа, а не до, смещая все сообщаемые позиции на единицу; (2) пути ошибок возвращают `pos` на текущем предвосхищении парсера, а не в начале проблемного токена, поэтому незавершённая строка сообщает позицию символа после закрывающей кавычки, которая так и не появилась. Записывай позицию `start` каждого токена, когда токенизатор впервые видит его открывающий символ, и держи этот `start` в токене, чтобы парсер мог сообщить его при ошибке.
Грамматика чисел против parseFloat: JavaScript's `parseFloat` принимает строки, которые спецификация JSON не принимает — `'Infinity'`, `'NaN'`, `'.5'` (без ведущей цифры) и даже `'1_000'` в некоторых движках. Валидация лексемы по грамматике чисел JSON (`/-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/`) перед вызовом `Number()` или `parseFloat` — правильное разделение ответственности: токенизатор решает, похожа ли последовательность символов на число; декодер чисел решает, является ли эта последовательность *допустимым* JSON-числом. Проверка регулярным выражением всей лексемы дёшева и явна.
Глубина рекурсии и переполнение стека: глубина стека вызовов V8 по умолчанию составляет примерно 10 000–15 000 кадров, но это зависит от размера функции и размера стека ОС. Максимально вложенный JSON-документ `[[[[...]]]]` на 10 001 уровне бросит JavaScript `RangeError: Maximum call stack size exceeded`, а не твой `ParseError`, что бесполезно для вызывающих. Счётчик глубины, пронизывающий рекурсивные вызовы, превращает это в управляемый `ParseError` на предсказуемом уровне. Спецификация JSON Pointer (RFC 6901) использует глубину `/` как естественную единицу глубины, и большинство production-парсеров ограничиваются 500–1000.
Сделай по-сеньорски
- Реализуй инкрементный/потоковый режим: принимай фрагменты ввода и генерируй завершённые JSON-значения по мере разбора, без буферизации всего документа — полезно для больших NDJSON-потоков, где нельзя удерживать весь ввод в памяти.
- Добавь разбор с валидацией по схеме: принимай JSON Schema (подмножество: type, properties, required, items, minimum/maximum) вместе со вводом и бросай типизированный `ValidationError` вместо возврата обычного `JsonValue`, когда разобранный документ нарушает схему.
- Замерь пропускную способность разбора (МБ/с) в сравнении с `JSON.parse` на документе 10 МБ и определи самый горячий путь — вероятно, декодирование строк или хеширование ключей объекта, — затем оптимизируй его, показав числа пропускной способности до и после.
- Обработай точность чисел: предоставь вариант `parseWithBigInt`, возвращающий `bigint` вместо `number` для целочисленных значений, превышающих `Number.MAX_SAFE_INTEGER`, и докажи, что для 64-битного целого значения, которое стандартный `parse` исказил бы, точность не теряется.