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