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 = chart_type_json_str(spec.chart_type);
170
171    let title = spec
172        .title
173        .as_deref()
174        .map(|t| json_string(t))
175        .unwrap_or_else(|| "null".to_string());
176
177    let y_count = spec.channels_by_name("y").len();
178
179    let mut parts = vec![
180        "\"type\":\"chart\"".to_string(),
181        format!("\"chart_type\":{}", chart_type),
182        format!("\"title\":{}", title),
183        format!("\"channel_count\":{}", spec.channels.len()),
184        format!("\"series_count\":{}", y_count),
185    ];
186
187    if let Some(ref xl) = spec.x_label {
188        parts.push(format!("\"x_label\":{}", json_string(xl)));
189    }
190    if let Some(ref yl) = spec.y_label {
191        parts.push(format!("\"y_label\":{}", json_string(yl)));
192    }
193
194    format!("{{{}}}", parts.join(","))
195}
196
197fn chart_type_json_str(ct: shape_value::content::ChartType) -> &'static str {
198    use shape_value::content::ChartType;
199    match ct {
200        ChartType::Line => "\"line\"",
201        ChartType::Bar => "\"bar\"",
202        ChartType::Scatter => "\"scatter\"",
203        ChartType::Area => "\"area\"",
204        ChartType::Candlestick => "\"candlestick\"",
205        ChartType::Histogram => "\"histogram\"",
206        ChartType::BoxPlot => "\"boxplot\"",
207        ChartType::Heatmap => "\"heatmap\"",
208        ChartType::Bubble => "\"bubble\"",
209    }
210}
211
212fn json_string(s: &str) -> String {
213    let mut out = String::with_capacity(s.len() + 2);
214    out.push('"');
215    for ch in s.chars() {
216        match ch {
217            '"' => out.push_str("\\\""),
218            '\\' => out.push_str("\\\\"),
219            '\n' => out.push_str("\\n"),
220            '\r' => out.push_str("\\r"),
221            '\t' => out.push_str("\\t"),
222            c if c < '\x20' => {
223                let _ = write!(out, "\\u{:04x}", c as u32);
224            }
225            c => out.push(c),
226        }
227    }
228    out.push('"');
229    out
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use shape_value::content::{ContentTable, NamedColor};
236
237    fn renderer() -> JsonRenderer {
238        JsonRenderer
239    }
240
241    #[test]
242    fn test_plain_text_json() {
243        let node = ContentNode::plain("hello");
244        let output = renderer().render(&node);
245        assert!(output.contains("\"type\":\"text\""));
246        assert!(output.contains("\"text\":\"hello\""));
247    }
248
249    #[test]
250    fn test_styled_text_json() {
251        let node = ContentNode::plain("bold")
252            .with_bold()
253            .with_fg(Color::Named(NamedColor::Red));
254        let output = renderer().render(&node);
255        assert!(output.contains("\"bold\":true"));
256        assert!(output.contains("\"fg\":\"red\""));
257    }
258
259    #[test]
260    fn test_rgb_color_json() {
261        let node = ContentNode::plain("rgb").with_fg(Color::Rgb(255, 0, 128));
262        let output = renderer().render(&node);
263        assert!(output.contains("\"r\":255"));
264        assert!(output.contains("\"g\":0"));
265        assert!(output.contains("\"b\":128"));
266    }
267
268    #[test]
269    fn test_table_json() {
270        let table = ContentNode::Table(ContentTable {
271            headers: vec!["A".into()],
272            rows: vec![vec![ContentNode::plain("1")]],
273            border: BorderStyle::Rounded,
274            max_rows: None,
275            column_types: None,
276            total_rows: None,
277            sortable: false,
278        });
279        let output = renderer().render(&table);
280        assert!(output.contains("\"type\":\"table\""));
281        assert!(output.contains("\"headers\":[\"A\"]"));
282        assert!(output.contains("\"border\":\"rounded\""));
283        assert!(output.contains("\"total_rows\":1"));
284    }
285
286    #[test]
287    fn test_code_json() {
288        let code = ContentNode::Code {
289            language: Some("rust".into()),
290            source: "fn main() {}".into(),
291        };
292        let output = renderer().render(&code);
293        assert!(output.contains("\"type\":\"code\""));
294        assert!(output.contains("\"language\":\"rust\""));
295        assert!(output.contains("\"source\":\"fn main() {}\""));
296    }
297
298    #[test]
299    fn test_code_no_language_json() {
300        let code = ContentNode::Code {
301            language: None,
302            source: "x".into(),
303        };
304        let output = renderer().render(&code);
305        assert!(output.contains("\"language\":null"));
306    }
307
308    #[test]
309    fn test_kv_json() {
310        let kv = ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]);
311        let output = renderer().render(&kv);
312        assert!(output.contains("\"type\":\"kv\""));
313        assert!(output.contains("\"key\":\"k\""));
314    }
315
316    #[test]
317    fn test_fragment_json() {
318        let frag = ContentNode::Fragment(vec![ContentNode::plain("a"), ContentNode::plain("b")]);
319        let output = renderer().render(&frag);
320        assert!(output.contains("\"type\":\"fragment\""));
321        assert!(output.contains("\"children\":["));
322    }
323
324    #[test]
325    fn test_chart_json() {
326        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
327            chart_type: shape_value::content::ChartType::Bar,
328            channels: vec![],
329            x_categories: None,
330            title: Some("Sales".into()),
331            x_label: None,
332            y_label: None,
333            width: None,
334            height: None,
335            echarts_options: None,
336            interactive: true,
337        });
338        let output = renderer().render(&chart);
339        assert!(output.contains("\"chart_type\":\"bar\""));
340        assert!(output.contains("\"title\":\"Sales\""));
341    }
342
343    #[test]
344    fn test_json_string_escaping() {
345        let node = ContentNode::plain("he said \"hello\" \\ \n\t");
346        let output = renderer().render(&node);
347        assert!(output.contains("\\\"hello\\\""));
348        assert!(output.contains("\\\\"));
349        assert!(output.contains("\\n"));
350        assert!(output.contains("\\t"));
351    }
352}