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/// Default columns reserved from an automatically detected terminal width.
16pub const DEFAULT_COLUMN_BUFFER: u16 = 1;
17/// Default environment variable for overriding the automatic-width buffer.
18pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
19/// Default ordered environment lookup for the automatic-width buffer.
20pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
21/// Default floor for an automatically detected effective width.
22pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
23
24/// Deterministic document rendering options.
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct RenderOptions {
27    color: ColorMode,
28    width: Option<u16>,
29    automatic_width_buffer: Option<u16>,
30    automatic_width_buffer_envs: &'static [&'static str],
31    minimum_automatic_width: u16,
32}
33
34impl RenderOptions {
35    /// Build options with automatic terminal width, a one-column buffer, and
36    /// [`DEFAULT_COLUMN_BUFFER_ENV`] as the operator override.
37    #[must_use]
38    pub const fn new(color: ColorMode) -> Self {
39        Self {
40            color,
41            width: None,
42            automatic_width_buffer: None,
43            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
44            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
45        }
46    }
47
48    /// Force an explicit width. Tests and redirected renderers should use this.
49    #[must_use]
50    pub const fn width(mut self, width: u16) -> Self {
51        self.width = Some(width);
52        self
53    }
54
55    /// Override the automatic-width buffer. Zero explicitly disables it.
56    /// Explicit [`Self::width`] remains exact regardless of this value.
57    #[must_use]
58    pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
59        self.automatic_width_buffer = Some(columns);
60        self
61    }
62
63    /// Replace the ordered environment names used to configure the buffer.
64    /// Pass aliases in precedence order or an empty slice to disable lookup.
65    #[must_use]
66    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
67        self.automatic_width_buffer_envs = names;
68        self
69    }
70
71    /// Set the floor for automatically detected effective widths.
72    #[must_use]
73    pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
74        self.minimum_automatic_width = columns;
75        self
76    }
77
78    /// Color policy.
79    #[must_use]
80    pub const fn color(self) -> ColorMode {
81        self.color
82    }
83
84    /// Explicit width, when set.
85    #[must_use]
86    pub const fn explicit_width(self) -> Option<u16> {
87        self.width
88    }
89
90    /// Explicit automatic-width buffer, when the library owner set one.
91    #[must_use]
92    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
93        self.automatic_width_buffer
94    }
95
96    /// Ordered environment names used for the automatic-width buffer.
97    #[must_use]
98    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
99        self.automatic_width_buffer_envs
100    }
101
102    /// Floor applied only to automatically detected widths.
103    #[must_use]
104    pub const fn automatic_width_minimum(self) -> u16 {
105        self.minimum_automatic_width
106    }
107
108    fn resolved_automatic_width_buffer(self) -> u16 {
109        self.automatic_width_buffer
110            .or_else(|| crate::layout::column_buffer(self.automatic_width_buffer_envs))
111            .unwrap_or(DEFAULT_COLUMN_BUFFER)
112    }
113}
114
115/// Semantic document renderer.
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub struct Renderer {
118    options: RenderOptions,
119}
120
121impl Renderer {
122    /// Build a renderer.
123    #[must_use]
124    pub const fn new(options: RenderOptions) -> Self {
125        Self { options }
126    }
127
128    /// Render one document to a newline-terminated string.
129    #[must_use]
130    pub fn render(self, document: &Document) -> String {
131        let mut rendered = document
132            .blocks()
133            .iter()
134            .filter_map(|block| {
135                let rendered = self.render_block(block);
136                (!rendered.is_empty()).then_some(rendered)
137            })
138            .collect::<Vec<_>>()
139            .join("\n\n");
140        if !rendered.is_empty() {
141            rendered.push('\n');
142        }
143        rendered
144    }
145
146    fn render_block(self, block: &Block) -> String {
147        match block {
148            Block::Heading(text) => self.text_with_default(text, Role::Heading),
149            Block::Paragraph(text) => self.wrap(&self.text(text), 0),
150            Block::Verbatim(value) => sanitize_verbatim(value),
151            Block::Fields(fields) => self.fields(fields),
152            Block::Table(table) => self.table(table),
153            Block::Section(section) => self.section(section),
154            Block::Notice(notice) => self.notice(notice),
155            Block::Rule(rule) => {
156                let width = usize::from(self.width().unwrap_or(40));
157                rule.title().map_or_else(
158                    || "─".repeat(width),
159                    |title| {
160                        let title_width = title
161                            .spans()
162                            .iter()
163                            .map(|span| UnicodeWidthStr::width(span.value()))
164                            .sum::<usize>();
165                        let title = self.text_with_default(title, Role::Heading);
166                        format!(
167                            "── {title} {}",
168                            "─".repeat(width.saturating_sub(title_width + 4))
169                        )
170                    },
171                )
172            }
173        }
174    }
175
176    fn section(self, section: &Section) -> String {
177        let heading = self.text_with_default(section.title(), Role::Heading);
178        let body = self.render(section.body());
179        if body.is_empty() {
180            heading
181        } else {
182            format!("{heading}\n{}", body.trim_end())
183        }
184    }
185
186    fn fields(self, fields: &Fields) -> String {
187        let mut table = self.engine_table();
188        for (label, value) in fields.rows() {
189            table.add_row([self.text(label), self.text(value)]);
190        }
191        format!("{table}")
192    }
193
194    fn table(self, table: &Table) -> String {
195        if self.should_stack(table) {
196            return self.stacked(table);
197        }
198        let mut engine = self.engine_table();
199        if !table.headers().is_empty() {
200            engine.set_header(
201                table
202                    .headers()
203                    .iter()
204                    .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
205            );
206        }
207        for row in table.rows() {
208            engine.add_row(row.iter().enumerate().map(|(index, cell)| {
209                let value = if table.token_column_index() == Some(index) {
210                    self.text_with_default(cell, Role::Token)
211                } else {
212                    self.text(cell)
213                };
214                Cell::new(value)
215            }));
216        }
217        format!("{engine}")
218    }
219
220    fn should_stack(self, table: &Table) -> bool {
221        let Some(stacked) = table.stacked() else {
222            return false;
223        };
224        self.width().is_some_and(|width| width < stacked.width())
225    }
226
227    fn stacked(self, table: &Table) -> String {
228        let Some(policy) = table.stacked() else {
229            return String::new();
230        };
231        let mut output = String::new();
232        for row in table.rows() {
233            let labels = row
234                .iter()
235                .take(policy.label_columns())
236                .filter(|value| !value.is_empty())
237                .map(|value| self.text_with_default(value, Role::Token))
238                .collect::<Vec<_>>()
239                .join(" ");
240            let description = row
241                .iter()
242                .skip(policy.label_columns())
243                .filter(|value| !value.is_empty())
244                .map(|value| self.text(value))
245                .collect::<Vec<_>>()
246                .join(" ");
247            for line in self.wrap(&labels, 2).lines() {
248                let _ = writeln!(output, "  {line}");
249            }
250            if !description.is_empty() {
251                let wrapped = self.wrap(&description, 4);
252                for line in wrapped.lines() {
253                    let _ = writeln!(output, "    {line}");
254                }
255            }
256        }
257        output.trim_end().to_owned()
258    }
259
260    fn notice(self, notice: &Notice) -> String {
261        let (label, role) = match notice.level() {
262            NoticeLevel::Success => ("success", Role::Success),
263            NoticeLevel::Warning => ("warning", Role::Warning),
264            NoticeLevel::Error => ("error", Role::Error),
265        };
266        let mut line = self.paint(role, label);
267        if let Some(code) = notice.code_value() {
268            let _ = write!(line, " · {}", self.paint(Role::Muted, code));
269        }
270        let _ = write!(line, " · {}", self.text(notice.message()));
271        self.wrap(&line, 0)
272    }
273
274    fn engine_table(self) -> EngineTable {
275        let mut table = EngineTable::new();
276        table
277            .load_style(UTF8_FULL_CONDENSED)
278            .set_content_arrangement(ContentArrangement::Dynamic);
279        if let Some(width) = self.width() {
280            table.set_width(width);
281        }
282        table
283    }
284
285    fn wrap(self, value: &str, indentation: u16) -> String {
286        let Some(width) = self.width() else {
287            return value.to_owned();
288        };
289        let mut table = EngineTable::new();
290        table
291            .load_style(NOTHING)
292            .set_content_arrangement(ContentArrangement::Dynamic)
293            .set_width(width.saturating_sub(indentation))
294            .add_row([value]);
295        if let Some(column) = table.column_mut(0) {
296            column.set_padding((0, 0));
297        }
298        table
299            .to_string()
300            .lines()
301            .map(str::trim_end)
302            .collect::<Vec<_>>()
303            .join("\n")
304    }
305
306    fn width(self) -> Option<u16> {
307        self.options.explicit_width().or_else(|| {
308            crate::layout::terminal_width(
309                self.options.resolved_automatic_width_buffer(),
310                self.options.automatic_width_minimum(),
311            )
312        })
313    }
314
315    fn text(self, text: &Text) -> String {
316        text.spans()
317            .iter()
318            .map(|span| self.paint(span.role(), span.value()))
319            .collect()
320    }
321
322    fn text_with_default(self, text: &Text, default: Role) -> String {
323        text.spans()
324            .iter()
325            .map(|span| {
326                let role = if span.role() == Role::Plain {
327                    default
328                } else {
329                    span.role()
330                };
331                self.paint(role, span.value())
332            })
333            .collect()
334    }
335
336    fn paint(self, role: Role, value: &str) -> String {
337        if self.options.color() == ColorMode::Never || role == Role::Plain {
338            return value.to_owned();
339        }
340        let style = match role {
341            Role::Plain => return value.to_owned(),
342            Role::Heading => HEADING,
343            Role::Success => SUCCESS,
344            Role::Warning => WARNING,
345            Role::Error => ERROR,
346            Role::Value => VALUE,
347            Role::Muted => MUTED,
348            Role::Token => OPTION,
349        };
350        styled(style, value)
351    }
352}
353
354impl Document {
355    /// Render with explicit semantic options.
356    #[must_use]
357    pub fn render(&self, options: RenderOptions) -> String {
358        Renderer::new(options).render(self)
359    }
360}
361
362fn sanitize_verbatim(value: &str) -> String {
363    value
364        .chars()
365        .filter(|character| {
366            *character == '\n' || *character == '\t' || !is_unsafe_verbatim(*character)
367        })
368        .collect::<String>()
369        .trim_end_matches('\n')
370        .to_owned()
371}
372
373fn is_unsafe_verbatim(character: char) -> bool {
374    character.is_control()
375        || matches!(
376            character,
377            ALM | FSI | LRE | LRI | LRM | LRO | PDF | PDI | RLE | RLI | RLM | RLO
378        )
379        || matches!(
380            get_general_category(character),
381            GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator
382        )
383}
384
385#[cfg(test)]
386mod tests {
387    use indoc::{formatdoc, indoc};
388
389    use super::RenderOptions;
390    use crate::color::ColorMode;
391    use crate::document::{Document, Fields, Notice, NoticeLevel, Table, Text};
392
393    #[test]
394    fn automatic_width_policy_is_explicit_and_overridable() {
395        let defaults = RenderOptions::new(ColorMode::Never);
396        assert_eq!(defaults.explicit_automatic_width_buffer(), None);
397        assert_eq!(
398            defaults.automatic_width_minimum(),
399            super::DEFAULT_MINIMUM_AUTOMATIC_WIDTH
400        );
401
402        let configured = defaults
403            .automatic_width_buffer(0)
404            .automatic_width_buffer_envs(&["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"])
405            .minimum_automatic_width(8);
406        assert_eq!(configured.explicit_automatic_width_buffer(), Some(0));
407        assert_eq!(
408            configured.automatic_width_buffer_env_names(),
409            ["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"]
410        );
411        assert_eq!(configured.automatic_width_minimum(), 8);
412    }
413
414    #[test]
415    fn explicit_width_ignores_the_automatic_buffer() {
416        let document = Document::new().paragraph("one two three four five six");
417        let exact = document.render(RenderOptions::new(ColorMode::Never).width(16));
418        let buffered = document.render(
419            RenderOptions::new(ColorMode::Never)
420                .width(16)
421                .automatic_width_buffer(8),
422        );
423        assert_eq!(buffered, exact);
424    }
425
426    #[test]
427    fn colorless_document_is_deterministic() {
428        let document = Document::new()
429            .heading("status")
430            .fields(Fields::new().row("pending", Text::plain("2")))
431            .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
432        let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
433        let expected = indoc! {"
434            status
435
436            ┌─────────┬───┐
437            │ pending ┆ 2 │
438            └─────────┴───┘
439
440            warning · one stale row
441        "};
442        assert_eq!(rendered, expected);
443        assert!(!rendered.contains('\u{1b}'));
444    }
445
446    #[test]
447    fn colored_document_has_ansi() {
448        let document = Document::new().heading("status");
449        let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
450        assert!(rendered.contains('\u{1b}'));
451    }
452
453    #[test]
454    fn narrow_table_stacks() {
455        let table =
456            Table::plain()
457                .stacked_below(64, 2)
458                .row(["-f", "--format", "Output representation"]);
459        let rendered = Document::new()
460            .table(table)
461            .render(RenderOptions::new(ColorMode::Never).width(40));
462        assert_eq!(rendered, "  -f --format\n    Output representation\n");
463    }
464
465    #[test]
466    fn verbatim_text_ignores_explicit_width() {
467        let source = indoc! {"
468            ```text
469            this line stays longer than five
470            ```
471        "};
472        let rendered = Document::new()
473            .verbatim(source)
474            .render(RenderOptions::new(ColorMode::Never).width(5));
475        assert_eq!(rendered, source);
476    }
477
478    #[test]
479    fn verbatim_text_removes_terminal_bidi_and_line_controls() {
480        let rendered = Document::new()
481            .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
482            .render(RenderOptions::new(ColorMode::Never).width(5));
483        assert_eq!(rendered, "]52;clipboard\tvalue\n");
484    }
485
486    #[test]
487    fn verbatim_text_preserves_other_unicode_formatting() {
488        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}";
489        let rendered = Document::new()
490            .verbatim(source)
491            .render(RenderOptions::new(ColorMode::Never).width(5));
492        assert_eq!(rendered, format!("{source}\n"));
493    }
494
495    #[test]
496    fn wrapped_stacked_labels_keep_indentation() {
497        let table = Table::plain()
498            .stacked_below(64, 1)
499            .row(["one two three four five", "description"]);
500        let rendered = Document::new()
501            .table(table)
502            .render(RenderOptions::new(ColorMode::Never).width(14));
503        let expected = formatdoc! {"
504            {label}one two
505            {label}three four
506            {label}five
507            {description}descriptio
508            {description}n
509            ",
510            label = "  ",
511            description = "    ",
512        };
513        assert_eq!(rendered, expected);
514    }
515}