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, UTF8_FULL_CONDENSED};
6use comfy_table::{
7    Cell, CellAlignment, Column, ColumnConstraint, ContentArrangement, LineStyle,
8    Table as EngineTable, TableStyle, Width,
9};
10use unicode_bidi::format_chars::{ALM, FSI, LRE, LRI, LRM, LRO, PDF, PDI, RLE, RLI, RLM, RLO};
11use unicode_general_category::{GeneralCategory, get_general_category};
12use unicode_width::UnicodeWidthStr;
13
14use crate::color::ColorMode;
15use crate::document::{Block, Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
16use crate::style::{ERROR, HEADING, ID, MUTED, OPTION, SUCCESS, VALUE, WARNING, styled};
17
18/// Default columns reserved from an automatically detected terminal width.
19pub const DEFAULT_COLUMN_BUFFER: u16 = 2;
20/// Default environment variable for overriding the automatic-width buffer.
21pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
22/// Default ordered environment lookup for the automatic-width buffer.
23pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
24/// Default floor for an automatically detected effective width.
25pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
26/// Width used when neither the terminal nor `COLUMNS` gives one.
27pub const DEFAULT_FALLBACK_WIDTH: u16 = 80;
28
29/// A light horizontal rule that runs through column gaps.
30const RULE: LineStyle = LineStyle::none().fill('─').junction('─');
31
32/// How a [`Fields`] record is framed.
33#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
34pub enum RecordStyle {
35    /// A full box around every key and value.
36    Boxed,
37    /// No frame. Keys are right-aligned in one column.
38    #[default]
39    KeysRight,
40    /// No frame. Keys are left-aligned in one column.
41    KeysLeft,
42}
43
44/// How a [`Table`] of rows is framed.
45#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum ListStyle {
47    /// Outer frame, column rules, and a header rule.
48    #[default]
49    Grid,
50    /// One rule under the header and nothing else.
51    HeaderRule,
52    /// No rules at all.
53    Plain,
54}
55
56/// What separates consecutive rows of a [`Table`].
57#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
58pub enum RowSeparation {
59    /// Rows print on consecutive lines.
60    #[default]
61    None,
62    /// A horizontal rule between each pair of rows.
63    Rule,
64    /// A blank line between each pair of rows.
65    Blank,
66}
67
68/// Deterministic document rendering options.
69#[derive(Clone, Copy, Debug, Eq, PartialEq)]
70pub struct RenderOptions {
71    color: ColorMode,
72    width: Option<u16>,
73    automatic_width_buffer: Option<u16>,
74    automatic_width_buffer_envs: &'static [&'static str],
75    minimum_automatic_width: u16,
76    fallback_width: Option<u16>,
77    record_style: RecordStyle,
78    list_style: ListStyle,
79    row_separation: RowSeparation,
80}
81
82impl RenderOptions {
83    /// Build options with automatic terminal width, a
84    /// [`DEFAULT_COLUMN_BUFFER`]-column buffer, [`DEFAULT_COLUMN_BUFFER_ENV`]
85    /// as the operator override, and [`DEFAULT_FALLBACK_WIDTH`] when no
86    /// width is detected.
87    #[must_use]
88    pub const fn new(color: ColorMode) -> Self {
89        Self {
90            color,
91            width: None,
92            automatic_width_buffer: None,
93            automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
94            minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
95            fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
96            record_style: RecordStyle::KeysRight,
97            list_style: ListStyle::Grid,
98            row_separation: RowSeparation::None,
99        }
100    }
101
102    /// Frame [`Fields`] records in `style`.
103    #[must_use]
104    pub const fn record_style(mut self, style: RecordStyle) -> Self {
105        self.record_style = style;
106        self
107    }
108
109    /// Frame [`Table`] lists in `style`.
110    #[must_use]
111    pub const fn list_style(mut self, style: ListStyle) -> Self {
112        self.list_style = style;
113        self
114    }
115
116    /// Separate [`Table`] rows with `separation`.
117    #[must_use]
118    pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
119        self.row_separation = separation;
120        self
121    }
122
123    /// Record framing.
124    #[must_use]
125    pub const fn record(self) -> RecordStyle {
126        self.record_style
127    }
128
129    /// List framing.
130    #[must_use]
131    pub const fn list(self) -> ListStyle {
132        self.list_style
133    }
134
135    /// Row separation.
136    #[must_use]
137    pub const fn separation(self) -> RowSeparation {
138        self.row_separation
139    }
140
141    /// Force an explicit width. Tests and redirected renderers should use this.
142    #[must_use]
143    pub const fn width(mut self, width: u16) -> Self {
144        self.width = Some(width);
145        self
146    }
147
148    /// Override the automatic-width buffer. Zero explicitly disables it.
149    /// Explicit [`Self::width`] remains exact regardless of this value.
150    #[must_use]
151    pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
152        self.automatic_width_buffer = Some(columns);
153        self
154    }
155
156    /// Replace the ordered environment names used to configure the buffer.
157    /// Pass aliases in precedence order or an empty slice to disable lookup.
158    #[must_use]
159    pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
160        self.automatic_width_buffer_envs = names;
161        self
162    }
163
164    /// Set the floor for automatically detected effective widths.
165    #[must_use]
166    pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
167        self.minimum_automatic_width = columns;
168        self
169    }
170
171    /// Lay out to `width` when neither the terminal nor `COLUMNS` gives one.
172    /// `None` renders tables at their natural width instead. This does not
173    /// touch [`Self::minimum_automatic_width`], which applies only to a
174    /// detected width.
175    #[must_use]
176    pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
177        self.fallback_width = width;
178        self
179    }
180
181    /// Width used when none is detected, if any.
182    #[must_use]
183    pub const fn fallback(self) -> Option<u16> {
184        self.fallback_width
185    }
186
187    /// Color policy.
188    #[must_use]
189    pub const fn color(self) -> ColorMode {
190        self.color
191    }
192
193    /// Explicit width, when set.
194    #[must_use]
195    pub const fn explicit_width(self) -> Option<u16> {
196        self.width
197    }
198
199    /// Explicit automatic-width buffer, when the library owner set one.
200    #[must_use]
201    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
202        self.automatic_width_buffer
203    }
204
205    /// Ordered environment names used for the automatic-width buffer.
206    #[must_use]
207    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
208        self.automatic_width_buffer_envs
209    }
210
211    /// Floor applied only to automatically detected widths.
212    #[must_use]
213    pub const fn automatic_width_minimum(self) -> u16 {
214        self.minimum_automatic_width
215    }
216
217    fn resolved_automatic_width_buffer(self) -> u16 {
218        self.automatic_width_buffer
219            .or_else(|| crate::layout::column_buffer(self.automatic_width_buffer_envs))
220            .unwrap_or(DEFAULT_COLUMN_BUFFER)
221    }
222}
223
224/// Semantic document renderer.
225#[derive(Clone, Copy, Debug, Eq, PartialEq)]
226pub struct Renderer {
227    options: RenderOptions,
228}
229
230impl Renderer {
231    /// Build a renderer.
232    #[must_use]
233    pub const fn new(options: RenderOptions) -> Self {
234        Self { options }
235    }
236
237    /// Render one document to a newline-terminated string.
238    #[must_use]
239    pub fn render(self, document: &Document) -> String {
240        let mut rendered = document
241            .blocks()
242            .iter()
243            .filter_map(|block| {
244                let rendered = self.render_block(block);
245                (!rendered.is_empty()).then_some(rendered)
246            })
247            .collect::<Vec<_>>()
248            .join("\n\n");
249        if !rendered.is_empty() {
250            rendered.push('\n');
251        }
252        rendered
253    }
254
255    fn render_block(self, block: &Block) -> String {
256        match block {
257            Block::Heading(text) => self.wrap(&self.text_with_default(text, Role::Heading), 0),
258            Block::Paragraph(text) => self.wrap(&self.text(text), 0),
259            Block::Verbatim(value) => sanitize_verbatim(value),
260            Block::Fields(fields) => self.fields(fields),
261            Block::Table(table) => self.table(table),
262            Block::Section(section) => self.section(section),
263            Block::Notice(notice) => self.notice(notice),
264            Block::Rule(rule) => {
265                let width = usize::from(self.width().unwrap_or(40));
266                rule.title().map_or_else(
267                    || "─".repeat(width),
268                    |title| {
269                        let title_width = title
270                            .spans()
271                            .iter()
272                            .map(|span| UnicodeWidthStr::width(span.value()))
273                            .sum::<usize>();
274                        let title = self.text_with_default(title, Role::Heading);
275                        format!(
276                            "── {title} {}",
277                            "─".repeat(width.saturating_sub(title_width + 4))
278                        )
279                    },
280                )
281            }
282        }
283    }
284
285    fn section(self, section: &Section) -> String {
286        let heading = self.wrap(&self.text_with_default(section.title(), Role::Heading), 0);
287        let body = self.render(section.body());
288        if body.is_empty() {
289            heading
290        } else {
291            format!("{heading}\n{}", body.trim_end())
292        }
293    }
294
295    fn fields(self, fields: &Fields) -> String {
296        let style = self.options.record();
297        let mut table = self.engine_table(if style == RecordStyle::Boxed {
298            UTF8_FULL_CONDENSED
299        } else {
300            NOTHING
301        });
302        for (label, value) in fields.rows() {
303            table.add_row([self.text(label), self.text(value)]);
304        }
305        if style != RecordStyle::Boxed {
306            if let Some(keys) = table.column_mut(0) {
307                keys.set_padding((0, 1));
308                if style == RecordStyle::KeysRight {
309                    keys.set_cell_alignment(CellAlignment::Right);
310                }
311            }
312            if let Some(values) = table.column_mut(1) {
313                values.set_padding((1, 0));
314            }
315        }
316        for (index, column) in [0, 1].into_iter().zip(table.column_iter_mut()) {
317            let longest = fields
318                .rows()
319                .iter()
320                .map(|row| longest_word(&self.text(if index == 0 { &row.0 } else { &row.1 })))
321                .max()
322                .unwrap_or(0);
323            keep_words_whole(column, longest);
324        }
325        trim_line_ends(&table.to_string())
326    }
327
328    fn table(self, table: &Table) -> String {
329        if self.should_stack(table) {
330            return self.stacked(table);
331        }
332        let mut engine = self.list_table();
333        if !table.headers().is_empty() {
334            engine.set_header(
335                table
336                    .headers()
337                    .iter()
338                    .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
339            );
340        }
341        let blank = self.options.separation() == RowSeparation::Blank;
342        for (position, row) in table.rows().iter().enumerate() {
343            if blank && position > 0 {
344                engine.add_row(row.iter().map(|_| Cell::new("")));
345            }
346            engine.add_row(row.iter().enumerate().map(|(index, cell)| {
347                let value = if table.token_column_index() == Some(index) {
348                    self.text_with_default(cell, Role::Token)
349                } else if table.id_column_index() == Some(index) {
350                    self.text_with_default(cell, Role::Id)
351                } else {
352                    self.text(cell)
353                };
354                Cell::new(value)
355            }));
356        }
357        if self.options.list() != ListStyle::Grid
358            && let Some(first) = engine.column_mut(0)
359        {
360            first.set_padding((0, 1));
361        }
362        trim_line_ends(&engine.to_string())
363    }
364
365    fn list_table(self) -> EngineTable {
366        let rule = self.options.separation() == RowSeparation::Rule;
367        let style = match (self.options.list(), rule) {
368            (ListStyle::Grid, false) => UTF8_FULL_CONDENSED,
369            (ListStyle::Grid, true) => UTF8_FULL,
370            (ListStyle::HeaderRule, false) => NOTHING.header_separator(RULE),
371            (ListStyle::HeaderRule, true) => NOTHING.header_separator(RULE).row_separator(RULE),
372            (ListStyle::Plain, false) => NOTHING,
373            (ListStyle::Plain, true) => NOTHING.row_separator(RULE),
374        };
375        self.engine_table(style)
376    }
377
378    fn should_stack(self, table: &Table) -> bool {
379        let Some(stacked) = table.stacked() else {
380            return false;
381        };
382        self.width().is_some_and(|width| width < stacked.width())
383    }
384
385    fn stacked(self, table: &Table) -> String {
386        let Some(policy) = table.stacked() else {
387            return String::new();
388        };
389        let mut output = String::new();
390        for row in table.rows() {
391            let labels = row
392                .iter()
393                .take(policy.label_columns())
394                .filter(|value| !value.is_empty())
395                .map(|value| self.text_with_default(value, Role::Token))
396                .collect::<Vec<_>>()
397                .join(" ");
398            let description = row
399                .iter()
400                .skip(policy.label_columns())
401                .filter(|value| !value.is_empty())
402                .map(|value| self.text(value))
403                .collect::<Vec<_>>()
404                .join(" ");
405            for line in self.wrap(&labels, 2).lines() {
406                let _ = writeln!(output, "  {line}");
407            }
408            if !description.is_empty() {
409                let wrapped = self.wrap(&description, 4);
410                for line in wrapped.lines() {
411                    let _ = writeln!(output, "    {line}");
412                }
413            }
414        }
415        output.trim_end().to_owned()
416    }
417
418    fn notice(self, notice: &Notice) -> String {
419        let (label, role) = match notice.level() {
420            NoticeLevel::Success => ("success", Role::Success),
421            NoticeLevel::Warning => ("warning", Role::Warning),
422            NoticeLevel::Error => ("error", Role::Error),
423        };
424        let mut line = self.paint(role, label);
425        if let Some(code) = notice.code_value() {
426            let _ = write!(line, " · {}", self.paint(Role::Muted, code));
427        }
428        let _ = write!(line, " · {}", self.text(notice.message()));
429        self.wrap(&line, 0)
430    }
431
432    fn engine_table(self, preset: TableStyle) -> EngineTable {
433        let mut table = EngineTable::new();
434        table
435            .load_style(preset)
436            .set_content_arrangement(ContentArrangement::Dynamic);
437        if let Some(width) = self.width() {
438            table.set_width(width);
439        }
440        table
441    }
442
443    fn wrap(self, value: &str, indentation: u16) -> String {
444        let Some(width) = self.width() else {
445            return value.to_owned();
446        };
447        let mut table = EngineTable::new();
448        table
449            .load_style(NOTHING)
450            .set_content_arrangement(ContentArrangement::Dynamic)
451            .set_width(width.saturating_sub(indentation))
452            .add_row([value]);
453        if let Some(column) = table.column_mut(0) {
454            column.set_padding((0, 0));
455            keep_words_whole(column, longest_word(value));
456        }
457        table
458            .to_string()
459            .lines()
460            .map(str::trim_end)
461            .collect::<Vec<_>>()
462            .join("\n")
463    }
464
465    fn width(self) -> Option<u16> {
466        self.options.explicit_width().or_else(|| {
467            crate::layout::terminal_width(
468                self.options.resolved_automatic_width_buffer(),
469                self.options.automatic_width_minimum(),
470            )
471            .or(self.options.fallback())
472        })
473    }
474
475    fn text(self, text: &Text) -> String {
476        text.spans()
477            .iter()
478            .map(|span| self.paint(span.role(), span.value()))
479            .collect()
480    }
481
482    fn text_with_default(self, text: &Text, default: Role) -> String {
483        text.spans()
484            .iter()
485            .map(|span| {
486                let role = if span.role() == Role::Plain {
487                    default
488                } else {
489                    span.role()
490                };
491                self.paint(role, span.value())
492            })
493            .collect()
494    }
495
496    fn paint(self, role: Role, value: &str) -> String {
497        if self.options.color() == ColorMode::Never || role == Role::Plain {
498            return value.to_owned();
499        }
500        let style = match role {
501            Role::Plain => return value.to_owned(),
502            Role::Heading => HEADING,
503            Role::Success => SUCCESS,
504            Role::Warning => WARNING,
505            Role::Error => ERROR,
506            Role::Value => VALUE,
507            Role::Muted => MUTED,
508            Role::Token => OPTION,
509            Role::Id => ID,
510        };
511        styled(style, value)
512    }
513}
514
515impl Document {
516    /// Render with explicit semantic options.
517    #[must_use]
518    pub fn render(&self, options: RenderOptions) -> String {
519        Renderer::new(options).render(self)
520    }
521}
522
523/// Borderless styles leave cell padding at the line end; drop it so captured
524/// output has no trailing spaces.
525fn trim_line_ends(value: &str) -> String {
526    value
527        .lines()
528        .map(str::trim_end)
529        .collect::<Vec<_>>()
530        .join("\n")
531}
532
533fn sanitize_verbatim(value: &str) -> String {
534    value
535        .chars()
536        .filter(|character| {
537            *character == '\n' || *character == '\t' || !is_unsafe_verbatim(*character)
538        })
539        .collect::<String>()
540        .trim_end_matches('\n')
541        .to_owned()
542}
543
544fn is_unsafe_verbatim(character: char) -> bool {
545    character.is_control()
546        || matches!(
547            character,
548            ALM | FSI | LRE | LRI | LRM | LRO | PDF | PDI | RLE | RLI | RLM | RLO
549        )
550        || matches!(
551            get_general_category(character),
552            GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator
553        )
554}
555
556/// Width of the widest whitespace-separated word, ignoring the SGR escapes
557/// this renderer paints with.
558fn longest_word(value: &str) -> u16 {
559    let mut plain = String::with_capacity(value.len());
560    let mut escape = false;
561    for character in value.chars() {
562        match (escape, character) {
563            (false, '\u{1b}') => escape = true,
564            (false, _) => plain.push(character),
565            (true, 'm') => escape = false,
566            (true, _) => {}
567        }
568    }
569    let widest = plain
570        .split_whitespace()
571        .map(UnicodeWidthStr::width)
572        .max()
573        .unwrap_or(0);
574    u16::try_from(widest).unwrap_or(u16::MAX)
575}
576
577/// A URL or path split across lines cannot be copied back out, so a word
578/// wider than the column runs past the width instead of being broken.
579fn keep_words_whole(column: &mut Column, longest: u16) {
580    if longest > 0 {
581        let bound = longest.saturating_add(column.padding_width());
582        column.set_constraint(ColumnConstraint::LowerBoundary(Width::Fixed(bound)));
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use indoc::{formatdoc, indoc};
589
590    use super::{ListStyle, RecordStyle, RenderOptions, RowSeparation};
591    use crate::color::ColorMode;
592    use crate::document::{Document, Fields, Notice, NoticeLevel, Section, Table, Text};
593
594    #[test]
595    fn automatic_width_policy_is_explicit_and_overridable() {
596        let defaults = RenderOptions::new(ColorMode::Never);
597        assert_eq!(defaults.explicit_automatic_width_buffer(), None);
598        assert_eq!(
599            defaults.automatic_width_minimum(),
600            super::DEFAULT_MINIMUM_AUTOMATIC_WIDTH
601        );
602
603        let configured = defaults
604            .automatic_width_buffer(0)
605            .automatic_width_buffer_envs(&["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"])
606            .minimum_automatic_width(8);
607        assert_eq!(configured.explicit_automatic_width_buffer(), Some(0));
608        assert_eq!(
609            configured.automatic_width_buffer_env_names(),
610            ["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"]
611        );
612        assert_eq!(configured.automatic_width_minimum(), 8);
613    }
614
615    #[test]
616    fn explicit_width_ignores_the_automatic_buffer() {
617        let document = Document::new().paragraph("one two three four five six");
618        let exact = document.render(RenderOptions::new(ColorMode::Never).width(16));
619        let buffered = document.render(
620            RenderOptions::new(ColorMode::Never)
621                .width(16)
622                .automatic_width_buffer(8),
623        );
624        assert_eq!(buffered, exact);
625    }
626
627    #[test]
628    fn colorless_document_is_deterministic() {
629        let document = Document::new()
630            .heading("status")
631            .fields(Fields::new().row("pending", Text::plain("2")))
632            .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
633        let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
634        let expected = indoc! {"
635            status
636
637            pending  2
638
639            warning · one stale row
640        "};
641        assert_eq!(rendered, expected);
642        assert!(!rendered.contains('\u{1b}'));
643    }
644
645    fn queue() -> Table {
646        Table::new(["id", "title"])
647            .row(["QCTL-014", "Let a ledger declare row separation"])
648            .row(["QCTL-015", "Write a ledger atomically"])
649            .row(["QCTL-016", "Name the next startable row"])
650    }
651
652    #[test]
653    fn borderless_record_right_aligns_keys() {
654        let rendered = Document::new()
655            .fields(
656                Fields::new()
657                    .row("ledger", Text::plain("tasks.yaml"))
658                    .row("active", Text::plain("QCTL-014")),
659            )
660            .render(
661                RenderOptions::new(ColorMode::Never)
662                    .width(80)
663                    .record_style(RecordStyle::KeysRight),
664            );
665        let expected = indoc! {"
666            ledger  tasks.yaml
667            active  QCTL-014
668        "};
669        assert_eq!(rendered, expected);
670    }
671
672    #[test]
673    fn left_aligned_record_pads_short_keys_on_the_right() {
674        let rendered = Document::new()
675            .fields(
676                Fields::new()
677                    .row("id", Text::plain("QCTL-014"))
678                    .row("outcome", Text::plain("done")),
679            )
680            .render(
681                RenderOptions::new(ColorMode::Never)
682                    .width(80)
683                    .record_style(RecordStyle::KeysLeft),
684            );
685        let expected = indoc! {"
686            id       QCTL-014
687            outcome  done
688        "};
689        assert_eq!(rendered, expected);
690    }
691
692    #[test]
693    fn header_rule_draws_one_rule_and_no_verticals() {
694        let rendered = Document::new().table(queue()).render(
695            RenderOptions::new(ColorMode::Never)
696                .width(80)
697                .list_style(ListStyle::HeaderRule),
698        );
699        let rules = rendered
700            .lines()
701            .filter(|line| line.starts_with('─'))
702            .count();
703        assert_eq!(rules, 1, "{rendered}");
704        assert_eq!(
705            rendered.lines().nth(1).map(|line| line.starts_with('─')),
706            Some(true)
707        );
708        assert!(!rendered.contains(['│', '┆', '┌', '└']), "{rendered}");
709    }
710
711    #[test]
712    fn rule_separation_draws_a_rule_between_rows() {
713        let rendered = Document::new().table(queue()).render(
714            RenderOptions::new(ColorMode::Never)
715                .width(80)
716                .list_style(ListStyle::Plain)
717                .row_separation(RowSeparation::Rule),
718        );
719        let lines = rendered.lines().collect::<Vec<_>>();
720        assert_eq!(lines.len(), 6, "{rendered}");
721        assert!(
722            lines[2].starts_with('─') && lines[4].starts_with('─'),
723            "{rendered}"
724        );
725    }
726
727    #[test]
728    fn blank_separation_leaves_one_empty_line_between_rows() {
729        let rendered = Document::new().table(queue()).render(
730            RenderOptions::new(ColorMode::Never)
731                .width(80)
732                .list_style(ListStyle::Plain)
733                .row_separation(RowSeparation::Blank),
734        );
735        let lines = rendered.lines().collect::<Vec<_>>();
736        assert_eq!(lines.len(), 6, "{rendered}");
737        assert!(lines[2].is_empty() && lines[4].is_empty(), "{rendered}");
738    }
739
740    #[test]
741    fn defaults_are_the_operator_picks() {
742        let options = RenderOptions::new(ColorMode::Never);
743        assert_eq!(options.record(), RecordStyle::KeysRight);
744        assert_eq!(options.list(), ListStyle::Grid);
745        assert_eq!(options.separation(), RowSeparation::None);
746        assert_eq!(options.fallback(), Some(80));
747        assert_eq!(options.fallback_width(None).fallback(), None);
748        assert_eq!(super::DEFAULT_COLUMN_BUFFER, 2);
749    }
750
751    #[test]
752    fn colored_document_has_ansi() {
753        let document = Document::new().heading("status");
754        let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
755        assert!(rendered.contains('\u{1b}'));
756    }
757
758    #[test]
759    fn ids_are_bold_without_a_colour() {
760        let table = Table::new(["id", "title"])
761            .id_column(0)
762            .row(["QCTL-014", "Title"]);
763        let rendered = Document::new()
764            .table(table)
765            .paragraph(Text::new().id("QCTL-015"))
766            .render(RenderOptions::new(ColorMode::Always).width(60));
767        assert!(
768            rendered.contains("\u{1b}[1mQCTL-014\u{1b}[0m"),
769            "{rendered}"
770        );
771        assert!(
772            rendered.contains("\u{1b}[1mQCTL-015\u{1b}[0m"),
773            "{rendered}"
774        );
775    }
776
777    #[test]
778    fn headings_wrap_to_the_width() {
779        let rendered = Document::new()
780            .heading("QCTL-001  a title that runs past the line")
781            .section(Section::new(
782                "a section title that also runs long",
783                Document::new(),
784            ))
785            .render(RenderOptions::new(ColorMode::Never).width(20));
786        assert!(rendered.lines().count() > 2, "{rendered}");
787        assert!(
788            rendered.lines().all(|line| line.chars().count() <= 20),
789            "{rendered}"
790        );
791    }
792
793    #[test]
794    fn long_words_run_past_the_width_instead_of_splitting() {
795        let url = "https://example.test/releases/tag/pkg@1.2.3";
796        let rendered = Document::new()
797            .fields(Fields::new().row("release", Text::plain(url)))
798            .paragraph(Text::plain(format!("see {url}")))
799            .render(RenderOptions::new(ColorMode::Always).width(30));
800        assert_eq!(rendered.matches(url).count(), 2, "{rendered}");
801    }
802
803    #[test]
804    fn narrow_table_stacks() {
805        let table =
806            Table::plain()
807                .stacked_below(64, 2)
808                .row(["-f", "--format", "Output representation"]);
809        let rendered = Document::new()
810            .table(table)
811            .render(RenderOptions::new(ColorMode::Never).width(40));
812        assert_eq!(rendered, "  -f --format\n    Output representation\n");
813    }
814
815    #[test]
816    fn verbatim_text_ignores_explicit_width() {
817        let source = indoc! {"
818            ```text
819            this line stays longer than five
820            ```
821        "};
822        let rendered = Document::new()
823            .verbatim(source)
824            .render(RenderOptions::new(ColorMode::Never).width(5));
825        assert_eq!(rendered, source);
826    }
827
828    #[test]
829    fn verbatim_text_removes_terminal_bidi_and_line_controls() {
830        let rendered = Document::new()
831            .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
832            .render(RenderOptions::new(ColorMode::Never).width(5));
833        assert_eq!(rendered, "]52;clipboard\tvalue\n");
834    }
835
836    #[test]
837    fn verbatim_text_preserves_other_unicode_formatting() {
838        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}";
839        let rendered = Document::new()
840            .verbatim(source)
841            .render(RenderOptions::new(ColorMode::Never).width(5));
842        assert_eq!(rendered, format!("{source}\n"));
843    }
844
845    #[test]
846    fn wrapped_stacked_labels_keep_indentation() {
847        let table = Table::plain()
848            .stacked_below(64, 1)
849            .row(["one two three four five", "description"]);
850        let rendered = Document::new()
851            .table(table)
852            .render(RenderOptions::new(ColorMode::Never).width(14));
853        let expected = formatdoc! {"
854            {label}one two
855            {label}three four
856            {label}five
857            {description}description
858            ",
859            label = "  ",
860            description = "    ",
861        };
862        assert_eq!(rendered, expected);
863    }
864}