Skip to main content

nomoreide_core/
js_json.rs

1//! `JSON.parse` failures, worded the way V8 words them.
2//!
3//! One route hands a client-supplied JSON *string* to a parser and lets the
4//! failure escape as the response's error message: the row browser's `filters`
5//! query parameter. The reference is Node, so that message is V8's parser
6//! diagnostic, and a client that shows it to a person shows V8's wording. This
7//! module reproduces that wording from `serde_json`'s.
8//!
9//! It is a translation of one parser's diagnostics into another's, not a
10//! parser, so it covers the shapes a malformed parameter actually arrives in
11//! and no more:
12//!
13//! | input | message |
14//! | --- | --- |
15//! | truncated or empty | `Unexpected end of JSON input` |
16//! | a character no value can start with | `Unexpected token 'x', "…" is not valid JSON` |
17//! | anything after a complete value | `Unexpected non-whitespace character after JSON at position N` |
18//! | a string with no closing quote | `Unterminated string in JSON at position N` |
19//! | a missing `,` or `]` between array elements | `Expected ',' or ']' after array element in JSON at position N` |
20//! | a key that is not a string | `Expected property name or '}' in JSON at position N` |
21//!
22//! Anything else falls back to the unexpected-token wording, which is V8's own
23//! most common answer. Positions are counted in **characters**; V8 counts UTF-16
24//! code units, so a message pointing past an astral character would differ.
25//! Every row of the table above is a case in the catalog parity gate.
26//!
27//! **Where this stops, on purpose.** A document that runs out *inside a
28//! container* is the one family not reproduced. V8 words those by what the
29//! parser was waiting for — a property name, a `:`, a `,` or `]`, a `,` or `}` —
30//! and `serde_json` reports only which container was open, so telling `[1` from
31//! `["a"` from `{"a"` would mean writing a second JSON scanner to recover a
32//! parse state. Those all answer `Unexpected end of JSON input` here. The
33//! status, the `ok: false`, and the shape are identical either way; only the
34//! sentence differs, and only for a `filters` value no client builds — the
35//! dashboard sends `JSON.stringify` output, which never truncates.
36
37use serde_json::Value;
38
39/// `JSON.parse(raw)`, with V8's message on failure.
40pub fn parse(raw: &str) -> Result<Value, String> {
41    serde_json::from_str(raw).map_err(|error| message(raw, &error))
42}
43
44fn message(raw: &str, error: &serde_json::Error) -> String {
45    let chars: Vec<char> = raw.chars().collect();
46    let position = position_of(&chars, error.line(), error.column());
47    // `classify` reports Eof for a value that simply ran out, and for a string
48    // that ran out — which V8 words differently, so the wording is chosen from
49    // serde's text before its category.
50    let text = error.to_string();
51    if text.starts_with("EOF while parsing a string") {
52        // The string ran to the end of the document, so that is where V8 points
53        // -- one past serde's last character, not at it.
54        return format!("Unterminated string in JSON {}", at(&chars, chars.len()));
55    }
56    if error.classify() == serde_json::error::Category::Eof {
57        return "Unexpected end of JSON input".to_string();
58    }
59    if text.starts_with("trailing characters") {
60        return format!(
61            "Unexpected non-whitespace character after JSON {}",
62            at(&chars, position)
63        );
64    }
65    if text.starts_with("expected `,` or `]`") {
66        return format!(
67            "Expected ',' or ']' after array element in JSON {}",
68            at(&chars, position)
69        );
70    }
71    if text.starts_with("key must be a string") {
72        return format!(
73            "Expected property name or '}}' in JSON {}",
74            at(&chars, position)
75        );
76    }
77    match chars.get(position) {
78        Some(token) => format!(
79            "Unexpected token '{token}', {} is not valid JSON",
80            snippet(&chars, position)
81        ),
82        None => "Unexpected end of JSON input".to_string(),
83    }
84}
85
86/// The character index serde's one-based line and column point at.
87fn position_of(chars: &[char], line: usize, column: usize) -> usize {
88    let mut index = 0;
89    for _ in 1..line {
90        match chars[index..]
91            .iter()
92            .position(|character| *character == '\n')
93        {
94            Some(offset) => index += offset + 1,
95            None => return chars.len(),
96        }
97    }
98    (index + column.saturating_sub(1)).min(chars.len())
99}
100
101/// V8 prints a position twice: once counting from zero, once as a line and a
102/// column counting from one.
103fn at(chars: &[char], position: usize) -> String {
104    let mut line = 1;
105    let mut column = 1;
106    for character in &chars[..position.min(chars.len())] {
107        if *character == '\n' {
108            line += 1;
109            column = 1;
110        } else {
111            column += 1;
112        }
113    }
114    format!("at position {position} (line {line} column {column})")
115}
116
117/// The offending text, quoted the way V8 quotes it.
118///
119/// A short document is shown whole. A long one is shown through a window of ten
120/// characters either side of the offending one, with an ellipsis on whichever
121/// end the window does not reach. The text inside the quotes is raw — a newline
122/// in the source is a newline in the message, not an escape.
123fn snippet(chars: &[char], position: usize) -> String {
124    if chars.len() <= 20 {
125        return format!("\"{}\"", chars.iter().collect::<String>());
126    }
127    let start = position.saturating_sub(10);
128    let end = (position + 10).min(chars.len());
129    let window: String = chars[start..end].iter().collect();
130    format!(
131        "{}\"{window}\"{}",
132        if start > 0 { "..." } else { "" },
133        if end < chars.len() { "..." } else { "" }
134    )
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    /// Every message here was read off a live `node -e 'JSON.parse(...)'`, not
142    /// written from the shape of the code.
143    fn failure(raw: &str) -> String {
144        parse(raw).expect_err(raw)
145    }
146
147    #[test]
148    fn says_what_v8_says() {
149        assert_eq!(
150            failure("oops"),
151            "Unexpected token 'o', \"oops\" is not valid JSON"
152        );
153        assert_eq!(
154            failure("[1,x]"),
155            "Unexpected token 'x', \"[1,x]\" is not valid JSON"
156        );
157        assert_eq!(
158            failure("@"),
159            "Unexpected token '@', \"@\" is not valid JSON"
160        );
161        assert_eq!(
162            failure("[1,2,]"),
163            "Unexpected token ']', \"[1,2,]\" is not valid JSON"
164        );
165    }
166
167    #[test]
168    fn a_document_that_ran_out_has_no_position() {
169        for raw in ["", "   ", "[", "[1,", "nul", "tru"] {
170            assert_eq!(failure(raw), "Unexpected end of JSON input", "{raw:?}");
171        }
172    }
173
174    #[test]
175    fn content_after_a_complete_value_is_its_own_complaint() {
176        assert_eq!(
177            failure("[1] x"),
178            "Unexpected non-whitespace character after JSON at position 4 (line 1 column 5)"
179        );
180        assert_eq!(
181            failure("[1]]"),
182            "Unexpected non-whitespace character after JSON at position 3 (line 1 column 4)"
183        );
184        assert_eq!(
185            failure("[1,2]junk"),
186            "Unexpected non-whitespace character after JSON at position 5 (line 1 column 6)"
187        );
188    }
189
190    #[test]
191    fn the_named_syntax_failures() {
192        assert_eq!(
193            failure("\"unterminated"),
194            "Unterminated string in JSON at position 13 (line 1 column 14)"
195        );
196        assert_eq!(
197            failure("[\"a"),
198            "Unterminated string in JSON at position 3 (line 1 column 4)"
199        );
200        assert_eq!(
201            failure("[1,\"a\"o]"),
202            "Expected ',' or ']' after array element in JSON at position 6 (line 1 column 7)"
203        );
204        assert_eq!(
205            failure("{a:1}"),
206            "Expected property name or '}' in JSON at position 1 (line 1 column 2)"
207        );
208    }
209
210    /// Twenty characters is the whole document; twenty-one is a window.
211    #[test]
212    fn the_window_opens_at_twenty_one_characters() {
213        assert_eq!(
214            failure(&"x".repeat(20)),
215            format!(
216                "Unexpected token 'x', \"{}\" is not valid JSON",
217                "x".repeat(20)
218            )
219        );
220        assert_eq!(
221            failure(&"x".repeat(21)),
222            "Unexpected token 'x', \"xxxxxxxxxx\"... is not valid JSON"
223        );
224        assert_eq!(
225            failure(&format!("[{}@]", "1,".repeat(9))),
226            "Unexpected token '@', ...\"1,1,1,1,1,@]\" is not valid JSON"
227        );
228    }
229
230    /// A newline inside the snippet is printed as a newline.
231    #[test]
232    fn a_multi_line_document_reports_a_multi_line_snippet() {
233        assert_eq!(
234            failure("[\n1,\noops\n]"),
235            "Unexpected token 'o', \"[\n1,\noops\n]\" is not valid JSON"
236        );
237    }
238
239    #[test]
240    fn a_document_that_parses_comes_back_parsed() {
241        assert_eq!(parse("[1,2]").unwrap(), serde_json::json!([1, 2]));
242    }
243}