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!(
127        "[{} Chart: {} ({} series)]\n",
128        type_name, title, y_count
129    )
130}
131
132fn chart_type_display_name(ct: shape_value::content::ChartType) -> &'static str {
133    use shape_value::content::ChartType;
134    match ct {
135        ChartType::Line => "Line",
136        ChartType::Bar => "Bar",
137        ChartType::Scatter => "Scatter",
138        ChartType::Area => "Area",
139        ChartType::Candlestick => "Candlestick",
140        ChartType::Histogram => "Histogram",
141        ChartType::BoxPlot => "BoxPlot",
142        ChartType::Heatmap => "Heatmap",
143        ChartType::Bubble => "Bubble",
144    }
145}
146
147fn render_key_value(pairs: &[(String, ContentNode)]) -> String {
148    if pairs.is_empty() {
149        return String::new();
150    }
151    let max_key_len = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
152    let mut out = String::new();
153    for (key, value) in pairs {
154        let value_str = render_node(value);
155        let _ = writeln!(out, "{:width$}  {}", key, value_str, width = max_key_len);
156    }
157    out
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use shape_value::content::{BorderStyle, Color, ContentTable, NamedColor};
164
165    fn renderer() -> PlainRenderer {
166        PlainRenderer
167    }
168
169    #[test]
170    fn test_plain_text() {
171        let node = ContentNode::plain("hello world");
172        let output = renderer().render(&node);
173        assert_eq!(output, "hello world");
174    }
175
176    #[test]
177    fn test_styled_text_strips_styles() {
178        let node = ContentNode::plain("styled")
179            .with_bold()
180            .with_fg(Color::Named(NamedColor::Red));
181        let output = renderer().render(&node);
182        // Should NOT contain any ANSI codes
183        assert!(!output.contains("\x1b["));
184        assert_eq!(output, "styled");
185    }
186
187    #[test]
188    fn test_ascii_table() {
189        let table = ContentNode::Table(ContentTable {
190            headers: vec!["Name".into(), "Age".into()],
191            rows: vec![
192                vec![ContentNode::plain("Alice"), ContentNode::plain("30")],
193                vec![ContentNode::plain("Bob"), ContentNode::plain("25")],
194            ],
195            border: BorderStyle::Rounded, // Ignored — plain always uses ASCII
196            max_rows: None,
197            column_types: None,
198            total_rows: None,
199            sortable: false,
200        });
201        let output = renderer().render(&table);
202        assert!(output.contains("+-------+-----+"));
203        assert!(output.contains("| Alice | 30  |"));
204        assert!(output.contains("| Bob   | 25  |"));
205    }
206
207    #[test]
208    fn test_ascii_table_max_rows() {
209        let table = ContentNode::Table(ContentTable {
210            headers: vec!["X".into()],
211            rows: vec![
212                vec![ContentNode::plain("1")],
213                vec![ContentNode::plain("2")],
214                vec![ContentNode::plain("3")],
215            ],
216            border: BorderStyle::default(),
217            max_rows: Some(1),
218            column_types: None,
219            total_rows: None,
220            sortable: false,
221        });
222        let output = renderer().render(&table);
223        assert!(output.contains("| 1 |"));
224        assert!(output.contains("... 2 more rows"));
225        assert!(!output.contains("| 3 |"));
226    }
227
228    #[test]
229    fn test_code_block_with_language() {
230        let code = ContentNode::Code {
231            language: Some("python".into()),
232            source: "print(\"hi\")".into(),
233        };
234        let output = renderer().render(&code);
235        assert!(output.contains("[python]"));
236        assert!(output.contains("    print(\"hi\")"));
237        assert!(!output.contains("\x1b["));
238    }
239
240    #[test]
241    fn test_code_block_no_language() {
242        let code = ContentNode::Code {
243            language: None,
244            source: "hello".into(),
245        };
246        let output = renderer().render(&code);
247        assert!(!output.contains("["));
248        assert!(output.contains("    hello"));
249    }
250
251    #[test]
252    fn test_chart_placeholder() {
253        let chart = ContentNode::Chart(shape_value::content::ChartSpec {
254            chart_type: shape_value::content::ChartType::Bar,
255            channels: vec![],
256            x_categories: None,
257            title: Some("Sales".into()),
258            x_label: None,
259            y_label: None,
260            width: None,
261            height: None,
262            echarts_options: None,
263            interactive: true,
264        });
265        let output = renderer().render(&chart);
266        assert_eq!(output, "[Bar Chart: Sales (0 series)]\n");
267    }
268
269    #[test]
270    fn test_key_value() {
271        let kv = ContentNode::KeyValue(vec![
272            ("name".into(), ContentNode::plain("Alice")),
273            ("age".into(), ContentNode::plain("30")),
274        ]);
275        let output = renderer().render(&kv);
276        assert!(output.contains("name"));
277        assert!(output.contains("Alice"));
278        assert!(output.contains("age"));
279        assert!(output.contains("30"));
280        assert!(!output.contains("\x1b["));
281    }
282
283    #[test]
284    fn test_fragment() {
285        let frag = ContentNode::Fragment(vec![
286            ContentNode::plain("hello "),
287            ContentNode::plain("world"),
288        ]);
289        let output = renderer().render(&frag);
290        assert_eq!(output, "hello world");
291    }
292
293    #[test]
294    fn test_no_ansi_in_any_output() {
295        // Comprehensive check: build a complex tree and ensure no ANSI escapes
296        let complex = ContentNode::Fragment(vec![
297            ContentNode::plain("text")
298                .with_bold()
299                .with_fg(Color::Named(NamedColor::Red)),
300            ContentNode::Table(ContentTable {
301                headers: vec!["H".into()],
302                rows: vec![vec![ContentNode::plain("v")]],
303                border: BorderStyle::default(),
304                max_rows: None,
305                column_types: None,
306                total_rows: None,
307                sortable: false,
308            }),
309            ContentNode::Code {
310                language: Some("js".into()),
311                source: "1+1".into(),
312            },
313            ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]),
314        ]);
315        let output = renderer().render(&complex);
316        assert!(
317            !output.contains("\x1b["),
318            "Plain renderer must not emit ANSI codes"
319        );
320    }
321}