Skip to main content

ctl_core/
render.rs

1//! Render semantic documents without exposing the terminal engine.
2
3use std::fmt::Write as _;
4
5use comfy_table::presets::{NOTHING, UTF8_FULL_CONDENSED};
6use comfy_table::{Cell, ContentArrangement, Table as EngineTable};
7use unicode_width::UnicodeWidthStr;
8
9use crate::color::ColorMode;
10use crate::document::{Block, Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
11use crate::style::{ERROR, HEADING, MUTED, OPTION, SUCCESS, VALUE, WARNING, styled};
12
13/// Deterministic document rendering options.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct RenderOptions {
16    color: ColorMode,
17    width: Option<u16>,
18}
19
20impl RenderOptions {
21    /// Build options with automatic terminal width.
22    #[must_use]
23    pub const fn new(color: ColorMode) -> Self {
24        Self { color, width: None }
25    }
26
27    /// Force an explicit width. Tests and redirected renderers should use this.
28    #[must_use]
29    pub const fn width(mut self, width: u16) -> Self {
30        self.width = Some(width);
31        self
32    }
33
34    /// Color policy.
35    #[must_use]
36    pub const fn color(self) -> ColorMode {
37        self.color
38    }
39
40    /// Explicit width, when set.
41    #[must_use]
42    pub const fn explicit_width(self) -> Option<u16> {
43        self.width
44    }
45}
46
47/// Semantic document renderer.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct Renderer {
50    options: RenderOptions,
51}
52
53impl Renderer {
54    /// Build a renderer.
55    #[must_use]
56    pub const fn new(options: RenderOptions) -> Self {
57        Self { options }
58    }
59
60    /// Render one document to a newline-terminated string.
61    #[must_use]
62    pub fn render(self, document: &Document) -> String {
63        let mut rendered = document
64            .blocks()
65            .iter()
66            .filter_map(|block| {
67                let rendered = self.render_block(block);
68                (!rendered.is_empty()).then_some(rendered)
69            })
70            .collect::<Vec<_>>()
71            .join("\n\n");
72        if !rendered.is_empty() {
73            rendered.push('\n');
74        }
75        rendered
76    }
77
78    fn render_block(self, block: &Block) -> String {
79        match block {
80            Block::Heading(text) => self.text_with_default(text, Role::Heading),
81            Block::Paragraph(text) => self.wrap(&self.text(text), 0),
82            Block::Fields(fields) => self.fields(fields),
83            Block::Table(table) => self.table(table),
84            Block::Section(section) => self.section(section),
85            Block::Notice(notice) => self.notice(notice),
86            Block::Rule(rule) => {
87                let width = usize::from(self.width().unwrap_or(40));
88                rule.title().map_or_else(
89                    || "─".repeat(width),
90                    |title| {
91                        let title_width = title
92                            .spans()
93                            .iter()
94                            .map(|span| UnicodeWidthStr::width(span.value()))
95                            .sum::<usize>();
96                        let title = self.text_with_default(title, Role::Heading);
97                        format!(
98                            "── {title} {}",
99                            "─".repeat(width.saturating_sub(title_width + 4))
100                        )
101                    },
102                )
103            }
104        }
105    }
106
107    fn section(self, section: &Section) -> String {
108        let heading = self.text_with_default(section.title(), Role::Heading);
109        let body = self.render(section.body());
110        if body.is_empty() {
111            heading
112        } else {
113            format!("{heading}\n{}", body.trim_end())
114        }
115    }
116
117    fn fields(self, fields: &Fields) -> String {
118        let mut table = self.engine_table();
119        for (label, value) in fields.rows() {
120            table.add_row([self.text(label), self.text(value)]);
121        }
122        format!("{table}")
123    }
124
125    fn table(self, table: &Table) -> String {
126        if self.should_stack(table) {
127            return self.stacked(table);
128        }
129        let mut engine = self.engine_table();
130        if !table.headers().is_empty() {
131            engine.set_header(
132                table
133                    .headers()
134                    .iter()
135                    .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
136            );
137        }
138        for row in table.rows() {
139            engine.add_row(row.iter().enumerate().map(|(index, cell)| {
140                let value = if table.token_column_index() == Some(index) {
141                    self.text_with_default(cell, Role::Token)
142                } else {
143                    self.text(cell)
144                };
145                Cell::new(value)
146            }));
147        }
148        format!("{engine}")
149    }
150
151    fn should_stack(self, table: &Table) -> bool {
152        let Some(stacked) = table.stacked() else {
153            return false;
154        };
155        self.width().is_some_and(|width| width < stacked.width())
156    }
157
158    fn stacked(self, table: &Table) -> String {
159        let Some(policy) = table.stacked() else {
160            return String::new();
161        };
162        let mut output = String::new();
163        for row in table.rows() {
164            let labels = row
165                .iter()
166                .take(policy.label_columns())
167                .filter(|value| !value.is_empty())
168                .map(|value| self.text_with_default(value, Role::Token))
169                .collect::<Vec<_>>()
170                .join(" ");
171            let description = row
172                .iter()
173                .skip(policy.label_columns())
174                .filter(|value| !value.is_empty())
175                .map(|value| self.text(value))
176                .collect::<Vec<_>>()
177                .join(" ");
178            for line in self.wrap(&labels, 2).lines() {
179                let _ = writeln!(output, "  {line}");
180            }
181            if !description.is_empty() {
182                let wrapped = self.wrap(&description, 4);
183                for line in wrapped.lines() {
184                    let _ = writeln!(output, "    {line}");
185                }
186            }
187        }
188        output.trim_end().to_owned()
189    }
190
191    fn notice(self, notice: &Notice) -> String {
192        let (label, role) = match notice.level() {
193            NoticeLevel::Success => ("success", Role::Success),
194            NoticeLevel::Warning => ("warning", Role::Warning),
195            NoticeLevel::Error => ("error", Role::Error),
196        };
197        let mut line = self.paint(role, label);
198        if let Some(code) = notice.code_value() {
199            let _ = write!(line, " · {}", self.paint(Role::Muted, code));
200        }
201        let _ = write!(line, " · {}", self.text(notice.message()));
202        self.wrap(&line, 0)
203    }
204
205    fn engine_table(self) -> EngineTable {
206        let mut table = EngineTable::new();
207        table
208            .load_style(UTF8_FULL_CONDENSED)
209            .set_content_arrangement(ContentArrangement::Dynamic);
210        if let Some(width) = self.width() {
211            table.set_width(width);
212        }
213        table
214    }
215
216    fn wrap(self, value: &str, indentation: u16) -> String {
217        let Some(width) = self.width() else {
218            return value.to_owned();
219        };
220        let mut table = EngineTable::new();
221        table
222            .load_style(NOTHING)
223            .set_content_arrangement(ContentArrangement::Dynamic)
224            .set_width(width.saturating_sub(indentation))
225            .add_row([value]);
226        if let Some(column) = table.column_mut(0) {
227            column.set_padding((0, 0));
228        }
229        table
230            .to_string()
231            .lines()
232            .map(str::trim_end)
233            .collect::<Vec<_>>()
234            .join("\n")
235    }
236
237    fn width(self) -> Option<u16> {
238        self.options
239            .explicit_width()
240            .or_else(crate::layout::terminal_width)
241    }
242
243    fn text(self, text: &Text) -> String {
244        text.spans()
245            .iter()
246            .map(|span| self.paint(span.role(), span.value()))
247            .collect()
248    }
249
250    fn text_with_default(self, text: &Text, default: Role) -> String {
251        text.spans()
252            .iter()
253            .map(|span| {
254                let role = if span.role() == Role::Plain {
255                    default
256                } else {
257                    span.role()
258                };
259                self.paint(role, span.value())
260            })
261            .collect()
262    }
263
264    fn paint(self, role: Role, value: &str) -> String {
265        if self.options.color() == ColorMode::Never || role == Role::Plain {
266            return value.to_owned();
267        }
268        let style = match role {
269            Role::Plain => return value.to_owned(),
270            Role::Heading => HEADING,
271            Role::Success => SUCCESS,
272            Role::Warning => WARNING,
273            Role::Error => ERROR,
274            Role::Value => VALUE,
275            Role::Muted => MUTED,
276            Role::Token => OPTION,
277        };
278        styled(style, value)
279    }
280}
281
282impl Document {
283    /// Render with explicit semantic options.
284    #[must_use]
285    pub fn render(&self, options: RenderOptions) -> String {
286        Renderer::new(options).render(self)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use indoc::{formatdoc, indoc};
293
294    use super::RenderOptions;
295    use crate::color::ColorMode;
296    use crate::document::{Document, Fields, Notice, NoticeLevel, Table, Text};
297
298    #[test]
299    fn colorless_document_is_deterministic() {
300        let document = Document::new()
301            .heading("status")
302            .fields(Fields::new().row("pending", Text::plain("2")))
303            .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
304        let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
305        let expected = indoc! {"
306            status
307
308            ┌─────────┬───┐
309            │ pending ┆ 2 │
310            └─────────┴───┘
311
312            warning · one stale row
313        "};
314        assert_eq!(rendered, expected);
315        assert!(!rendered.contains('\u{1b}'));
316    }
317
318    #[test]
319    fn colored_document_has_ansi() {
320        let document = Document::new().heading("status");
321        let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
322        assert!(rendered.contains('\u{1b}'));
323    }
324
325    #[test]
326    fn narrow_table_stacks() {
327        let table =
328            Table::plain()
329                .stacked_below(64, 2)
330                .row(["-f", "--format", "Output representation"]);
331        let rendered = Document::new()
332            .table(table)
333            .render(RenderOptions::new(ColorMode::Never).width(40));
334        assert_eq!(rendered, "  -f --format\n    Output representation\n");
335    }
336
337    #[test]
338    fn wrapped_stacked_labels_keep_indentation() {
339        let table = Table::plain()
340            .stacked_below(64, 1)
341            .row(["one two three four five", "description"]);
342        let rendered = Document::new()
343            .table(table)
344            .render(RenderOptions::new(ColorMode::Never).width(14));
345        let expected = formatdoc! {"
346            {label}one two
347            {label}three four
348            {label}five
349            {description}descriptio
350            {description}n
351            ",
352            label = "  ",
353            description = "    ",
354        };
355        assert_eq!(rendered, expected);
356    }
357}