Skip to main content

shape_runtime/renderers/
plain.rs

1//! Plain text renderer — renders ContentNode with no ANSI formatting.
2//!
3//! Produces clean text output suitable for logging, file output, or
4//! environments that don't support ANSI escape codes. Tables use ASCII
5//! box-drawing characters (+--+).
6
7use crate::content_renderer::{ContentRenderer, RendererCapabilities};
8use shape_value::content::{ChartSpec, ContentNode, ContentTable};
9use std::fmt::Write;
10
11/// Renders ContentNode trees to plain text with no ANSI codes.
12pub struct PlainRenderer;
13
14impl ContentRenderer for PlainRenderer {
15    fn capabilities(&self) -> RendererCapabilities {
16        RendererCapabilities::plain()
17    }
18
19    fn render(&self, content: &ContentNode) -> String {
20        render_node(content)
21    }
22}
23
24fn render_node(node: &ContentNode) -> String {
25    match node {
26        ContentNode::Text(st) => {
27            let mut out = String::new();
28            for span in &st.spans {
29                out.push_str(&span.text);
30            }
31            out
32        }
33        ContentNode::Table(table) => render_table(table),
34        ContentNode::Code { language, source } => render_code(language.as_deref(), source),
35        ContentNode::Chart(spec) => render_chart(spec),
36        ContentNode::KeyValue(pairs) => render_key_value(pairs),
37        ContentNode::Fragment(parts) => parts.iter().map(render_node).collect(),
38    }
39}
40
41fn render_table(table: &ContentTable) -> String {
42    let col_count = table.headers.len();
43    let mut widths: Vec<usize> = table.headers.iter().map(|h| h.len()).collect();
44
45    let limit = table.max_rows.unwrap_or(table.rows.len());
46    let display_rows = &table.rows[..limit.min(table.rows.len())];
47    let truncated = table.rows.len().saturating_sub(limit);
48
49    for row in display_rows {
50        for (i, cell) in row.iter().enumerate() {
51            if i < col_count {
52                let cell_text = cell.to_string();
53                if cell_text.len() > widths[i] {
54                    widths[i] = cell_text.len();
55                }
56            }
57        }
58    }
59
60    let mut out = String::new();
61
62    // Top border: +------+------+
63    write_ascii_border(&mut out, &widths);
64
65    // Header row: | Name | Age  |
66    let _ = write!(out, "|");
67    for (i, header) in table.headers.iter().enumerate() {
68        let _ = write!(out, " {:width$} |", header, width = widths[i]);
69    }
70    let _ = writeln!(out);
71
72    // Separator
73    write_ascii_border(&mut out, &widths);
74
75    // Data rows
76    for row in display_rows {
77        let _ = write!(out, "|");
78        for i in 0..col_count {
79            let cell_text = row.get(i).map(|c| c.to_string()).unwrap_or_default();
80            let _ = write!(out, " {:width$} |", cell_text, width = widths[i]);
81        }
82        let _ = writeln!(out);
83    }
84
85    // Truncation indicator
86    if truncated > 0 {
87        let _ = write!(out, "|");
88        let msg = format!("... {} more rows", truncated);
89        let total_width: usize = widths.iter().sum::<usize>() + (col_count - 1) * 3 + 2;
90        let _ = write!(out, " {:width$} |", msg, width = total_width);
91        let _ = writeln!(out);
92    }
93
94    // Bottom border
95    write_ascii_border(&mut out, &widths);
96
97    out
98}
99
100fn write_ascii_border(out: &mut String, widths: &[usize]) {
101    let _ = write!(out, "+");
102    for w in widths {
103        for _ in 0..(w + 2) {
104            out.push('-');
105        }
106        out.push('+');
107    }
108    let _ = writeln!(out);
109}
110
111fn render_code(language: Option<&str>, source: &str) -> String {
112    let mut out = String::new();
113    if let Some(lang) = language {
114        let _ = writeln!(out, "[{}]", lang);
115    }
116    for line in source.lines() {
117        let _ = writeln!(out, "    {}", line);
118    }
119    out
120}
121
122fn render_chart(spec: &ChartSpec) -> String {
123    let title = spec.title.as_deref().unwrap_or("untitled");
124    let type_name = chart_type_display_name(spec.chart_type);
125    let y_count = spec.channels_by_name("y").len();
126    format!("[{} Chart: {} ({} series)]\n", type_name, title, y_count)
127}
128
129fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
130    use shape_value::content::ChartType;
131    match ct {
132        ChartType::Line => "Line",
133        ChartType::Bar => "Bar",
134        ChartType::Scatter => "Scatter",
135        ChartType::Area => "Area",
136        ChartType::Candlestick => "Candlestick",
137        ChartType::Histogram => "Histogram",
138        ChartType::BoxPlot => "BoxPlot",
139        ChartType::Heatmap => "Heatmap",
140        ChartType::Bubble => "Bubble",
141    }
142}
143
144fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
145    if pairs.is_empty() {
146        return String::new();
147    }
148    let max_key_len = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
149    let mut out = String::new();
150    for (key, value) in pairs {
151        let value_str = render_node(value);
152        let _ = writeln!(out, "{:width$}  {}", key, value_str, width = max_key_len);
153    }
154    out
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
161
162    fn renderer() -> PlainRenderer {
163        PlainRenderer
164    }
165
166    #[test]
167    fn test_plain_text() {
168        let node = ContentNode::plain("hello world");
169        let output = renderer().render(&node);
170        assert_eq!(output, "hello world");
171    }
172
173    #[test]
174    fn test_styled_text_strips_styles() {
175        let node = ContentNode::plain("styled")
176            .with_bold()
177            .with_fg(Color::Named(NamedColor::Red));
178        let output = renderer().render(&node);
179        // Should NOT contain any ANSI codes
180        assert!(!output.contains("\x1b["));
181        assert_eq!(output, "styled");
182    }
183
184    #[test]
185    fn test_ascii_table() {
186        let table = ContentNode::Table(ContentTable {
187            headers: vec!["Name".into(), "Age".into()],
188            rows: vec![
189                vec![ContentNode::plain("Alice"), ContentNode::plain("30")],
190                vec![ContentNode::plain("Bob"), ContentNode::plain("25")],
191            ],
192            border: BorderStyle::Rounded, // Ignored — plain always uses ASCII
193            max_rows: None,
194            column_types: None,
195            total_rows: None,
196            sortable: false,
197        });
198        let output = renderer().render(&table);
199        assert!(output.contains("+-------+-----+"));
200        assert!(output.contains("| Alice | 30  |"));
201        assert!(output.contains("| Bob   | 25  |"));
202    }
203
204    #[test]
205    fn test_ascii_table_max_rows() {
206        let table = ContentNode::Table(ContentTable {
207            headers: vec!["X".into()],
208            rows: vec![
209                vec![ContentNode::plain("1")],
210                vec![ContentNode::plain("2")],
211                vec![ContentNode::plain("3")],
212            ],
213            border: BorderStyle::default(),
214            max_rows: Some(1),
215            column_types: None,
216            total_rows: None,
217            sortable: false,
218        });
219        let output = renderer().render(&table);
220        assert!(output.contains("| 1 |"));
221        assert!(output.contains("... 2 more rows"));
222        assert!(!output.contains("| 3 |"));
223    }
224
225    #[test]
226    fn test_code_block_with_language() {
227        let code = ContentNode::Code {
228            language: Some("python".into()),
229            source: "print(\"hi\")".into(),
230        };
231        let output = renderer().render(&code);
232        assert!(output.contains("[python]"));
233        assert!(output.contains("    print(\"hi\")"));
234        assert!(!output.contains("\x1b["));
235    }
236
237    #[test]
238    fn test_code_block_no_language() {
239        let code = ContentNode::Code {
240            language: None,
241            source: "hello".into(),
242        };
243        let output = renderer().render(&code);
244        assert!(!output.contains("["));
245        assert!(output.contains("    hello"));
246    }
247
248    #[test]
249    fn test_chart_placeholder() {
250        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
251            chart_type: shape_value::content::ChartType::Bar,
252            channels: vec![],
253            x_categories: None,
254            title: Some("Sales".into()),
255            x_label: None,
256            y_label: None,
257            width: None,
258            height: None,
259            echarts_options: None,
260            interactive: true,
261        });
262        let output = renderer().render(&chart);
263        assert_eq!(output, "[Bar Chart: Sales (0 series)]\n");
264    }
265
266    #[test]
267    fn test_key_value() {
268        let kv = ContentNode::KeyValue(vec![
269            ("name".into(), ContentNode::plain("Alice")),
270            ("age".into(), ContentNode::plain("30")),
271        ]);
272        let output = renderer().render(&kv);
273        assert!(output.contains("name"));
274        assert!(output.contains("Alice"));
275        assert!(output.contains("age"));
276        assert!(output.contains("30"));
277        assert!(!output.contains("\x1b["));
278    }
279
280    #[test]
281    fn test_fragment() {
282        let frag = ContentNode::Fragment(vec![
283            ContentNode::plain("hello "),
284            ContentNode::plain("world"),
285        ]);
286        let output = renderer().render(&frag);
287        assert_eq!(output, "hello world");
288    }
289
290    #[test]
291    fn test_no_ansi_in_any_output() {
292        // Comprehensive check: build a complex tree and ensure no ANSI escapes
293        let complex = ContentNode::Fragment(vec![
294            ContentNode::plain("text")
295                .with_bold()
296                .with_fg(Color::Named(NamedColor::Red)),
297            ContentNode::Table(ContentTable {
298                headers: vec!["H".into()],
299                rows: vec![vec![ContentNode::plain("v")]],
300                border: BorderStyle::default(),
301                max_rows: None,
302                column_types: None,
303                total_rows: None,
304                sortable: false,
305            }),
306            ContentNode::Code {
307                language: Some("js".into()),
308                source: "1+1".into(),
309            },
310            ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]),
311        ]);
312        let output = renderer().render(&complex);
313        assert!(
314            !output.contains("\x1b["),
315            "Plain renderer must not emit ANSI codes"
316        );
317    }
318}