1use 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
18pub const DEFAULT_COLUMN_BUFFER: u16 = 2;
20pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
22pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
24pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
26pub const DEFAULT_FALLBACK_WIDTH: u16 = 80;
28
29const RULE: LineStyle = LineStyle::none().fill('─').junction('─');
31
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
34pub enum RecordStyle {
35 Boxed,
37 #[default]
39 KeysRight,
40 KeysLeft,
42}
43
44#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum ListStyle {
47 #[default]
49 Grid,
50 HeaderRule,
52 Plain,
54}
55
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
58pub enum RowSeparation {
59 #[default]
61 None,
62 Rule,
64 Blank,
66}
67
68#[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 #[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 #[must_use]
104 pub const fn record_style(mut self, style: RecordStyle) -> Self {
105 self.record_style = style;
106 self
107 }
108
109 #[must_use]
111 pub const fn list_style(mut self, style: ListStyle) -> Self {
112 self.list_style = style;
113 self
114 }
115
116 #[must_use]
118 pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
119 self.row_separation = separation;
120 self
121 }
122
123 #[must_use]
125 pub const fn record(self) -> RecordStyle {
126 self.record_style
127 }
128
129 #[must_use]
131 pub const fn list(self) -> ListStyle {
132 self.list_style
133 }
134
135 #[must_use]
137 pub const fn separation(self) -> RowSeparation {
138 self.row_separation
139 }
140
141 #[must_use]
143 pub const fn width(mut self, width: u16) -> Self {
144 self.width = Some(width);
145 self
146 }
147
148 #[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 #[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 #[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 #[must_use]
176 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
177 self.fallback_width = width;
178 self
179 }
180
181 #[must_use]
183 pub const fn fallback(self) -> Option<u16> {
184 self.fallback_width
185 }
186
187 #[must_use]
189 pub const fn color(self) -> ColorMode {
190 self.color
191 }
192
193 #[must_use]
195 pub const fn explicit_width(self) -> Option<u16> {
196 self.width
197 }
198
199 #[must_use]
201 pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
202 self.automatic_width_buffer
203 }
204
205 #[must_use]
207 pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
208 self.automatic_width_buffer_envs
209 }
210
211 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
226pub struct Renderer {
227 options: RenderOptions,
228}
229
230impl Renderer {
231 #[must_use]
233 pub const fn new(options: RenderOptions) -> Self {
234 Self { options }
235 }
236
237 #[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 #[must_use]
518 pub fn render(&self, options: RenderOptions) -> String {
519 Renderer::new(options).render(self)
520 }
521}
522
523fn 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
556fn 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
577fn 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}