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