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}