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.13";
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 depth check is not bound through the
158/// grammar document: `parse_guard` takes the closure directly, under the
159/// name [`DEPTH_GUARD`], and the document has nothing 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 depth guard: 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 name the depth check is installed under, as a parse guard.
208///
209/// A guard rather than the parse budget, because the budget is one slot
210/// that a caller's `parse_budget` replaces, and the limit went with it
211/// whenever a caller set a budget of its own. Nothing a caller does to the
212/// budget reaches a guard. A grammar built on this one that counts depth
213/// its own way installs its check under the same name to replace this one,
214/// as `tabnas_jsonic` does.
215const DEPTH_GUARD: &str = "depth";
216
217/// The one serialized document carrying both the strict-JSON options and
218/// the JSON rule set, mirroring `JSON_OPTIONS` + `registerJsonGrammar` in
219/// `ts/src/json.ts` and `jsonOptions` + `RegisterJSONGrammar` in
220/// `go/json.go`.
221///
222/// Options travel in the grammar document rather than through the typed
223/// `Options` struct because `number.check` can only be bound by name from
224/// here; everything else could go either way, and keeping them together
225/// means there is one definition of "strict JSON" rather than two halves
226/// that can drift.
227fn json_document() -> serde_json::Value {
228    json!({
229        // The schema version of the native-value builtins this grammar
230        // binds to (object/array/reset/key/setval/push/value).
231        "v": 2,
232
233        "options": {
234            "text": { "lex": false },
235            "number": {
236                "hex": false, "oct": false, "bin": false, "sep": null,
237                "check": NUMBER_CHECK,
238            },
239            "string": {
240                "chars": "\"",
241                "multiChars": "",
242                // Standard JSON escape handling: allowUnknown:false
243                // rejects any unrecognized escape (\q, \z); escapeStrict
244                // disables the engine's non-standard \xHH and \u{...}
245                // structural escapes (plain \uXXXX stays); and dropping
246                // v / ' / ` from the escape map removes the remaining
247                // non-standard built-ins. Result: exactly the JSON escape
248                // set, identical to the other two runtimes.
249                //
250                // NOTE the entries are `null`, where ts/src/json.ts writes
251                // `''`. Same intent, different idiom: this engine deletes
252                // an escape entry on a null value and treats `""` as a
253                // real mapping TO the empty string, so `""` here would
254                // make `"\v"` parse as `""` instead of being rejected --
255                // which is exactly what it did before this comment
256                // existed, on five spec rows.
257                "allowUnknown": false,
258                "escapeStrict": true,
259                "escape": { "v": null, "'": null, "`": null },
260            },
261            "comment": { "lex": false },
262            "map": { "extend": false },
263            "lex": { "empty": false },
264            // Restrict the rule set to the `json`-tagged alternates. The
265            // grammar below tags every alt "json", so on a bare engine
266            // this is inert; it matters when these options are applied
267            // over an already-extended grammar, keeping only its
268            // strict-JSON alternates.
269            "rule": { "finish": false, "include": "json" },
270            // Strict JSON keys are quoted strings only.
271            //
272            // Spelled with the three trailing nulls `ts/src/json.ts`
273            // spells it with, and for the same reason: a token set in a
274            // serialized document overlays the installed one INDEX-WISE,
275            // so a bare `["#ST"]` replaces position 0 and keeps the rest
276            // of the engine default (`#TX #NR #ST #VL`) underneath it.
277            // A `null` clears its position, so listing one per remaining
278            // member is how a wholesale replacement is written.
279            "tokenSet": { "KEY": ["#ST", null, null, null] },
280        },
281
282        // The value tree is built ENTIRELY by the engine's native-value
283        // `$`-builtins, referenced by name on the alts below; the engine
284        // merges them in at load. There are NO grammar-local closures.
285        //
286        //   @reset$  - clear the parent-seeded node.
287        //   @object$ - allocate an empty object into the node.
288        //   @array$  - allocate an empty array into the node.
289        //   @key$    - capture the matched key for the pending @setval$.
290        //   @setval$ - assign the built child value under that key.
291        //   @push$   - append the built child value to the array.
292        //   @value$  - resolve the rule's value (child wins, else token).
293        "rule": {
294            "val": {
295                "open": [
296                    { "s": "#OB", "p": "map",  "b": 1, "a": "@reset$", "g": "map,json" },
297                    { "s": "#OS", "p": "list", "b": 1, "a": "@reset$", "g": "list,json" },
298                    { "s": "#VAL", "a": "@reset$", "g": "val,json" },
299                ],
300                "close": [
301                    { "s": "#ZZ", "a": "@value$", "g": "end,json" },
302                    { "b": 1, "a": "@value$", "g": "more,json" },
303                ],
304            },
305            "map": {
306                "open": [
307                    { "s": "#OB #CB", "b": 1, "n": { "pk": 0 }, "a": "@object$", "g": "map,json" },
308                    { "s": "#OB", "p": "pair", "n": { "pk": 0 }, "a": "@object$", "g": "map,json,pair" },
309                ],
310                "close": [ { "s": "#CB", "g": "end,json" } ],
311            },
312            "list": {
313                "open": [
314                    { "s": "#OS #CS", "b": 1, "a": "@array$", "g": "list,json" },
315                    { "s": "#OS", "p": "elem", "a": "@array$", "g": "list,elem,json" },
316                ],
317                "close": [ { "s": "#CS", "g": "end,json" } ],
318            },
319            "pair": {
320                "open": [
321                    { "s": "#KEY #CL", "p": "val", "u": { "pair": true },
322                      "a": "@key$", "g": "map,pair,key,json" },
323                ],
324                "close": [
325                    { "s": "#CA", "r": "pair", "a": "@setval$", "g": "map,pair,comma,json" },
326                    { "s": "#CB", "b": 1, "a": "@setval$", "g": "map,pair,close,json" },
327                ],
328            },
329            // `"r": "elem"` REPLACES this rule, and `push$.chain: false`
330            // says no rule in the assembled grammar will resolve `$prev`
331            // to read the rule it displaced. It is unconditional here,
332            // where the TypeScript and Go ports make it an opt-in their
333            // rules-only installers leave off: this port has no such
334            // installer (see `json`'s docs), so this grammar IS the
335            // assembled grammar and the claim is the port's to make.
336            //
337            // It buys nothing either way. A list here is one shared
338            // array that every view already sees grow; the walk it skips
339            // is O(elements^2) only in Go, where a list is a slice VALUE.
340            // The key is declared so the three grammars stay one grammar.
341            "elem": {
342                "open": [ { "p": "val", "g": "list,elem,val,json" } ],
343                "close": [
344                    { "s": "#CA", "r": "elem", "a": "@push$",
345                      "k": { "push$": { "chain": false } },
346                      "g": "list,elem,comma,json" },
347                    { "s": "#CS", "b": 1, "a": "@push$",
348                      "k": { "push$": { "chain": false } },
349                      "g": "list,elem,close,json" },
350                ],
351            },
352        },
353
354        // Declared order, matching the order ts/src/json.ts declares the
355        // rules in. Without it the engine falls back to sorted names and
356        // anything reading rule order (railroad's extracted model among
357        // them) would report this grammar alphabetically.
358        "ruleOrder": ["val", "map", "list", "pair", "elem"],
359    })
360}
361
362/// Install the strict JSON options and the JSON rule set on `parser`.
363///
364/// This is the one entry point: `make` goes through it too, so the two
365/// construction paths cannot drift apart.
366///
367/// ```
368/// let mut parser = tabnas::Tabnas::new();
369/// tabnas_json::json(&mut parser)?;
370/// let value = parser.parse("[1,2,3]")?;
371/// assert_eq!(value.to_string(), "[1,2,3]");
372/// # Ok::<(), Box<dyn std::error::Error>>(())
373/// ```
374pub fn json(parser: &mut Tabnas) -> Result<(), GrammarError> {
375    parser.lex_check_ref(NUMBER_CHECK, strict_number_check);
376    let spec = GrammarSpec::from_value(json_document())?;
377    parser.grammar(&spec)?;
378    // A guard, not the budget (see `DEPTH_GUARD`): a budget the caller
379    // sets, before this or after it, runs beside the limit. Every step,
380    // because the check is what stands between a deeply nested source and
381    // a stack overflow; a sampled check would let the parse run past the
382    // limit by however many levels the sample missed.
383    parser.parse_guard(DEPTH_GUARD, within_depth_limit);
384    Ok(())
385}
386
387/// Build a standard-JSON parser instance.
388///
389/// Infallible by design, and it goes through [`json()`] rather than
390/// duplicating the setup, so this path and installing the plugin by hand
391/// cannot drift. The document is a fixed literal, so a failure here is a
392/// bug in this crate rather than anything a caller did — the Go `Make`
393/// panics for the same reason, with the same justification.
394///
395/// ```
396/// let parser = tabnas_json::make();
397/// let value = parser.parse("[1,2,3]")?;
398/// assert_eq!(value.to_string(), "[1,2,3]");
399/// assert!(parser.parse("[1,2,]").is_err());
400/// # Ok::<(), tabnas_json::JsonError>(())
401/// ```
402pub fn make() -> Tabnas {
403    let mut parser = Tabnas::new();
404    json(&mut parser).expect("the JSON grammar document is fixed and valid");
405    parser
406}
407
408/// Parse a JSON source string with the shared default parser.
409///
410/// The engine is built once, on first use, and reused after that. Both
411/// other runtimes do the same (`sync.Once` in `go/json.go`, a lazily
412/// assigned module variable in `ts/src/json.ts`), and reuse is safe here
413/// for the same reason it is there: [`Tabnas::parse`] takes `&self` and
414/// builds a fresh parse context per call, and `Tabnas` is `Send + Sync`,
415/// so concurrent callers share one installed grammar instead of each
416/// rebuilding it. `tests/json_test.rs` pins that with a threaded test.
417///
418/// Use [`make`] instead when the parser needs configuring: that returns a
419/// fresh instance and leaves this one alone.
420///
421/// ```
422/// let value = tabnas_json::parse(r#"{"a":[1,2]}"#)?;
423/// assert_eq!(value.to_string(), r#"{"a":[1,2]}"#);
424/// assert_eq!(tabnas_json::parse("{a:1}").unwrap_err().code, "unexpected");
425/// # Ok::<(), tabnas_json::JsonError>(())
426/// ```
427pub fn parse(src: &str) -> Result<Value, JsonError> {
428    static DEFAULT: OnceLock<Tabnas> = OnceLock::new();
429    DEFAULT.get_or_init(make).parse(src)
430}
431
432/// One optional alchemy source and the entry point a host calls.
433#[derive(Clone, Copy, Debug, Eq, PartialEq)]
434pub struct TranslationPart {
435    /// The entry point a host calls.
436    pub entry: &'static str,
437    /// The source text, or `None` for an entry supplied by alchemy.
438    pub source: Option<&'static str>,
439}
440
441/// The package-local structural translation interface.
442#[derive(Clone, Copy, Debug, Eq, PartialEq)]
443pub struct TranslationParts {
444    /// The complete `tabnas.plugin.json` text.
445    pub manifest: &'static str,
446    /// An optional lift from the grammar's events to its first read shape.
447    pub lift: Option<TranslationPart>,
448    /// An optional render from the write shape to text.
449    pub render: Option<TranslationPart>,
450}
451
452const TRANSLATION: TranslationParts = TranslationParts {
453    manifest: include_str!("../translate/manifest.json"),
454    lift: None,
455    render: Some(TranslationPart {
456        entry: "json",
457        source: None,
458    }),
459};
460
461/// Return JSON's immutable translation parts.
462#[must_use]
463pub const fn translate() -> Option<TranslationParts> {
464    Some(TRANSLATION)
465}
466
467/// The plugin's manifest, `tabnas.plugin.json`, as the repository carries
468/// it. Its `translate` object is what a host that translates reads: the
469/// shape JSON is read as and written from (`tree`) and the render that
470/// writes it, which is the `json` render alchemy carries rather than a
471/// file of this repository's. It declares no loss: that render keeps every
472/// value and every number's spelling, so a JSON document written from a
473/// JSON tree loses nothing. The crate embeds its own copy,
474/// `translate/manifest.json`, since a packaged crate holds nothing outside
475/// `rs/`; `tests/translate_test.rs` holds the copy to the file.
476///
477/// ```
478/// assert!(tabnas_json::manifest_text().contains("\"translate\""));
479/// ```
480pub fn manifest_text() -> &'static str {
481    TRANSLATION.manifest
482}
483
484#[cfg(test)]
485mod document {
486    use super::json_document;
487
488    /// The counterpart of `the json plugin opts out of the chain walk` in
489    /// `ts/test/json.test.js` and of its Go twin in `go/json_test.go`.
490    ///
491    /// It has to be asked of the DOCUMENT rather than of an installed
492    /// engine. The engine binds builtin config when it loads the spec and
493    /// then drops the consumed keys from the alternate, so reading the key
494    /// back off an installed grammar answers nothing against a current
495    /// engine, and answers the key only against one too old to recognise
496    /// it, which is residue rather than an answer. Go indirects its
497    /// `installGrammar` to get at the spec for the same reason; here the
498    /// document is a private function and a unit test can read it.
499    ///
500    /// The other two of that trio hold a rules-only installer to leaving
501    /// the claim off. This port has none, so they have no counterpart.
502    #[test]
503    fn the_grammar_opts_out_of_the_chain_walk() {
504        let document = json_document();
505        let alts = document["rule"]["elem"]["close"]
506            .as_array()
507            .expect("elem declares close alternates")
508            .clone();
509        assert_eq!(alts.len(), 2, "both close alts carry the claim");
510        for alt in &alts {
511            assert_eq!(
512                alt["k"]["push$"]["chain"],
513                serde_json::json!(false),
514                "this elem close alt should turn the chain walk off: {alt}"
515            );
516        }
517    }
518
519    /// The `KEY` token set is a WHOLESALE replacement, and the nulls are
520    /// what make it one.
521    ///
522    /// A token set in a serialized document overlays the installed one
523    /// position by position, so `["#ST"]` alone replaces the first member
524    /// of the engine default `#TX #NR #ST #VL` and leaves the tail in
525    /// place, which is a key set that still takes numbers and the bare
526    /// value words. `{1:1}` then parsed as `{}`. The shared fixtures catch
527    /// that, but only through an engine; this catches the spelling itself,
528    /// because three trailing nulls read like padding to anyone tidying.
529    #[test]
530    fn the_key_token_set_replaces_the_default_outright() {
531        let document = json_document();
532        let key = document["options"]["tokenSet"]["KEY"]
533            .as_array()
534            .expect("tokenSet.KEY is a list")
535            .clone();
536        assert_eq!(key[0], serde_json::json!("#ST"), "quoted strings only");
537        assert_eq!(
538            key.len(),
539            4,
540            "one entry per member of the default set, or the tail survives"
541        );
542        for member in key.iter().skip(1) {
543            assert!(member.is_null(), "a cleared position is null: {member}");
544        }
545    }
546}