Skip to main content

ftui_widgets/
json_view.rs

1//! JSON view widget for pretty-printing JSON text.
2//!
3//! Renders formatted JSON with indentation and optional syntax highlighting.
4//! Does not depend on serde; operates on raw JSON strings with a minimal
5//! tokenizer.
6//!
7//! # Example
8//!
9//! ```
10//! use ftui_widgets::json_view::JsonView;
11//!
12//! let json = r#"{"name": "Alice", "age": 30}"#;
13//! let view = JsonView::new(json);
14//! let lines = view.formatted_lines();
15//! assert!(lines.len() > 1); // Pretty-printed across multiple lines
16//! ```
17
18use crate::{Widget, draw_text_span};
19use ftui_core::geometry::Rect;
20use ftui_render::frame::Frame;
21use ftui_style::Style;
22
23/// A classified JSON token for rendering.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum JsonToken {
26    /// Object key (string before colon).
27    Key(String),
28    /// String value.
29    StringVal(String),
30    /// Number value.
31    Number(String),
32    /// Boolean or null literal.
33    Literal(String),
34    /// Structural character: `{`, `}`, `[`, `]`, `:`, `,`.
35    Punctuation(String),
36    /// Whitespace / indentation.
37    Whitespace(String),
38    /// Newline.
39    Newline,
40    /// Error text (invalid JSON portion).
41    Error(String),
42}
43
44/// Widget that renders pretty-printed JSON with syntax coloring.
45#[derive(Debug, Clone)]
46pub struct JsonView {
47    source: String,
48    indent: usize,
49    key_style: Style,
50    string_style: Style,
51    number_style: Style,
52    literal_style: Style,
53    punct_style: Style,
54    error_style: Style,
55}
56
57impl Default for JsonView {
58    fn default() -> Self {
59        Self::new("")
60    }
61}
62
63impl JsonView {
64    /// Create a new JSON view from a raw JSON string.
65    #[must_use]
66    pub fn new(source: impl Into<String>) -> Self {
67        Self {
68            source: source.into(),
69            indent: 2,
70            key_style: Style::new().bold(),
71            string_style: Style::default(),
72            number_style: Style::default(),
73            literal_style: Style::default(),
74            punct_style: Style::default(),
75            error_style: Style::default(),
76        }
77    }
78
79    /// Set the indentation width.
80    #[must_use]
81    pub fn with_indent(mut self, indent: usize) -> Self {
82        self.indent = indent;
83        self
84    }
85
86    /// Set style for object keys.
87    #[must_use]
88    pub fn with_key_style(mut self, style: Style) -> Self {
89        self.key_style = style;
90        self
91    }
92
93    /// Set style for string values.
94    #[must_use]
95    pub fn with_string_style(mut self, style: Style) -> Self {
96        self.string_style = style;
97        self
98    }
99
100    /// Set style for numbers.
101    #[must_use]
102    pub fn with_number_style(mut self, style: Style) -> Self {
103        self.number_style = style;
104        self
105    }
106
107    /// Set style for boolean/null literals.
108    #[must_use]
109    pub fn with_literal_style(mut self, style: Style) -> Self {
110        self.literal_style = style;
111        self
112    }
113
114    /// Set style for punctuation.
115    #[must_use]
116    pub fn with_punct_style(mut self, style: Style) -> Self {
117        self.punct_style = style;
118        self
119    }
120
121    /// Set style for error text.
122    #[must_use]
123    pub fn with_error_style(mut self, style: Style) -> Self {
124        self.error_style = style;
125        self
126    }
127
128    /// Set the source JSON.
129    pub fn set_source(&mut self, source: impl Into<String>) {
130        self.source = source.into();
131    }
132
133    /// Get the source JSON.
134    #[must_use]
135    pub fn source(&self) -> &str {
136        &self.source
137    }
138
139    /// Pretty-format the JSON into lines of tokens for rendering.
140    #[must_use]
141    pub fn formatted_lines(&self) -> Vec<Vec<JsonToken>> {
142        let trimmed = self.source.trim();
143        if trimmed.is_empty() {
144            return vec![];
145        }
146
147        let mut lines: Vec<Vec<JsonToken>> = Vec::new();
148        let mut current_line: Vec<JsonToken> = Vec::new();
149        let mut depth: usize = 0;
150        let mut chars = trimmed.chars().peekable();
151
152        while let Some(&ch) = chars.peek() {
153            match ch {
154                '{' | '[' => {
155                    chars.next();
156                    current_line.push(JsonToken::Punctuation(ch.to_string()));
157                    // Check if next non-whitespace is closing bracket
158                    skip_ws(&mut chars);
159                    let next = chars.peek().copied();
160                    if next == Some('}') || next == Some(']') {
161                        // Empty object/array
162                        let closing = chars.next().unwrap();
163                        current_line.push(JsonToken::Punctuation(closing.to_string()));
164                        // Check for comma
165                        skip_ws(&mut chars);
166                        if chars.peek() == Some(&',') {
167                            chars.next();
168                            current_line.push(JsonToken::Punctuation(",".to_string()));
169                        }
170                    } else {
171                        depth += 1;
172                        lines.push(current_line);
173                        current_line = vec![JsonToken::Whitespace(make_indent(
174                            depth.min(32),
175                            self.indent,
176                        ))];
177                    }
178                }
179                '}' | ']' => {
180                    chars.next();
181                    depth = depth.saturating_sub(1);
182                    lines.push(current_line);
183                    current_line = vec![
184                        JsonToken::Whitespace(make_indent(depth, self.indent)),
185                        JsonToken::Punctuation(ch.to_string()),
186                    ];
187                    // Check for comma
188                    skip_ws(&mut chars);
189                    if chars.peek() == Some(&',') {
190                        chars.next();
191                        current_line.push(JsonToken::Punctuation(",".to_string()));
192                    }
193                }
194                '"' => {
195                    let s = read_string(&mut chars);
196                    skip_ws(&mut chars);
197                    if chars.peek() == Some(&':') {
198                        // This is a key
199                        current_line.push(JsonToken::Key(s));
200                        chars.next();
201                        current_line.push(JsonToken::Punctuation(": ".to_string()));
202                        skip_ws(&mut chars);
203                    } else {
204                        current_line.push(JsonToken::StringVal(s));
205                        // Check for comma
206                        skip_ws(&mut chars);
207                        if chars.peek() == Some(&',') {
208                            chars.next();
209                            current_line.push(JsonToken::Punctuation(",".to_string()));
210                            lines.push(current_line);
211                            current_line = vec![JsonToken::Whitespace(make_indent(
212                                depth.min(32),
213                                self.indent,
214                            ))];
215                        }
216                    }
217                }
218                ',' => {
219                    chars.next();
220                    current_line.push(JsonToken::Punctuation(",".to_string()));
221                    lines.push(current_line);
222                    current_line = vec![JsonToken::Whitespace(make_indent(
223                        depth.min(32),
224                        self.indent,
225                    ))];
226                }
227                ':' => {
228                    chars.next();
229                    current_line.push(JsonToken::Punctuation(": ".to_string()));
230                    skip_ws(&mut chars);
231                }
232                ' ' | '\t' | '\r' | '\n' => {
233                    chars.next();
234                }
235                _ => {
236                    // Number, boolean, null, or error
237                    let literal = read_literal(&mut chars);
238                    let tok = classify_literal(&literal);
239                    current_line.push(tok);
240                    // Check for comma
241                    skip_ws(&mut chars);
242                    if chars.peek() == Some(&',') {
243                        chars.next();
244                        current_line.push(JsonToken::Punctuation(",".to_string()));
245                        lines.push(current_line);
246                        current_line = vec![JsonToken::Whitespace(make_indent(
247                            depth.min(32),
248                            self.indent,
249                        ))];
250                    }
251                }
252            }
253        }
254
255        if !current_line.is_empty() {
256            lines.push(current_line);
257        }
258
259        lines
260    }
261}
262
263fn make_indent(depth: usize, width: usize) -> String {
264    " ".repeat(depth * width)
265}
266
267fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
268    while let Some(&ch) = chars.peek() {
269        if ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' {
270            chars.next();
271        } else {
272            break;
273        }
274    }
275}
276
277fn read_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
278    let mut s = String::new();
279    s.push('"');
280    chars.next(); // consume opening quote
281    let mut escaped = false;
282    for ch in chars.by_ref() {
283        s.push(ch);
284        if escaped {
285            escaped = false;
286        } else if ch == '\\' {
287            escaped = true;
288        } else if ch == '"' {
289            break;
290        }
291    }
292    s
293}
294
295fn read_literal(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
296    let mut s = String::new();
297    while let Some(&ch) = chars.peek() {
298        if ch == ','
299            || ch == '}'
300            || ch == ']'
301            || ch == ':'
302            || ch == ' '
303            || ch == '\n'
304            || ch == '\r'
305            || ch == '\t'
306        {
307            break;
308        }
309        s.push(ch);
310        chars.next();
311    }
312    s
313}
314
315fn classify_literal(s: &str) -> JsonToken {
316    match s {
317        "true" | "false" | "null" => JsonToken::Literal(s.to_string()),
318        _ => {
319            // Try as number
320            if s.bytes().all(|b| {
321                b.is_ascii_digit() || b == b'.' || b == b'-' || b == b'+' || b == b'e' || b == b'E'
322            }) && !s.is_empty()
323            {
324                JsonToken::Number(s.to_string())
325            } else {
326                JsonToken::Error(s.to_string())
327            }
328        }
329    }
330}
331
332impl Widget for JsonView {
333    fn render(&self, area: Rect, frame: &mut Frame) {
334        if area.width == 0 || area.height == 0 {
335            return;
336        }
337
338        let deg = frame.buffer.degradation;
339        let lines = self.formatted_lines();
340        let max_x = area.right();
341
342        for (row_idx, tokens) in lines.iter().enumerate() {
343            if row_idx >= area.height as usize {
344                break;
345            }
346
347            let y = area.y.saturating_add(row_idx as u16);
348            let mut x = area.x;
349
350            for token in tokens {
351                let (text, style) = match token {
352                    JsonToken::Key(s) => (s.as_str(), self.key_style),
353                    JsonToken::StringVal(s) => (s.as_str(), self.string_style),
354                    JsonToken::Number(s) => (s.as_str(), self.number_style),
355                    JsonToken::Literal(s) => (s.as_str(), self.literal_style),
356                    JsonToken::Punctuation(s) => (s.as_str(), self.punct_style),
357                    JsonToken::Whitespace(s) => (s.as_str(), Style::default()),
358                    JsonToken::Error(s) => (s.as_str(), self.error_style),
359                    JsonToken::Newline => continue,
360                };
361
362                if deg.apply_styling() {
363                    x = draw_text_span(frame, x, y, text, style, max_x);
364                } else {
365                    x = draw_text_span(frame, x, y, text, Style::default(), max_x);
366                }
367            }
368        }
369    }
370
371    fn is_essential(&self) -> bool {
372        false
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use ftui_render::frame::Frame;
380    use ftui_render::grapheme_pool::GraphemePool;
381
382    #[test]
383    fn empty_source() {
384        let view = JsonView::new("");
385        assert!(view.formatted_lines().is_empty());
386    }
387
388    #[test]
389    fn simple_object() {
390        let view = JsonView::new(r#"{"a": 1}"#);
391        let lines = view.formatted_lines();
392        assert!(lines.len() >= 3); // { + content + }
393    }
394
395    #[test]
396    fn nested_object() {
397        let view = JsonView::new(r#"{"a": {"b": 2}}"#);
398        let lines = view.formatted_lines();
399        assert!(lines.len() >= 3);
400    }
401
402    #[test]
403    fn array() {
404        let view = JsonView::new(r#"[1, 2, 3]"#);
405        let lines = view.formatted_lines();
406        assert!(lines.len() >= 3);
407    }
408
409    #[test]
410    fn empty_object() {
411        let view = JsonView::new(r#"{}"#);
412        let lines = view.formatted_lines();
413        assert!(!lines.is_empty());
414        // Should be compact: single line with {}
415    }
416
417    #[test]
418    fn empty_array() {
419        let view = JsonView::new(r#"[]"#);
420        let lines = view.formatted_lines();
421        assert!(!lines.is_empty());
422    }
423
424    #[test]
425    fn string_values() {
426        let view = JsonView::new(r#"{"msg": "hello world"}"#);
427        let lines = view.formatted_lines();
428        // Should contain StringVal token with quoted string
429        let has_string = lines.iter().any(|line| {
430            line.iter()
431                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("hello")))
432        });
433        assert!(has_string);
434    }
435
436    #[test]
437    fn boolean_and_null() {
438        let view = JsonView::new(r#"{"a": true, "b": false, "c": null}"#);
439        let lines = view.formatted_lines();
440        let has_literal = lines.iter().any(|line| {
441            line.iter()
442                .any(|t| matches!(t, JsonToken::Literal(s) if s == "true"))
443        });
444        assert!(has_literal);
445    }
446
447    #[test]
448    fn numbers() {
449        let view = JsonView::new(r#"{"x": 42, "y": -3.14}"#);
450        let lines = view.formatted_lines();
451        let has_number = lines.iter().any(|line| {
452            line.iter()
453                .any(|t| matches!(t, JsonToken::Number(s) if s == "42"))
454        });
455        assert!(has_number);
456    }
457
458    #[test]
459    fn escaped_string() {
460        let view = JsonView::new(r#"{"msg": "hello \"world\""}"#);
461        let lines = view.formatted_lines();
462        let has_escaped = lines.iter().any(|line| {
463            line.iter()
464                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("\\\"")))
465        });
466        assert!(has_escaped);
467    }
468
469    #[test]
470    fn indent_width() {
471        let view = JsonView::new(r#"{"a": 1}"#).with_indent(4);
472        let lines = view.formatted_lines();
473        let has_4_indent = lines.iter().any(|line| {
474            line.iter()
475                .any(|t| matches!(t, JsonToken::Whitespace(s) if s == "    "))
476        });
477        assert!(has_4_indent);
478    }
479
480    #[test]
481    fn render_basic() {
482        let view = JsonView::new(r#"{"key": "value"}"#);
483        let mut pool = GraphemePool::new();
484        let mut frame = Frame::new(40, 10, &mut pool);
485        let area = Rect::new(0, 0, 40, 10);
486        view.render(area, &mut frame);
487
488        // First char should be '{'
489        let cell = frame.buffer.get(0, 0).unwrap();
490        assert_eq!(cell.content.as_char(), Some('{'));
491    }
492
493    #[test]
494    fn render_zero_area() {
495        let view = JsonView::new(r#"{"a": 1}"#);
496        let mut pool = GraphemePool::new();
497        let mut frame = Frame::new(40, 10, &mut pool);
498        view.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
499    }
500
501    #[test]
502    fn render_truncated_height() {
503        let view = JsonView::new(r#"{"a": 1, "b": 2, "c": 3}"#);
504        let mut pool = GraphemePool::new();
505        let mut frame = Frame::new(40, 2, &mut pool);
506        let area = Rect::new(0, 0, 40, 2);
507        view.render(area, &mut frame); // Only first 2 lines, no panic
508    }
509
510    #[test]
511    fn is_not_essential() {
512        let view = JsonView::new("");
513        assert!(!view.is_essential());
514    }
515
516    #[test]
517    fn default_impl() {
518        let view = JsonView::default();
519        assert!(view.source().is_empty());
520    }
521
522    #[test]
523    fn set_source() {
524        let mut view = JsonView::new("");
525        view.set_source(r#"{"a": 1}"#);
526        assert!(!view.formatted_lines().is_empty());
527    }
528
529    #[test]
530    fn plain_literal() {
531        let view = JsonView::new("42");
532        let lines = view.formatted_lines();
533        assert_eq!(lines.len(), 1);
534    }
535
536    // ─── Edge-case tests (bd-2agoi) ────────────────────────────────────
537
538    #[test]
539    fn whitespace_only_source() {
540        let view = JsonView::new("   \n\t  ");
541        assert!(view.formatted_lines().is_empty());
542    }
543
544    #[test]
545    fn deeply_nested_objects() {
546        // 35 levels deep — depth clamped at 32 for indent
547        let open: String = "{\"a\": ".repeat(35);
548        let close: String = "}".repeat(35);
549        let json = format!("{open}1{close}");
550        let view = JsonView::new(json);
551        let lines = view.formatted_lines();
552        // Should not panic and produce output
553        assert!(lines.len() > 10);
554    }
555
556    #[test]
557    fn scientific_notation_number() {
558        let view = JsonView::new(r#"{"x": 1.23e+10}"#);
559        let lines = view.formatted_lines();
560        let has_sci = lines.iter().any(|line| {
561            line.iter()
562                .any(|t| matches!(t, JsonToken::Number(s) if s.contains("e+")))
563        });
564        assert!(has_sci, "scientific notation should be Number: {lines:?}");
565    }
566
567    #[test]
568    fn empty_string_key_and_value() {
569        let view = JsonView::new(r#"{"": ""}"#);
570        let lines = view.formatted_lines();
571        let has_empty_key = lines.iter().any(|line| {
572            line.iter()
573                .any(|t| matches!(t, JsonToken::Key(s) if s == "\"\""))
574        });
575        assert!(has_empty_key, "empty key should be present: {lines:?}");
576    }
577
578    #[test]
579    fn unicode_in_strings() {
580        let view = JsonView::new(r#"{"emoji": "🎉🚀"}"#);
581        let lines = view.formatted_lines();
582        let has_emoji = lines.iter().any(|line| {
583            line.iter()
584                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains('🎉')))
585        });
586        assert!(has_emoji);
587    }
588
589    #[test]
590    fn unclosed_string() {
591        // Missing closing quote — tokenizer reads until EOF
592        let view = JsonView::new(r#"{"key": "val"#);
593        let lines = view.formatted_lines();
594        // Should not panic; produces some output
595        assert!(!lines.is_empty());
596    }
597
598    #[test]
599    fn unclosed_object() {
600        let view = JsonView::new(r#"{"a": 1"#);
601        let lines = view.formatted_lines();
602        assert!(!lines.is_empty());
603    }
604
605    #[test]
606    fn unclosed_array() {
607        let view = JsonView::new(r#"[1, 2, 3"#);
608        let lines = view.formatted_lines();
609        assert!(!lines.is_empty());
610    }
611
612    #[test]
613    fn nested_empty_containers() {
614        let view = JsonView::new(r#"{"a": [], "b": {}}"#);
615        let lines = view.formatted_lines();
616        // [] and {} should appear compact
617        let flat = lines
618            .iter()
619            .map(|line| {
620                line.iter()
621                    .filter_map(|t| match t {
622                        JsonToken::Punctuation(s) => Some(s.as_str()),
623                        _ => None,
624                    })
625                    .collect::<String>()
626            })
627            .collect::<String>();
628        assert!(flat.contains("[]"), "empty array should be compact: {flat}");
629        assert!(
630            flat.contains("{}"),
631            "empty object should be compact: {flat}"
632        );
633    }
634
635    #[test]
636    fn array_of_mixed_types() {
637        let view = JsonView::new(r#"[1, "two", true, null]"#);
638        let lines = view.formatted_lines();
639        let all_tokens: Vec<&JsonToken> = lines.iter().flat_map(|l| l.iter()).collect();
640        assert!(all_tokens.iter().any(|t| matches!(t, JsonToken::Number(_))));
641        assert!(
642            all_tokens
643                .iter()
644                .any(|t| matches!(t, JsonToken::StringVal(_)))
645        );
646        assert!(
647            all_tokens
648                .iter()
649                .any(|t| matches!(t, JsonToken::Literal(s) if s == "true"))
650        );
651        assert!(
652            all_tokens
653                .iter()
654                .any(|t| matches!(t, JsonToken::Literal(s) if s == "null"))
655        );
656    }
657
658    #[test]
659    fn zero_indent_width() {
660        let view = JsonView::new(r#"{"a": 1}"#).with_indent(0);
661        let lines = view.formatted_lines();
662        // Indentation should be empty strings
663        for line in &lines {
664            for token in line {
665                if let JsonToken::Whitespace(s) = token {
666                    assert!(s.is_empty(), "zero indent should produce empty whitespace");
667                }
668            }
669        }
670    }
671
672    #[test]
673    fn bare_string_top_level() {
674        let view = JsonView::new(r#""hello""#);
675        let lines = view.formatted_lines();
676        assert_eq!(lines.len(), 1);
677        assert!(
678            lines[0]
679                .iter()
680                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("hello")))
681        );
682    }
683
684    #[test]
685    fn error_token_for_invalid_literal() {
686        let view = JsonView::new(r#"{"a": undefined}"#);
687        let lines = view.formatted_lines();
688        let has_error = lines
689            .iter()
690            .any(|line| line.iter().any(|t| matches!(t, JsonToken::Error(_))));
691        assert!(has_error, "undefined should produce Error token");
692    }
693
694    #[test]
695    fn clone_independence() {
696        let view = JsonView::new(r#"{"a": 1}"#);
697        let cloned = view.clone();
698        assert_eq!(view.source(), cloned.source());
699    }
700
701    #[test]
702    fn debug_format() {
703        let view = JsonView::new("{}");
704        let dbg = format!("{view:?}");
705        assert!(dbg.contains("JsonView"));
706    }
707
708    #[test]
709    fn style_builders_chain() {
710        let view = JsonView::new("{}")
711            .with_indent(4)
712            .with_key_style(Style::new().bold())
713            .with_string_style(Style::default())
714            .with_number_style(Style::default())
715            .with_literal_style(Style::default())
716            .with_punct_style(Style::default())
717            .with_error_style(Style::default());
718        assert_eq!(view.indent, 4);
719    }
720
721    #[test]
722    fn render_width_one() {
723        let view = JsonView::new(r#"{"a": 1}"#);
724        let mut pool = GraphemePool::new();
725        let mut frame = Frame::new(1, 10, &mut pool);
726        view.render(Rect::new(0, 0, 1, 10), &mut frame);
727        // Should render first char of each line without panic
728        let cell = frame.buffer.get(0, 0).unwrap();
729        assert_eq!(cell.content.as_char(), Some('{'));
730    }
731
732    #[test]
733    fn json_token_eq() {
734        assert_eq!(JsonToken::Key("a".into()), JsonToken::Key("a".into()));
735        assert_ne!(JsonToken::Key("a".into()), JsonToken::StringVal("a".into()));
736        assert_ne!(JsonToken::Newline, JsonToken::Whitespace("".into()));
737    }
738
739    #[test]
740    fn json_token_clone_and_debug() {
741        let tokens = vec![
742            JsonToken::Key("k".into()),
743            JsonToken::StringVal("s".into()),
744            JsonToken::Number("1".into()),
745            JsonToken::Literal("true".into()),
746            JsonToken::Punctuation("{".into()),
747            JsonToken::Whitespace("  ".into()),
748            JsonToken::Newline,
749            JsonToken::Error("bad".into()),
750        ];
751        for tok in &tokens {
752            let cloned = tok.clone();
753            assert_eq!(tok, &cloned);
754            let _ = format!("{tok:?}");
755        }
756    }
757
758    #[test]
759    fn classify_literal_empty_string() {
760        // Empty literal should be Error (not a number or keyword)
761        let result = classify_literal("");
762        assert!(matches!(result, JsonToken::Error(s) if s.is_empty()));
763    }
764
765    #[test]
766    fn negative_number() {
767        assert_eq!(
768            classify_literal("-42"),
769            JsonToken::Number("-42".to_string())
770        );
771    }
772
773    #[test]
774    fn number_with_exponent() {
775        assert_eq!(
776            classify_literal("5E-3"),
777            JsonToken::Number("5E-3".to_string())
778        );
779    }
780
781    // ─── End edge-case tests (bd-2agoi) ──────────────────────────────
782
783    #[test]
784    fn classify_literal_types() {
785        assert_eq!(
786            classify_literal("true"),
787            JsonToken::Literal("true".to_string())
788        );
789        assert_eq!(
790            classify_literal("false"),
791            JsonToken::Literal("false".to_string())
792        );
793        assert_eq!(
794            classify_literal("null"),
795            JsonToken::Literal("null".to_string())
796        );
797        assert_eq!(classify_literal("42"), JsonToken::Number("42".to_string()));
798        assert_eq!(
799            classify_literal("-3.14"),
800            JsonToken::Number("-3.14".to_string())
801        );
802        assert!(matches!(classify_literal("invalid!"), JsonToken::Error(_)));
803    }
804}