Skip to main content

shape_runtime/renderers/
json.rs

1//! JSON renderer — renders ContentNode to a structured JSON tree.
2//!
3//! Produces a JSON representation that preserves the full structure
4//! of the ContentNode tree, including styles, colors, and metadata.
5
6use crate::content_renderer::{ContentRenderer, RendererCapabilities};
7use shape_value::content::{
8    BorderStyle, ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style,
9};
10use std::fmt::Write;
11
12/// Renders ContentNode trees to structured JSON.
13pub struct JsonRenderer;
14
15impl ContentRenderer for JsonRenderer {
16    fn capabilities(&self) -> RendererCapabilities {
17        RendererCapabilities {
18            ansi: false,
19            unicode: true,
20            color: true,
21            interactive: false,
22        }
23    }
24
25    fn render(&self, content: &ContentNode) -> String {
26        render_node(content)
27    }
28}
29
30fn render_node(node: &ContentNode) -> String {
31    match node {
32        ContentNode::Text(st) => {
33            let spans: Vec<String> = st
34                .spans
35                .iter()
36                .map(|span| {
37                    let style = render_style(&span.style);
38                    format!(
39                        "{{\"text\":{},\"style\":{}}}",
40                        json_string(&span.text),
41                        style
42                    )
43                })
44                .collect();
45            format!("{{\"type\":\"text\",\"spans\":[{}]}}", spans.join(","))
46        }
47        ContentNode::Table(table) => render_table(table),
48        ContentNode::Code { language, source } => {
49            let lang = language
50                .as_deref()
51                .map(|l| json_string(l))
52                .unwrap_or_else(|| "null".to_string());
53            format!(
54                "{{\"type\":\"code\",\"language\":{},\"source\":{}}}",
55                lang,
56                json_string(source)
57            )
58        }
59        ContentNode::Chart(spec) => render_chart(spec),
60        ContentNode::KeyValue(pairs) => {
61            let entries: Vec<String> = pairs
62                .iter()
63                .map(|(k, v)| {
64                    format!(
65                        "{{\"key\":{},\"value\":{}}}",
66                        json_string(k),
67                        render_node(v)
68                    )
69                })
70                .collect();
71            format!("{{\"type\":\"kv\",\"pairs\":[{}]}}", entries.join(","))
72        }
73        ContentNode::Fragment(parts) => {
74            let children: Vec<String> = parts.iter().map(render_node).collect();
75            format!(
76                "{{\"type\":\"fragment\",\"children\":[{}]}}",
77                children.join(",")
78            )
79        }
80    }
81}
82
83fn render_style(style: &Style) -> String {
84    let mut parts = Vec::new();
85    if style.bold {
86        parts.push("\"bold\":true".to_string());
87    }
88    if style.italic {
89        parts.push("\"italic\":true".to_string());
90    }
91    if style.underline {
92        parts.push("\"underline\":true".to_string());
93    }
94    if style.dim {
95        parts.push("\"dim\":true".to_string());
96    }
97    if let Some(ref color) = style.fg {
98        parts.push(format!("\"fg\":{}", render_color(color)));
99    }
100    if let Some(ref color) = style.bg {
101        parts.push(format!("\"bg\":{}", render_color(color)));
102    }
103    if parts.is_empty() {
104        "{}".to_string()
105    } else {
106        format!("{{{}}}", parts.join(","))
107    }
108}
109
110fn render_color(color: &Color) -> String {
111    match color {
112        Color::Named(named) => json_string(named_to_str(*named)),
113        Color::Rgb(r, g, b) => format!("{{\"r\":{},\"g\":{},\"b\":{}}}", r, g, b),
114    }
115}
116
117fn named_to_str(color: NamedColor) -> &'static str {
118    match color {
119        NamedColor::Red => "red",
120        NamedColor::Green => "green",
121        NamedColor::Blue => "blue",
122        NamedColor::Yellow => "yellow",
123        NamedColor::Magenta => "magenta",
124        NamedColor::Cyan => "cyan",
125        NamedColor::White => "white",
126        NamedColor::Default => "default",
127    }
128}
129
130fn render_table(table: &ContentTable) -> String {
131    let headers: Vec<String> = table.headers.iter().map(|h| json_string(h)).collect();
132
133    let limit = table.max_rows.unwrap_or(table.rows.len());
134    let display_rows = &table.rows[..limit.min(table.rows.len())];
135
136    let rows: Vec<String> = display_rows
137        .iter()
138        .map(|row| {
139            let cells: Vec<String> = row.iter().map(render_node).collect();
140            format!("[{}]", cells.join(","))
141        })
142        .collect();
143
144    let border = match table.border {
145        BorderStyle::Rounded => "\"rounded\"",
146        BorderStyle::Sharp => "\"sharp\"",
147        BorderStyle::Heavy => "\"heavy\"",
148        BorderStyle::Double => "\"double\"",
149        BorderStyle::Minimal => "\"minimal\"",
150        BorderStyle::None => "\"none\"",
151    };
152
153    let max_rows = table
154        .max_rows
155        .map(|n| n.to_string())
156        .unwrap_or_else(|| "null".to_string());
157
158    format!(
159        "{{\"type\":\"table\",\"headers\":[{}],\"rows\":[{}],\"border\":{},\"max_rows\":{},\"total_rows\":{}}}",
160        headers.join(","),
161        rows.join(","),
162        border,
163        max_rows,
164        table.rows.len()
165    )
166}
167
168fn render_chart(spec: &ChartSpec) -> String {
169    let chart_type = match spec.chart_type {
170        shape_value::content::ChartType::Line => "\"line\"",
171        shape_value::content::ChartType::Bar => "\"bar\"",
172        shape_value::content::ChartType::Scatter => "\"scatter\"",
173        shape_value::content::ChartType::Area => "\"area\"",
174        shape_value::content::ChartType::Candlestick => "\"candlestick\"",
175        shape_value::content::ChartType::Histogram => "\"histogram\"",
176    };
177
178    let title = spec
179        .title
180        .as_deref()
181        .map(|t| json_string(t))
182        .unwrap_or_else(|| "null".to_string());
183
184    let mut parts = vec![
185        format!("\"type\":\"chart\""),
186        format!("\"chart_type\":{}", chart_type),
187        format!("\"title\":{}", title),
188        format!("\"series_count\":{}", spec.series.len()),
189    ];
190
191    if let Some(ref xl) = spec.x_label {
192        parts.push(format!("\"x_label\":{}", json_string(xl)));
193    }
194    if let Some(ref yl) = spec.y_label {
195        parts.push(format!("\"y_label\":{}", json_string(yl)));
196    }
197
198    format!("{{{}}}", parts.join(","))
199}
200
201fn json_string(s: &str) -> String {
202    let mut out = String::with_capacity(s.len() + 2);
203    out.push('"');
204    for ch in s.chars() {
205        match ch {
206            '"' => out.push_str("\\\""),
207            '\\' => out.push_str("\\\\"),
208            '\n' => out.push_str("\\n"),
209            '\r' => out.push_str("\\r"),
210            '\t' => out.push_str("\\t"),
211            c if c < '\x20' => {
212                let _ = write!(out, "\\u{:04x}", c as u32);
213            }
214            c => out.push(c),
215        }
216    }
217    out.push('"');
218    out
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use shape_value::content::{ContentTable, NamedColor};
225
226    fn renderer() -> JsonRenderer {
227        JsonRenderer
228    }
229
230    #[test]
231    fn test_plain_text_json() {
232        let node = ContentNode::plain("hello");
233        let output = renderer().render(&node);
234        assert!(output.contains("\"type\":\"text\""));
235        assert!(output.contains("\"text\":\"hello\""));
236    }
237
238    #[test]
239    fn test_styled_text_json() {
240        let node = ContentNode::plain("bold")
241            .with_bold()
242            .with_fg(Color::Named(NamedColor::Red));
243        let output = renderer().render(&node);
244        assert!(output.contains("\"bold\":true"));
245        assert!(output.contains("\"fg\":\"red\""));
246    }
247
248    #[test]
249    fn test_rgb_color_json() {
250        let node = ContentNode::plain("rgb").with_fg(Color::Rgb(255, 0, 128));
251        let output = renderer().render(&node);
252        assert!(output.contains("\"r\":255"));
253        assert!(output.contains("\"g\":0"));
254        assert!(output.contains("\"b\":128"));
255    }
256
257    #[test]
258    fn test_table_json() {
259        let table = ContentNode::Table(ContentTable {
260            headers: vec!["A".into()],
261            rows: vec![vec![ContentNode::plain("1")]],
262            border: BorderStyle::Rounded,
263            max_rows: None,
264            column_types: None,
265            total_rows: None,
266            sortable: false,
267        });
268        let output = renderer().render(&table);
269        assert!(output.contains("\"type\":\"table\""));
270        assert!(output.contains("\"headers\":[\"A\"]"));
271        assert!(output.contains("\"border\":\"rounded\""));
272        assert!(output.contains("\"total_rows\":1"));
273    }
274
275    #[test]
276    fn test_code_json() {
277        let code = ContentNode::Code {
278            language: Some("rust".into()),
279            source: "fn main() {}".into(),
280        };
281        let output = renderer().render(&code);
282        assert!(output.contains("\"type\":\"code\""));
283        assert!(output.contains("\"language\":\"rust\""));
284        assert!(output.contains("\"source\":\"fn main() {}\""));
285    }
286
287    #[test]
288    fn test_code_no_language_json() {
289        let code = ContentNode::Code {
290            language: None,
291            source: "x".into(),
292        };
293        let output = renderer().render(&code);
294        assert!(output.contains("\"language\":null"));
295    }
296
297    #[test]
298    fn test_kv_json() {
299        let kv = ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]);
300        let output = renderer().render(&kv);
301        assert!(output.contains("\"type\":\"kv\""));
302        assert!(output.contains("\"key\":\"k\""));
303    }
304
305    #[test]
306    fn test_fragment_json() {
307        let frag = ContentNode::Fragment(vec![ContentNode::plain("a"), ContentNode::plain("b")]);
308        let output = renderer().render(&frag);
309        assert!(output.contains("\"type\":\"fragment\""));
310        assert!(output.contains("\"children\":["));
311    }
312
313    #[test]
314    fn test_chart_json() {
315        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
316            chart_type: shape_value::content::ChartType::Bar,
317            series: vec![],
318            title: Some("Sales".into()),
319            x_label: None,
320            y_label: None,
321            width: None,
322            height: None,
323            echarts_options: None,
324            interactive: true,
325        });
326        let output = renderer().render(&chart);
327        assert!(output.contains("\"chart_type\":\"bar\""));
328        assert!(output.contains("\"title\":\"Sales\""));
329    }
330
331    #[test]
332    fn test_json_string_escaping() {
333        let node = ContentNode::plain("he said \"hello\" \\ \n\t");
334        let output = renderer().render(&node);
335        assert!(output.contains("\\\"hello\\\""));
336        assert!(output.contains("\\\\"));
337        assert!(output.contains("\\n"));
338        assert!(output.contains("\\t"));
339    }
340}