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