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        frame.buffer.fill(area, ftui_render::cell::Cell::default());
340        if !deg.render_content() {
341            return;
342        }
343        let lines = self.formatted_lines();
344        let max_x = area.right();
345
346        for (row_idx, tokens) in lines.iter().enumerate() {
347            if row_idx >= area.height as usize {
348                break;
349            }
350
351            let y = area.y.saturating_add(row_idx as u16);
352            let mut x = area.x;
353
354            for token in tokens {
355                let (text, style) = match token {
356                    JsonToken::Key(s) => (s.as_str(), self.key_style),
357                    JsonToken::StringVal(s) => (s.as_str(), self.string_style),
358                    JsonToken::Number(s) => (s.as_str(), self.number_style),
359                    JsonToken::Literal(s) => (s.as_str(), self.literal_style),
360                    JsonToken::Punctuation(s) => (s.as_str(), self.punct_style),
361                    JsonToken::Whitespace(s) => (s.as_str(), Style::default()),
362                    JsonToken::Error(s) => (s.as_str(), self.error_style),
363                    JsonToken::Newline => continue,
364                };
365
366                if deg.apply_styling() {
367                    x = draw_text_span(frame, x, y, text, style, max_x);
368                } else {
369                    x = draw_text_span(frame, x, y, text, Style::default(), max_x);
370                }
371            }
372        }
373    }
374
375    fn is_essential(&self) -> bool {
376        false
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use ftui_render::budget::DegradationLevel;
384    use ftui_render::cell::{CellAttrs, PackedRgba};
385    use ftui_render::frame::Frame;
386    use ftui_render::grapheme_pool::GraphemePool;
387
388    #[test]
389    fn empty_source() {
390        let view = JsonView::new("");
391        assert!(view.formatted_lines().is_empty());
392    }
393
394    #[test]
395    fn simple_object() {
396        let view = JsonView::new(r#"{"a": 1}"#);
397        let lines = view.formatted_lines();
398        assert!(lines.len() >= 3); // { + content + }
399    }
400
401    #[test]
402    fn nested_object() {
403        let view = JsonView::new(r#"{"a": {"b": 2}}"#);
404        let lines = view.formatted_lines();
405        assert!(lines.len() >= 3);
406    }
407
408    #[test]
409    fn array() {
410        let view = JsonView::new(r#"[1, 2, 3]"#);
411        let lines = view.formatted_lines();
412        assert!(lines.len() >= 3);
413    }
414
415    #[test]
416    fn empty_object() {
417        let view = JsonView::new(r#"{}"#);
418        let lines = view.formatted_lines();
419        assert!(!lines.is_empty());
420        // Should be compact: single line with {}
421    }
422
423    #[test]
424    fn empty_array() {
425        let view = JsonView::new(r#"[]"#);
426        let lines = view.formatted_lines();
427        assert!(!lines.is_empty());
428    }
429
430    #[test]
431    fn string_values() {
432        let view = JsonView::new(r#"{"msg": "hello world"}"#);
433        let lines = view.formatted_lines();
434        // Should contain StringVal token with quoted string
435        let has_string = lines.iter().any(|line| {
436            line.iter()
437                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("hello")))
438        });
439        assert!(has_string);
440    }
441
442    #[test]
443    fn boolean_and_null() {
444        let view = JsonView::new(r#"{"a": true, "b": false, "c": null}"#);
445        let lines = view.formatted_lines();
446        let has_literal = lines.iter().any(|line| {
447            line.iter()
448                .any(|t| matches!(t, JsonToken::Literal(s) if s == "true"))
449        });
450        assert!(has_literal);
451    }
452
453    #[test]
454    fn numbers() {
455        let view = JsonView::new(r#"{"x": 42, "y": -3.14}"#);
456        let lines = view.formatted_lines();
457        let has_number = lines.iter().any(|line| {
458            line.iter()
459                .any(|t| matches!(t, JsonToken::Number(s) if s == "42"))
460        });
461        assert!(has_number);
462    }
463
464    #[test]
465    fn escaped_string() {
466        let view = JsonView::new(r#"{"msg": "hello \"world\""}"#);
467        let lines = view.formatted_lines();
468        let has_escaped = lines.iter().any(|line| {
469            line.iter()
470                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("\\\"")))
471        });
472        assert!(has_escaped);
473    }
474
475    #[test]
476    fn indent_width() {
477        let view = JsonView::new(r#"{"a": 1}"#).with_indent(4);
478        let lines = view.formatted_lines();
479        let has_4_indent = lines.iter().any(|line| {
480            line.iter()
481                .any(|t| matches!(t, JsonToken::Whitespace(s) if s == "    "))
482        });
483        assert!(has_4_indent);
484    }
485
486    #[test]
487    fn render_basic() {
488        let view = JsonView::new(r#"{"key": "value"}"#);
489        let mut pool = GraphemePool::new();
490        let mut frame = Frame::new(40, 10, &mut pool);
491        let area = Rect::new(0, 0, 40, 10);
492        view.render(area, &mut frame);
493
494        // First char should be '{'
495        let cell = frame.buffer.get(0, 0).unwrap();
496        assert_eq!(cell.content.as_char(), Some('{'));
497    }
498
499    #[test]
500    fn render_zero_area() {
501        let view = JsonView::new(r#"{"a": 1}"#);
502        let mut pool = GraphemePool::new();
503        let mut frame = Frame::new(40, 10, &mut pool);
504        view.render(Rect::new(0, 0, 0, 0), &mut frame); // No panic
505    }
506
507    #[test]
508    fn render_truncated_height() {
509        let view = JsonView::new(r#"{"a": 1, "b": 2, "c": 3}"#);
510        let mut pool = GraphemePool::new();
511        let mut frame = Frame::new(40, 2, &mut pool);
512        let area = Rect::new(0, 0, 40, 2);
513        view.render(area, &mut frame); // Only first 2 lines, no panic
514    }
515
516    #[test]
517    fn is_not_essential() {
518        let view = JsonView::new("");
519        assert!(!view.is_essential());
520    }
521
522    #[test]
523    fn default_impl() {
524        let view = JsonView::default();
525        assert!(view.source().is_empty());
526    }
527
528    #[test]
529    fn set_source() {
530        let mut view = JsonView::new("");
531        view.set_source(r#"{"a": 1}"#);
532        assert!(!view.formatted_lines().is_empty());
533    }
534
535    #[test]
536    fn plain_literal() {
537        let view = JsonView::new("42");
538        let lines = view.formatted_lines();
539        assert_eq!(lines.len(), 1);
540    }
541
542    // ─── Edge-case tests (bd-2agoi) ────────────────────────────────────
543
544    #[test]
545    fn whitespace_only_source() {
546        let view = JsonView::new("   \n\t  ");
547        assert!(view.formatted_lines().is_empty());
548    }
549
550    #[test]
551    fn deeply_nested_objects() {
552        // 35 levels deep — depth clamped at 32 for indent
553        let open: String = "{\"a\": ".repeat(35);
554        let close: String = "}".repeat(35);
555        let json = format!("{open}1{close}");
556        let view = JsonView::new(json);
557        let lines = view.formatted_lines();
558        // Should not panic and produce output
559        assert!(lines.len() > 10);
560    }
561
562    #[test]
563    fn scientific_notation_number() {
564        let view = JsonView::new(r#"{"x": 1.23e+10}"#);
565        let lines = view.formatted_lines();
566        let has_sci = lines.iter().any(|line| {
567            line.iter()
568                .any(|t| matches!(t, JsonToken::Number(s) if s.contains("e+")))
569        });
570        assert!(has_sci, "scientific notation should be Number: {lines:?}");
571    }
572
573    #[test]
574    fn empty_string_key_and_value() {
575        let view = JsonView::new(r#"{"": ""}"#);
576        let lines = view.formatted_lines();
577        let has_empty_key = lines.iter().any(|line| {
578            line.iter()
579                .any(|t| matches!(t, JsonToken::Key(s) if s == "\"\""))
580        });
581        assert!(has_empty_key, "empty key should be present: {lines:?}");
582    }
583
584    #[test]
585    fn unicode_in_strings() {
586        let view = JsonView::new(r#"{"emoji": "🎉🚀"}"#);
587        let lines = view.formatted_lines();
588        let has_emoji = lines.iter().any(|line| {
589            line.iter()
590                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains('🎉')))
591        });
592        assert!(has_emoji);
593    }
594
595    #[test]
596    fn unclosed_string() {
597        // Missing closing quote — tokenizer reads until EOF
598        let view = JsonView::new(r#"{"key": "val"#);
599        let lines = view.formatted_lines();
600        // Should not panic; produces some output
601        assert!(!lines.is_empty());
602    }
603
604    #[test]
605    fn unclosed_object() {
606        let view = JsonView::new(r#"{"a": 1"#);
607        let lines = view.formatted_lines();
608        assert!(!lines.is_empty());
609    }
610
611    #[test]
612    fn unclosed_array() {
613        let view = JsonView::new(r#"[1, 2, 3"#);
614        let lines = view.formatted_lines();
615        assert!(!lines.is_empty());
616    }
617
618    #[test]
619    fn nested_empty_containers() {
620        let view = JsonView::new(r#"{"a": [], "b": {}}"#);
621        let lines = view.formatted_lines();
622        // [] and {} should appear compact
623        let flat = lines
624            .iter()
625            .map(|line| {
626                line.iter()
627                    .filter_map(|t| match t {
628                        JsonToken::Punctuation(s) => Some(s.as_str()),
629                        _ => None,
630                    })
631                    .collect::<String>()
632            })
633            .collect::<String>();
634        assert!(flat.contains("[]"), "empty array should be compact: {flat}");
635        assert!(
636            flat.contains("{}"),
637            "empty object should be compact: {flat}"
638        );
639    }
640
641    #[test]
642    fn array_of_mixed_types() {
643        let view = JsonView::new(r#"[1, "two", true, null]"#);
644        let lines = view.formatted_lines();
645        let all_tokens: Vec<&JsonToken> = lines.iter().flat_map(|l| l.iter()).collect();
646        assert!(all_tokens.iter().any(|t| matches!(t, JsonToken::Number(_))));
647        assert!(
648            all_tokens
649                .iter()
650                .any(|t| matches!(t, JsonToken::StringVal(_)))
651        );
652        assert!(
653            all_tokens
654                .iter()
655                .any(|t| matches!(t, JsonToken::Literal(s) if s == "true"))
656        );
657        assert!(
658            all_tokens
659                .iter()
660                .any(|t| matches!(t, JsonToken::Literal(s) if s == "null"))
661        );
662    }
663
664    #[test]
665    fn zero_indent_width() {
666        let view = JsonView::new(r#"{"a": 1}"#).with_indent(0);
667        let lines = view.formatted_lines();
668        // Indentation should be empty strings
669        for line in &lines {
670            for token in line {
671                if let JsonToken::Whitespace(s) = token {
672                    assert!(s.is_empty(), "zero indent should produce empty whitespace");
673                }
674            }
675        }
676    }
677
678    #[test]
679    fn bare_string_top_level() {
680        let view = JsonView::new(r#""hello""#);
681        let lines = view.formatted_lines();
682        assert_eq!(lines.len(), 1);
683        assert!(
684            lines[0]
685                .iter()
686                .any(|t| matches!(t, JsonToken::StringVal(s) if s.contains("hello")))
687        );
688    }
689
690    #[test]
691    fn error_token_for_invalid_literal() {
692        let view = JsonView::new(r#"{"a": undefined}"#);
693        let lines = view.formatted_lines();
694        let has_error = lines
695            .iter()
696            .any(|line| line.iter().any(|t| matches!(t, JsonToken::Error(_))));
697        assert!(has_error, "undefined should produce Error token");
698    }
699
700    #[test]
701    fn clone_independence() {
702        let view = JsonView::new(r#"{"a": 1}"#);
703        let cloned = view.clone();
704        assert_eq!(view.source(), cloned.source());
705    }
706
707    #[test]
708    fn debug_format() {
709        let view = JsonView::new("{}");
710        let dbg = format!("{view:?}");
711        assert!(dbg.contains("JsonView"));
712    }
713
714    #[test]
715    fn style_builders_chain() {
716        let view = JsonView::new("{}")
717            .with_indent(4)
718            .with_key_style(Style::new().bold())
719            .with_string_style(Style::default())
720            .with_number_style(Style::default())
721            .with_literal_style(Style::default())
722            .with_punct_style(Style::default())
723            .with_error_style(Style::default());
724        assert_eq!(view.indent, 4);
725    }
726
727    #[test]
728    fn render_width_one() {
729        let view = JsonView::new(r#"{"a": 1}"#);
730        let mut pool = GraphemePool::new();
731        let mut frame = Frame::new(1, 10, &mut pool);
732        view.render(Rect::new(0, 0, 1, 10), &mut frame);
733        // Should render first char of each line without panic
734        let cell = frame.buffer.get(0, 0).unwrap();
735        assert_eq!(cell.content.as_char(), Some('{'));
736    }
737
738    #[test]
739    fn render_no_styling_drops_token_styles() {
740        let key_color = PackedRgba::rgb(1, 2, 3);
741        let number_color = PackedRgba::rgb(4, 5, 6);
742        let view = JsonView::new(r#"{"key": 1}"#)
743            .with_key_style(Style::new().fg(key_color).bold())
744            .with_number_style(Style::new().fg(number_color).italic());
745        let mut pool = GraphemePool::new();
746        let mut frame = Frame::new(40, 10, &mut pool);
747        frame.buffer.degradation = DegradationLevel::NoStyling;
748        view.render(Rect::new(0, 0, 40, 10), &mut frame);
749
750        let key_cell = frame.buffer.get(2, 1).unwrap();
751        assert_eq!(key_cell.content.as_char(), Some('"'));
752        assert_ne!(key_cell.fg, key_color);
753        assert_eq!(key_cell.attrs, CellAttrs::NONE);
754
755        let number_cell = frame.buffer.get(9, 1).unwrap();
756        assert_eq!(number_cell.content.as_char(), Some('1'));
757        assert_ne!(number_cell.fg, number_color);
758        assert_eq!(number_cell.attrs, CellAttrs::NONE);
759    }
760
761    #[test]
762    fn render_skeleton_is_noop() {
763        let view = JsonView::new(r#"{"key": "value"}"#);
764        let mut pool = GraphemePool::new();
765        let mut frame = Frame::new(40, 10, &mut pool);
766        let area = Rect::new(0, 0, 40, 10);
767        view.render(area, &mut frame);
768        frame.buffer.degradation = DegradationLevel::Skeleton;
769        view.render(area, &mut frame);
770
771        for y in 0..10 {
772            for x in 0..40 {
773                assert_eq!(
774                    frame.buffer.get(x, y),
775                    Some(&ftui_render::cell::Cell::default())
776                );
777            }
778        }
779    }
780
781    #[test]
782    fn render_shorter_json_clears_stale_suffix_and_rows() {
783        let long = JsonView::new(r#"{"alpha": 1000, "beta": 2000}"#);
784        let short = JsonView::new(r#"{"a": 1}"#);
785        let mut pool = GraphemePool::new();
786        let mut frame = Frame::new(40, 10, &mut pool);
787        let area = Rect::new(0, 0, 40, 10);
788
789        long.render(area, &mut frame);
790        short.render(area, &mut frame);
791
792        for y in 0..10u16 {
793            for x in 0..40u16 {
794                if y >= 3 {
795                    assert_eq!(
796                        frame.buffer.get(x, y),
797                        Some(&ftui_render::cell::Cell::default())
798                    );
799                }
800            }
801        }
802    }
803
804    #[test]
805    fn json_token_eq() {
806        assert_eq!(JsonToken::Key("a".into()), JsonToken::Key("a".into()));
807        assert_ne!(JsonToken::Key("a".into()), JsonToken::StringVal("a".into()));
808        assert_ne!(JsonToken::Newline, JsonToken::Whitespace("".into()));
809    }
810
811    #[test]
812    fn json_token_clone_and_debug() {
813        let tokens = vec![
814            JsonToken::Key("k".into()),
815            JsonToken::StringVal("s".into()),
816            JsonToken::Number("1".into()),
817            JsonToken::Literal("true".into()),
818            JsonToken::Punctuation("{".into()),
819            JsonToken::Whitespace("  ".into()),
820            JsonToken::Newline,
821            JsonToken::Error("bad".into()),
822        ];
823        for tok in &tokens {
824            let cloned = tok.clone();
825            assert_eq!(tok, &cloned);
826            let _ = format!("{tok:?}");
827        }
828    }
829
830    #[test]
831    fn classify_literal_empty_string() {
832        // Empty literal should be Error (not a number or keyword)
833        let result = classify_literal("");
834        assert!(matches!(result, JsonToken::Error(s) if s.is_empty()));
835    }
836
837    #[test]
838    fn negative_number() {
839        assert_eq!(
840            classify_literal("-42"),
841            JsonToken::Number("-42".to_string())
842        );
843    }
844
845    #[test]
846    fn number_with_exponent() {
847        assert_eq!(
848            classify_literal("5E-3"),
849            JsonToken::Number("5E-3".to_string())
850        );
851    }
852
853    // ─── End edge-case tests (bd-2agoi) ──────────────────────────────
854
855    #[test]
856    fn classify_literal_types() {
857        assert_eq!(
858            classify_literal("true"),
859            JsonToken::Literal("true".to_string())
860        );
861        assert_eq!(
862            classify_literal("false"),
863            JsonToken::Literal("false".to_string())
864        );
865        assert_eq!(
866            classify_literal("null"),
867            JsonToken::Literal("null".to_string())
868        );
869        assert_eq!(classify_literal("42"), JsonToken::Number("42".to_string()));
870        assert_eq!(
871            classify_literal("-3.14"),
872            JsonToken::Number("-3.14".to_string())
873        );
874        assert!(matches!(classify_literal("invalid!"), JsonToken::Error(_)));
875    }
876}