Skip to main content

tabnas_json/
lib.rs

1// Copyright (c) 2026 tabnas, MIT License
2
3// The engine's error carries a code, position, hint and a formatted
4// report, so it is large by design and `Result<_, TabnasError>` trips
5// clippy's `result_large_err`. The engine allows the lint at its own
6// crate root for the same reason; boxing here instead would make
7// `parse` return a different shape from `Tabnas::parse` and from the
8// TypeScript and Go ports, which is a worse trade than the lint.
9#![allow(clippy::result_large_err)]
10
11//! A standard JSON grammar plugin for the `tabnas` parsing engine.
12//!
13//! The engine ships no grammar of its own; this crate supplies the
14//! strict, standard-JSON one. The rule set (`val` / `map` / `list` /
15//! `pair` / `elem`) is jsonic's "Plain JSON" grammar, the pure-JSON core
16//! jsonic defines before extending it for the relaxed jsonic format.
17//! Here that core is installed on its own, with the lexer restricted to
18//! strict JSON and none of jsonic's extended grammar (comments, unquoted
19//! keys, implicit objects/arrays, trailing commas, single/backtick
20//! strings, path diving).
21//!
22//! This plugin is intended to be the foundation other tabnas grammar
23//! plugins build on: install it first, then layer additional rules on the
24//! shared `val` / `map` / `list` / `pair` / `elem` rules.
25
26use std::sync::OnceLock;
27
28use regex::Regex;
29use serde_json::json;
30use tabnas::{Context, GrammarError, GrammarSpec, LexCheckResult, Tabnas, Value};
31
32/// The README's Rust examples run as doctests, so a stale one fails the
33/// gate rather than misleading the reader. Its `toml` and `bash` fences
34/// are skipped; rustdoc runs only the `rust` ones.
35#[cfg(doctest)]
36#[doc = include_str!("../README.md")]
37mod readme_examples {}
38
39/// This crate's version. It MUST equal `ts/package.json` "version": the
40/// release orchestrator rewrites both, and `tests/version_test.rs` fails
41/// the build if they drift. Mirrors `VERSION` in `ts/src/json.ts` and
42/// `const VERSION` in `go/json.go`.
43pub const VERSION: &str = "0.5.11";
44
45/// The error a failed parse produces, re-exported so callers need not
46/// depend on the engine crate directly. Mirrors the TypeScript
47/// `export { TabnasError as JsonError }`.
48pub use tabnas::TabnasError as JsonError;
49
50/// The name the serialized options bind the number preflight hook under.
51/// Referencing it by name is the only way to reach `options.number.check`
52/// from outside the engine crate: `LexCheck`'s constructors are
53/// `pub(crate)`, and `Tabnas::lex_check_ref` exists for exactly this.
54const NUMBER_CHECK: &str = "json-strict-number";
55
56/// Exactly a standard JSON number.
57fn strict_number() -> &'static Regex {
58    static RE: OnceLock<Regex> = OnceLock::new();
59    RE.get_or_init(|| {
60        Regex::new(r"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$")
61            .expect("the strict-number pattern is a literal and compiles")
62    })
63}
64
65/// The candidate literal starting at `src`, up to the next JSON
66/// structural character or whitespace. That boundary is what the engine's
67/// (lenient) number matcher would consider, so it is what has to be
68/// judged.
69///
70/// `/` is a boundary too, and not for strict JSON's sake: a slash after a
71/// number is invalid there whatever this returns. It is for the JSONC
72/// recipe the docs describe. With comment lexing re-enabled,
73/// `{"a":1/* note */}` has no space between the number and the comment,
74/// so without `/` here the literal scanned to the next WHITESPACE and the
75/// hook judged `1/*`, failed the pattern, and answered `Skip` -- turning
76/// a valid JSONC document into `unexpected`. Stopping here lets the
77/// number tokenize and leaves the comment to the lexer, which is the one
78/// that knows whether comments are on.
79fn leading_literal(src: &str) -> &str {
80    let end = src
81        .find(|c: char| c.is_whitespace() || matches!(c, ',' | '}' | ']' | ':' | '"' | '/'))
82        .unwrap_or(src.len());
83    &src[..end]
84}
85
86/// Reject anything the engine's lenient number matcher would accept that
87/// standard JSON does not.
88///
89/// This is a `check` hook rather than `options.number.exclude` for two
90/// independent reasons, and both matter:
91///
92/// 1. **`exclude` cannot express it.** The TypeScript exclude is a
93///    negative lookahead (`/^(?!-?(?:0|[1-9][0-9]*)…$)/`), and the Rust
94///    engine's `exclude` is a pattern for the `regex` crate, which has no
95///    lookaround at all. The positive form plus an inversion is the only
96///    way to say it here, which is the shape the Go port already uses.
97///
98/// 2. **Out-of-range exponents.** `1e999` and `123123e100000` are
99///    syntactically valid JSON, and the platform oracles disagree about
100///    them: `JSON.parse` saturates to `Infinity` (so TypeScript accepts),
101///    while `encoding/json` errors. `serde_json` — this runtime's oracle —
102///    errors too ("number out of range"), verified, so Rust rejects them
103///    with Go rather than accepting with TypeScript. AGENTS.md rule 4 is
104///    per-runtime parity and names this as a deliberate, permanent
105///    asymmetry. Underflow (`1e-999` -> `0`) is accepted by serde_json and
106///    is left alone, exactly as Go leaves it.
107///
108/// Note the asymmetry is not expressible as a regex either way, which is
109/// the deeper reason both ports need a predicate and TypeScript does not.
110fn strict_number_check(src: &str) -> LexCheckResult {
111    let literal = leading_literal(src);
112
113    // The hook runs wherever a number COULD be lexed, not only where one
114    // starts, so say nothing unless a number-ish literal is actually here.
115    let starts_number = literal
116        .chars()
117        .next()
118        .is_some_and(|c| c == '-' || c == '+' || c == '.' || c.is_ascii_digit());
119    if !starts_number {
120        return LexCheckResult::Continue;
121    }
122
123    if !strict_number().is_match(literal) {
124        return LexCheckResult::Skip;
125    }
126
127    // Syntactically standard, but out of f64 range. `parse` saturates to
128    // an infinity rather than failing, so the finiteness test is the check.
129    match literal.parse::<f64>() {
130        Ok(value) if !value.is_finite() => LexCheckResult::Skip,
131        Ok(_) => LexCheckResult::Continue,
132        Err(_) => LexCheckResult::Skip,
133    }
134}
135
136/// serde_json's own nesting limit, and therefore this port's.
137///
138/// `serde_json::from_str` accepts 127 levels of nesting and refuses the
139/// 128th with "recursion limit exceeded"; `JSON.parse` and
140/// `encoding/json` both go far deeper. That is the same shape of
141/// platform disagreement as the out-of-range exponent above, and
142/// per-runtime parity answers it the same way: this port follows its own
143/// platform. The boundary was measured against serde_json rather than
144/// read off its constant, and `tests/json_test.rs` re-measures it, so a
145/// future change there shows up as a failure instead of as silent drift.
146///
147/// Unlike that one, it is also a crash fix. Without a limit, a 1 KB
148/// source of 500 open brackets aborts the process with a stack overflow
149/// rather than returning an error, which the external conformance corpus
150/// exercises directly (`i_structure_500_nested_arrays`,
151/// `n_structure_100000_opening_arrays`). A parser reached with untrusted
152/// input must not be able to end the process.
153const DEPTH_LIMIT: usize = 127;
154
155/// How many containers are open at this point in the parse.
156///
157/// Unlike the number check, the budget needs no name: `parse_budget`
158/// takes the closure directly, so there is nothing to bind by name and
159/// nothing for the grammar document to reference.
160///
161/// Counted from the RULE NAMES rather than from `rule_stack.len()`. The
162/// stack holds about three rules per level (`val`, then `map`/`list`,
163/// then `pair`/`elem`), so a length-based limit would encode that ratio
164/// and shift silently the first time the grammar gains an alternate.
165/// Counting the container rules is the depth a reader of the document
166/// would count.
167///
168/// The rule the loop is working on is NOT in `rule_stack`: the engine
169/// hands it over separately as `context.rule`, and the stack holds only
170/// its ancestors. A container is open from the moment it is that rule,
171/// so it has to be counted too. Counting the ancestors alone made the
172/// boundary depend on what the innermost container held: `[]` nested 127
173/// deep parsed, because the 127th list was the current rule and went
174/// uncounted, while `[1]` nested 127 deep was refused, because by the
175/// time `1` was read all 127 lists were ancestors. serde_json accepts
176/// both, and `tests/json_test.rs` now measures both shapes against it.
177fn depth(context: &Context) -> usize {
178    let is_container = |name: &str| name == "map" || name == "list";
179    let ancestors = context
180        .rule_stack
181        .iter()
182        .filter(|rule| is_container(&rule.name))
183        .count();
184    let current = usize::from(
185        context
186            .rule
187            .as_ref()
188            .is_some_and(|rule| is_container(&rule.name)),
189    );
190    ancestors + current
191}
192
193/// The parse budget: stop before the nesting outruns the stack.
194///
195/// At most, not strictly less than. `depth` already includes the
196/// container the loop is inside, so the count it returns IS the nesting
197/// depth of the token about to be read, and `DEPTH_LIMIT` means "this
198/// many levels parse, the next one does not": the 128th container fails
199/// the check on the very iteration it becomes the current rule, whether
200/// it turns out to be empty or not. That is the boundary
201/// `tests/json_test.rs` measures against serde_json rather than
202/// asserting from this reasoning.
203fn within_depth_limit(context: &Context) -> bool {
204    depth(context) <= DEPTH_LIMIT
205}
206
207/// The one serialized document carrying both the strict-JSON options and
208/// the JSON rule set, mirroring `JSON_OPTIONS` + `registerJsonGrammar` in
209/// `ts/src/json.ts` and `jsonOptions` + `RegisterJSONGrammar` in
210/// `go/json.go`.
211///
212/// Options travel in the grammar document rather than through the typed
213/// `Options` struct because `number.check` can only be bound by name from
214/// here; everything else could go either way, and keeping them together
215/// means there is one definition of "strict JSON" rather than two halves
216/// that can drift.
217fn json_document() -> serde_json::Value {
218    json!({
219        // The schema version of the native-value builtins this grammar
220        // binds to (object/array/reset/key/setval/push/value).
221        "v": 2,
222
223        "options": {
224            "text": { "lex": false },
225            "number": {
226                "hex": false, "oct": false, "bin": false, "sep": null,
227                "check": NUMBER_CHECK,
228            },
229            "string": {
230                "chars": "\"",
231                "multiChars": "",
232                // Standard JSON escape handling: allowUnknown:false
233                // rejects any unrecognized escape (\q, \z); escapeStrict
234                // disables the engine's non-standard \xHH and \u{...}
235                // structural escapes (plain \uXXXX stays); and dropping
236                // v / ' / ` from the escape map removes the remaining
237                // non-standard built-ins. Result: exactly the JSON escape
238                // set, identical to the other two runtimes.
239                //
240                // NOTE the entries are `null`, where ts/src/json.ts writes
241                // `''`. Same intent, different idiom: this engine deletes
242                // an escape entry on a null value and treats `""` as a
243                // real mapping TO the empty string, so `""` here would
244                // make `"\v"` parse as `""` instead of being rejected --
245                // which is exactly what it did before this comment
246                // existed, on five spec rows.
247                "allowUnknown": false,
248                "escapeStrict": true,
249                "escape": { "v": null, "'": null, "`": null },
250            },
251            "comment": { "lex": false },
252            "map": { "extend": false },
253            "lex": { "empty": false },
254            // Restrict the rule set to the `json`-tagged alternates. The
255            // grammar below tags every alt "json", so on a bare engine
256            // this is inert; it matters when these options are applied
257            // over an already-extended grammar, keeping only its
258            // strict-JSON alternates.
259            "rule": { "finish": false, "include": "json" },
260            // Strict JSON keys are quoted strings only.
261            //
262            // Spelled with the three trailing nulls `ts/src/json.ts`
263            // spells it with, and for the same reason: a token set in a
264            // serialized document overlays the installed one INDEX-WISE,
265            // so a bare `["#ST"]` replaces position 0 and keeps the rest
266            // of the engine default (`#TX #NR #ST #VL`) underneath it.
267            // A `null` clears its position, so listing one per remaining
268            // member is how a wholesale replacement is written.
269            "tokenSet": { "KEY": ["#ST", null, null, null] },
270        },
271
272        // The value tree is built ENTIRELY by the engine's native-value
273        // `$`-builtins, referenced by name on the alts below; the engine
274        // merges them in at load. There are NO grammar-local closures.
275        //
276        //   @reset$  - clear the parent-seeded node.
277        //   @object$ - allocate an empty object into the node.
278        //   @array$  - allocate an empty array into the node.
279        //   @key$    - capture the matched key for the pending @setval$.
280        //   @setval$ - assign the built child value under that key.
281        //   @push$   - append the built child value to the array.
282        //   @value$  - resolve the rule's value (child wins, else token).
283        "rule": {
284            "val": {
285                "open": [
286                    { "s": "#OB", "p": "map",  "b": 1, "a": "@reset$", "g": "map,json" },
287                    { "s": "#OS", "p": "list", "b": 1, "a": "@reset$", "g": "list,json" },
288                    { "s": "#VAL", "a": "@reset$", "g": "val,json" },
289                ],
290                "close": [
291                    { "s": "#ZZ", "a": "@value$", "g": "end,json" },
292                    { "b": 1, "a": "@value$", "g": "more,json" },
293                ],
294            },
295            "map": {
296                "open": [
297                    { "s": "#OB #CB", "b": 1, "n": { "pk": 0 }, "a": "@object$", "g": "map,json" },
298                    { "s": "#OB", "p": "pair", "n": { "pk": 0 }, "a": "@object$", "g": "map,json,pair" },
299                ],
300                "close": [ { "s": "#CB", "g": "end,json" } ],
301            },
302            "list": {
303                "open": [
304                    { "s": "#OS #CS", "b": 1, "a": "@array$", "g": "list,json" },
305                    { "s": "#OS", "p": "elem", "a": "@array$", "g": "list,elem,json" },
306                ],
307                "close": [ { "s": "#CS", "g": "end,json" } ],
308            },
309            "pair": {
310                "open": [
311                    { "s": "#KEY #CL", "p": "val", "u": { "pair": true },
312                      "a": "@key$", "g": "map,pair,key,json" },
313                ],
314                "close": [
315                    { "s": "#CA", "r": "pair", "a": "@setval$", "g": "map,pair,comma,json" },
316                    { "s": "#CB", "b": 1, "a": "@setval$", "g": "map,pair,close,json" },
317                ],
318            },
319            // `"r": "elem"` REPLACES this rule, and `push$.chain: false`
320            // says no rule in the assembled grammar will resolve `$prev`
321            // to read the rule it displaced. It is unconditional here,
322            // where the TypeScript and Go ports make it an opt-in their
323            // rules-only installers leave off: this port has no such
324            // installer (see `json`'s docs), so this grammar IS the
325            // assembled grammar and the claim is the port's to make.
326            //
327            // It buys nothing either way. A list here is one shared
328            // array that every view already sees grow; the walk it skips
329            // is O(elements^2) only in Go, where a list is a slice VALUE.
330            // The key is declared so the three grammars stay one grammar.
331            "elem": {
332                "open": [ { "p": "val", "g": "list,elem,val,json" } ],
333                "close": [
334                    { "s": "#CA", "r": "elem", "a": "@push$",
335                      "k": { "push$": { "chain": false } },
336                      "g": "list,elem,comma,json" },
337                    { "s": "#CS", "b": 1, "a": "@push$",
338                      "k": { "push$": { "chain": false } },
339                      "g": "list,elem,close,json" },
340                ],
341            },
342        },
343
344        // Declared order, matching the order ts/src/json.ts declares the
345        // rules in. Without it the engine falls back to sorted names and
346        // anything reading rule order (railroad's extracted model among
347        // them) would report this grammar alphabetically.
348        "ruleOrder": ["val", "map", "list", "pair", "elem"],
349    })
350}
351
352/// Install the strict JSON options and the JSON rule set on `parser`.
353///
354/// This is the one entry point: `make` goes through it too, so the two
355/// construction paths cannot drift apart.
356///
357/// ```
358/// let mut parser = tabnas::Tabnas::new();
359/// tabnas_json::json(&mut parser)?;
360/// let value = parser.parse("[1,2,3]")?;
361/// assert_eq!(value.to_string(), "[1,2,3]");
362/// # Ok::<(), Box<dyn std::error::Error>>(())
363/// ```
364pub fn json(parser: &mut Tabnas) -> Result<(), GrammarError> {
365    parser.lex_check_ref(NUMBER_CHECK, strict_number_check);
366    let spec = GrammarSpec::from_value(json_document())?;
367    parser.grammar(&spec)?;
368    // AFTER the grammar, not before: `grammar` applies the document's
369    // options, and an options pass that does not mention `parse.budget`
370    // is not required to preserve one set earlier. Setting it here is
371    // also the same ordering rule `make` documents for caller options.
372    //
373    // Every iteration, because the check is what stands between a deeply
374    // nested source and a stack overflow; a sampled check would let the
375    // parse run past the limit by however many levels the sample missed.
376    parser.parse_budget(1, within_depth_limit);
377    Ok(())
378}
379
380/// Build a standard-JSON parser instance.
381///
382/// Infallible by design, and it goes through [`json()`] rather than
383/// duplicating the setup, so this path and installing the plugin by hand
384/// cannot drift. The document is a fixed literal, so a failure here is a
385/// bug in this crate rather than anything a caller did — the Go `Make`
386/// panics for the same reason, with the same justification.
387///
388/// ```
389/// let parser = tabnas_json::make();
390/// let value = parser.parse("[1,2,3]")?;
391/// assert_eq!(value.to_string(), "[1,2,3]");
392/// assert!(parser.parse("[1,2,]").is_err());
393/// # Ok::<(), tabnas_json::JsonError>(())
394/// ```
395pub fn make() -> Tabnas {
396    let mut parser = Tabnas::new();
397    json(&mut parser).expect("the JSON grammar document is fixed and valid");
398    parser
399}
400
401/// Parse a JSON source string with the shared default parser.
402///
403/// The engine is built once, on first use, and reused after that. Both
404/// other runtimes do the same (`sync.Once` in `go/json.go`, a lazily
405/// assigned module variable in `ts/src/json.ts`), and reuse is safe here
406/// for the same reason it is there: [`Tabnas::parse`] takes `&self` and
407/// builds a fresh parse context per call, and `Tabnas` is `Send + Sync`,
408/// so concurrent callers share one installed grammar instead of each
409/// rebuilding it. `tests/json_test.rs` pins that with a threaded test.
410///
411/// Use [`make`] instead when the parser needs configuring: that returns a
412/// fresh instance and leaves this one alone.
413///
414/// ```
415/// let value = tabnas_json::parse(r#"{"a":[1,2]}"#)?;
416/// assert_eq!(value.to_string(), r#"{"a":[1,2]}"#);
417/// assert_eq!(tabnas_json::parse("{a:1}").unwrap_err().code, "unexpected");
418/// # Ok::<(), tabnas_json::JsonError>(())
419/// ```
420pub fn parse(src: &str) -> Result<Value, JsonError> {
421    static DEFAULT: OnceLock<Tabnas> = OnceLock::new();
422    DEFAULT.get_or_init(make).parse(src)
423}
424
425#[cfg(test)]
426mod document {
427    use super::json_document;
428
429    /// The counterpart of `the json plugin opts out of the chain walk` in
430    /// `ts/test/json.test.js` and of its Go twin in `go/json_test.go`.
431    ///
432    /// It has to be asked of the DOCUMENT rather than of an installed
433    /// engine. The engine binds builtin config when it loads the spec and
434    /// then drops the consumed keys from the alternate, so reading the key
435    /// back off an installed grammar answers nothing against a current
436    /// engine, and answers the key only against one too old to recognise
437    /// it, which is residue rather than an answer. Go indirects its
438    /// `installGrammar` to get at the spec for the same reason; here the
439    /// document is a private function and a unit test can read it.
440    ///
441    /// The other two of that trio hold a rules-only installer to leaving
442    /// the claim off. This port has none, so they have no counterpart.
443    #[test]
444    fn the_grammar_opts_out_of_the_chain_walk() {
445        let document = json_document();
446        let alts = document["rule"]["elem"]["close"]
447            .as_array()
448            .expect("elem declares close alternates")
449            .clone();
450        assert_eq!(alts.len(), 2, "both close alts carry the claim");
451        for alt in &alts {
452            assert_eq!(
453                alt["k"]["push$"]["chain"],
454                serde_json::json!(false),
455                "this elem close alt should turn the chain walk off: {alt}"
456            );
457        }
458    }
459
460    /// The `KEY` token set is a WHOLESALE replacement, and the nulls are
461    /// what make it one.
462    ///
463    /// A token set in a serialized document overlays the installed one
464    /// position by position, so `["#ST"]` alone replaces the first member
465    /// of the engine default `#TX #NR #ST #VL` and leaves the tail in
466    /// place, which is a key set that still takes numbers and the bare
467    /// value words. `{1:1}` then parsed as `{}`. The shared fixtures catch
468    /// that, but only through an engine; this catches the spelling itself,
469    /// because three trailing nulls read like padding to anyone tidying.
470    #[test]
471    fn the_key_token_set_replaces_the_default_outright() {
472        let document = json_document();
473        let key = document["options"]["tokenSet"]["KEY"]
474            .as_array()
475            .expect("tokenSet.KEY is a list")
476            .clone();
477        assert_eq!(key[0], serde_json::json!("#ST"), "quoted strings only");
478        assert_eq!(
479            key.len(),
480            4,
481            "one entry per member of the default set, or the tail survives"
482        );
483        for member in key.iter().skip(1) {
484            assert!(member.is_null(), "a cleared position is null: {member}");
485        }
486    }
487}