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;
28pub const DEFAULT_RECORD_STYLE_ENV: &str = "CTL_CORE_RECORD_STYLE";
30pub const DEFAULT_RECORD_STYLE_ENVS: &[&str] = &[DEFAULT_RECORD_STYLE_ENV];
32pub const DEFAULT_LIST_STYLE_ENV: &str = "CTL_CORE_LIST_STYLE";
34pub const DEFAULT_LIST_STYLE_ENVS: &[&str] = &[DEFAULT_LIST_STYLE_ENV];
36pub const DEFAULT_ROW_SEPARATION_ENV: &str = "CTL_CORE_ROW_SEPARATION";
38pub const DEFAULT_ROW_SEPARATION_ENVS: &[&str] = &[DEFAULT_ROW_SEPARATION_ENV];
40
41const RULE: LineStyle = LineStyle::none().fill('─').junction('─');
43
44#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum RecordStyle {
47 Boxed,
49 #[default]
51 KeysRight,
52 KeysLeft,
54}
55
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
58pub enum ListStyle {
59 #[default]
61 Grid,
62 HeaderRule,
64 Plain,
66}
67
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
70pub enum RowSeparation {
71 #[default]
73 None,
74 Rule,
76 Blank,
78}
79
80pub(crate) trait EnvChoice: Copy + 'static {
82 const VALUES: &'static [(&'static str, Self)];
84}
85
86impl EnvChoice for RecordStyle {
87 const VALUES: &'static [(&'static str, Self)] = &[
88 ("keys-right", Self::KeysRight),
89 ("keys-left", Self::KeysLeft),
90 ("boxed", Self::Boxed),
91 ];
92}
93
94impl EnvChoice for ListStyle {
95 const VALUES: &'static [(&'static str, Self)] = &[
96 ("grid", Self::Grid),
97 ("header-rule", Self::HeaderRule),
98 ("plain", Self::Plain),
99 ];
100}
101
102impl EnvChoice for RowSeparation {
103 const VALUES: &'static [(&'static str, Self)] = &[
104 ("none", Self::None),
105 ("rule", Self::Rule),
106 ("blank", Self::Blank),
107 ];
108}
109
110pub(crate) fn env_choice<T: EnvChoice>(
112 names: &[&str],
113 value: impl Fn(&str) -> Option<String>,
114) -> Option<T> {
115 names
116 .iter()
117 .filter_map(|name| value(name))
118 .find_map(|raw| parse_choice(&raw))
119}
120
121#[cfg(feature = "app")]
123pub(crate) fn env_choice_warnings<T: EnvChoice>(
124 names: &[&str],
125 value: impl Fn(&str) -> Option<String>,
126) -> Vec<String> {
127 names
128 .iter()
129 .filter_map(|name| {
130 let raw = value(name)?;
131 parse_choice::<T>(&raw).is_none().then(|| {
132 let accepted = T::VALUES
133 .iter()
134 .map(|(value, _)| *value)
135 .collect::<Vec<_>>()
136 .join(", ");
137 format!("{name}={raw} is ignored; use one of {accepted}")
138 })
139 })
140 .collect()
141}
142
143pub(crate) fn process_env(name: &str) -> Option<String> {
145 std::env::var(name).ok().filter(|value| !value.is_empty())
146}
147
148fn parse_choice<T: EnvChoice>(raw: &str) -> Option<T> {
149 T::VALUES
150 .iter()
151 .find(|(value, _)| *value == raw.trim())
152 .map(|(_, choice)| *choice)
153}
154
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub struct RenderOptions {
158 color: ColorMode,
159 width: Option<u16>,
160 automatic_width_buffer: Option<u16>,
161 automatic_width_buffer_envs: &'static [&'static str],
162 minimum_automatic_width: u16,
163 fallback_width: Option<u16>,
164 record_style: Option<RecordStyle>,
165 record_style_envs: &'static [&'static str],
166 list_style: Option<ListStyle>,
167 list_style_envs: &'static [&'static str],
168 row_separation: Option<RowSeparation>,
169 row_separation_envs: &'static [&'static str],
170}
171
172impl RenderOptions {
173 #[must_use]
180 pub const fn new(color: ColorMode) -> Self {
181 Self {
182 color,
183 width: None,
184 automatic_width_buffer: None,
185 automatic_width_buffer_envs: DEFAULT_COLUMN_BUFFER_ENVS,
186 minimum_automatic_width: DEFAULT_MINIMUM_AUTOMATIC_WIDTH,
187 fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
188 record_style: None,
189 record_style_envs: DEFAULT_RECORD_STYLE_ENVS,
190 list_style: None,
191 list_style_envs: DEFAULT_LIST_STYLE_ENVS,
192 row_separation: None,
193 row_separation_envs: DEFAULT_ROW_SEPARATION_ENVS,
194 }
195 }
196
197 #[must_use]
199 pub const fn record_style(mut self, style: RecordStyle) -> Self {
200 self.record_style = Some(style);
201 self
202 }
203
204 #[must_use]
206 pub const fn list_style(mut self, style: ListStyle) -> Self {
207 self.list_style = Some(style);
208 self
209 }
210
211 #[must_use]
213 pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
214 self.row_separation = Some(separation);
215 self
216 }
217
218 #[must_use]
221 pub const fn record_style_envs(mut self, names: &'static [&'static str]) -> Self {
222 self.record_style_envs = names;
223 self
224 }
225
226 #[must_use]
229 pub const fn list_style_envs(mut self, names: &'static [&'static str]) -> Self {
230 self.list_style_envs = names;
231 self
232 }
233
234 #[must_use]
237 pub const fn row_separation_envs(mut self, names: &'static [&'static str]) -> Self {
238 self.row_separation_envs = names;
239 self
240 }
241
242 #[must_use]
245 pub fn record(self) -> RecordStyle {
246 self.record_with(process_env)
247 }
248
249 #[must_use]
252 pub fn list(self) -> ListStyle {
253 self.list_with(process_env)
254 }
255
256 #[must_use]
259 pub fn separation(self) -> RowSeparation {
260 self.separation_with(process_env)
261 }
262
263 fn record_with(self, value: impl Fn(&str) -> Option<String>) -> RecordStyle {
264 self.record_style
265 .or_else(|| env_choice(self.record_style_envs, value))
266 .unwrap_or_default()
267 }
268
269 fn list_with(self, value: impl Fn(&str) -> Option<String>) -> ListStyle {
270 self.list_style
271 .or_else(|| env_choice(self.list_style_envs, value))
272 .unwrap_or_default()
273 }
274
275 fn separation_with(self, value: impl Fn(&str) -> Option<String>) -> RowSeparation {
276 self.row_separation
277 .or_else(|| env_choice(self.row_separation_envs, value))
278 .unwrap_or_default()
279 }
280
281 #[cfg(feature = "app")]
284 pub(crate) fn style_env_warnings(self, value: impl Fn(&str) -> Option<String>) -> Vec<String> {
285 let mut warnings = Vec::new();
286 if self.record_style.is_none() {
287 warnings.extend(env_choice_warnings::<RecordStyle>(
288 self.record_style_envs,
289 &value,
290 ));
291 }
292 if self.list_style.is_none() {
293 warnings.extend(env_choice_warnings::<ListStyle>(
294 self.list_style_envs,
295 &value,
296 ));
297 }
298 if self.row_separation.is_none() {
299 warnings.extend(env_choice_warnings::<RowSeparation>(
300 self.row_separation_envs,
301 &value,
302 ));
303 }
304 warnings
305 }
306
307 #[must_use]
309 pub const fn width(mut self, width: u16) -> Self {
310 self.width = Some(width);
311 self
312 }
313
314 #[must_use]
317 pub const fn automatic_width_buffer(mut self, columns: u16) -> Self {
318 self.automatic_width_buffer = Some(columns);
319 self
320 }
321
322 #[must_use]
325 pub const fn automatic_width_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
326 self.automatic_width_buffer_envs = names;
327 self
328 }
329
330 #[must_use]
332 pub const fn minimum_automatic_width(mut self, columns: u16) -> Self {
333 self.minimum_automatic_width = columns;
334 self
335 }
336
337 #[must_use]
342 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
343 self.fallback_width = width;
344 self
345 }
346
347 #[must_use]
349 pub const fn fallback(self) -> Option<u16> {
350 self.fallback_width
351 }
352
353 #[must_use]
355 pub const fn color(self) -> ColorMode {
356 self.color
357 }
358
359 #[must_use]
361 pub const fn explicit_width(self) -> Option<u16> {
362 self.width
363 }
364
365 #[must_use]
367 pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
368 self.automatic_width_buffer
369 }
370
371 #[must_use]
373 pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
374 self.automatic_width_buffer_envs
375 }
376
377 #[must_use]
379 pub const fn automatic_width_minimum(self) -> u16 {
380 self.minimum_automatic_width
381 }
382
383 fn resolved_automatic_width_buffer(self) -> u16 {
384 self.automatic_width_buffer
385 .or_else(|| crate::layout::column_buffer(self.automatic_width_buffer_envs))
386 .unwrap_or(DEFAULT_COLUMN_BUFFER)
387 }
388}
389
390#[derive(Clone, Copy, Debug, Eq, PartialEq)]
392pub struct Renderer {
393 options: RenderOptions,
394}
395
396impl Renderer {
397 #[must_use]
399 pub const fn new(options: RenderOptions) -> Self {
400 Self { options }
401 }
402
403 #[must_use]
405 pub fn render(self, document: &Document) -> String {
406 let mut rendered = document
407 .blocks()
408 .iter()
409 .filter_map(|block| {
410 let rendered = self.render_block(block);
411 (!rendered.is_empty()).then_some(rendered)
412 })
413 .collect::<Vec<_>>()
414 .join("\n\n");
415 if !rendered.is_empty() {
416 rendered.push('\n');
417 }
418 rendered
419 }
420
421 fn render_block(self, block: &Block) -> String {
422 match block {
423 Block::Heading(text) => self.wrap(&self.text_with_default(text, Role::Heading), 0),
424 Block::Paragraph(text) => self.wrap(&self.text(text), 0),
425 Block::Verbatim(value) => sanitize_verbatim(value),
426 Block::Fields(fields) => self.fields(fields),
427 Block::Table(table) => self.table(table),
428 Block::Section(section) => self.section(section),
429 Block::Notice(notice) => self.notice(notice),
430 Block::Rule(rule) => {
431 let width = usize::from(self.width().unwrap_or(40));
432 rule.title().map_or_else(
433 || "─".repeat(width),
434 |title| {
435 let title_width = title
436 .spans()
437 .iter()
438 .map(|span| UnicodeWidthStr::width(span.value()))
439 .sum::<usize>();
440 let title = self.text_with_default(title, Role::Heading);
441 format!(
442 "── {title} {}",
443 "─".repeat(width.saturating_sub(title_width + 4))
444 )
445 },
446 )
447 }
448 }
449 }
450
451 fn section(self, section: &Section) -> String {
452 let heading = self.wrap(&self.text_with_default(section.title(), Role::Heading), 0);
453 let body = self.render(section.body());
454 if body.is_empty() {
455 heading
456 } else {
457 format!("{heading}\n{}", body.trim_end())
458 }
459 }
460
461 fn fields(self, fields: &Fields) -> String {
462 let style = self.options.record();
463 let mut table = self.engine_table(if style == RecordStyle::Boxed {
464 UTF8_FULL_CONDENSED
465 } else {
466 NOTHING
467 });
468 for (label, value) in fields.rows() {
469 table.add_row([self.text(label), self.text(value)]);
470 }
471 if style != RecordStyle::Boxed {
472 if let Some(keys) = table.column_mut(0) {
473 keys.set_padding((0, 1));
474 if style == RecordStyle::KeysRight {
475 keys.set_cell_alignment(CellAlignment::Right);
476 }
477 }
478 if let Some(values) = table.column_mut(1) {
479 values.set_padding((1, 0));
480 }
481 }
482 for (index, column) in [0, 1].into_iter().zip(table.column_iter_mut()) {
483 let longest = fields
484 .rows()
485 .iter()
486 .map(|row| longest_word(&self.text(if index == 0 { &row.0 } else { &row.1 })))
487 .max()
488 .unwrap_or(0);
489 keep_words_whole(column, longest);
490 }
491 trim_line_ends(&table.to_string())
492 }
493
494 fn table(self, table: &Table) -> String {
495 if self.should_stack(table) {
496 return self.stacked(table);
497 }
498 let mut engine = self.list_table();
499 if !table.headers().is_empty() {
500 engine.set_header(
501 table
502 .headers()
503 .iter()
504 .map(|header| Cell::new(self.text_with_default(header, Role::Heading))),
505 );
506 }
507 let blank = self.options.separation() == RowSeparation::Blank;
508 for (position, row) in table.rows().iter().enumerate() {
509 if blank && position > 0 {
510 engine.add_row(row.iter().map(|_| Cell::new("")));
511 }
512 engine.add_row(row.iter().enumerate().map(|(index, cell)| {
513 let value = if table.token_column_index() == Some(index) {
514 self.text_with_default(cell, Role::Token)
515 } else if table.id_column_index() == Some(index) {
516 self.text_with_default(cell, Role::Id)
517 } else {
518 self.text(cell)
519 };
520 Cell::new(value)
521 }));
522 }
523 if self.options.list() != ListStyle::Grid
524 && let Some(first) = engine.column_mut(0)
525 {
526 first.set_padding((0, 1));
527 }
528 trim_line_ends(&engine.to_string())
529 }
530
531 fn list_table(self) -> EngineTable {
532 let rule = self.options.separation() == RowSeparation::Rule;
533 let style = match (self.options.list(), rule) {
534 (ListStyle::Grid, false) => UTF8_FULL_CONDENSED,
535 (ListStyle::Grid, true) => UTF8_FULL,
536 (ListStyle::HeaderRule, false) => NOTHING.header_separator(RULE),
537 (ListStyle::HeaderRule, true) => NOTHING.header_separator(RULE).row_separator(RULE),
538 (ListStyle::Plain, false) => NOTHING,
539 (ListStyle::Plain, true) => NOTHING.row_separator(RULE),
540 };
541 self.engine_table(style)
542 }
543
544 fn should_stack(self, table: &Table) -> bool {
545 let Some(stacked) = table.stacked() else {
546 return false;
547 };
548 self.width().is_some_and(|width| width < stacked.width())
549 }
550
551 fn stacked(self, table: &Table) -> String {
552 let Some(policy) = table.stacked() else {
553 return String::new();
554 };
555 let mut output = String::new();
556 for row in table.rows() {
557 let labels = row
558 .iter()
559 .take(policy.label_columns())
560 .filter(|value| !value.is_empty())
561 .map(|value| self.text_with_default(value, Role::Token))
562 .collect::<Vec<_>>()
563 .join(" ");
564 let description = row
565 .iter()
566 .skip(policy.label_columns())
567 .filter(|value| !value.is_empty())
568 .map(|value| self.text(value))
569 .collect::<Vec<_>>()
570 .join(" ");
571 for line in self.wrap(&labels, 2).lines() {
572 let _ = writeln!(output, " {line}");
573 }
574 if !description.is_empty() {
575 let wrapped = self.wrap(&description, 4);
576 for line in wrapped.lines() {
577 let _ = writeln!(output, " {line}");
578 }
579 }
580 }
581 output.trim_end().to_owned()
582 }
583
584 fn notice(self, notice: &Notice) -> String {
585 let (label, role) = match notice.level() {
586 NoticeLevel::Success => ("success", Role::Success),
587 NoticeLevel::Warning => ("warning", Role::Warning),
588 NoticeLevel::Error => ("error", Role::Error),
589 };
590 let mut line = self.paint(role, label);
591 if let Some(code) = notice.code_value() {
592 let _ = write!(line, " · {}", self.paint(Role::Muted, code));
593 }
594 let _ = write!(line, " · {}", self.text(notice.message()));
595 self.wrap(&line, 0)
596 }
597
598 fn engine_table(self, preset: TableStyle) -> EngineTable {
599 let mut table = EngineTable::new();
600 table
601 .load_style(preset)
602 .set_content_arrangement(ContentArrangement::Dynamic);
603 if let Some(width) = self.width() {
604 table.set_width(width);
605 }
606 table
607 }
608
609 fn wrap(self, value: &str, indentation: u16) -> String {
610 let Some(width) = self.width() else {
611 return value.to_owned();
612 };
613 let mut table = EngineTable::new();
614 table
615 .load_style(NOTHING)
616 .set_content_arrangement(ContentArrangement::Dynamic)
617 .set_width(width.saturating_sub(indentation))
618 .add_row([value]);
619 if let Some(column) = table.column_mut(0) {
620 column.set_padding((0, 0));
621 keep_words_whole(column, longest_word(value));
622 }
623 table
624 .to_string()
625 .lines()
626 .map(str::trim_end)
627 .collect::<Vec<_>>()
628 .join("\n")
629 }
630
631 fn width(self) -> Option<u16> {
632 self.options.explicit_width().or_else(|| {
633 crate::layout::terminal_width(
634 self.options.resolved_automatic_width_buffer(),
635 self.options.automatic_width_minimum(),
636 )
637 .or(self.options.fallback())
638 })
639 }
640
641 fn text(self, text: &Text) -> String {
642 text.spans()
643 .iter()
644 .map(|span| self.paint(span.role(), span.value()))
645 .collect()
646 }
647
648 fn text_with_default(self, text: &Text, default: Role) -> String {
649 text.spans()
650 .iter()
651 .map(|span| {
652 let role = if span.role() == Role::Plain {
653 default
654 } else {
655 span.role()
656 };
657 self.paint(role, span.value())
658 })
659 .collect()
660 }
661
662 fn paint(self, role: Role, value: &str) -> String {
663 if self.options.color() == ColorMode::Never || role == Role::Plain {
664 return value.to_owned();
665 }
666 let style = match role {
667 Role::Plain => return value.to_owned(),
668 Role::Heading => HEADING,
669 Role::Success => SUCCESS,
670 Role::Warning => WARNING,
671 Role::Error => ERROR,
672 Role::Value => VALUE,
673 Role::Muted => MUTED,
674 Role::Token => OPTION,
675 Role::Id => ID,
676 };
677 styled(style, value)
678 }
679}
680
681impl Document {
682 #[must_use]
684 pub fn render(&self, options: RenderOptions) -> String {
685 Renderer::new(options).render(self)
686 }
687}
688
689fn trim_line_ends(value: &str) -> String {
692 value
693 .lines()
694 .map(str::trim_end)
695 .collect::<Vec<_>>()
696 .join("\n")
697}
698
699fn sanitize_verbatim(value: &str) -> String {
700 value
701 .chars()
702 .filter(|character| {
703 *character == '\n' || *character == '\t' || !is_unsafe_verbatim(*character)
704 })
705 .collect::<String>()
706 .trim_end_matches('\n')
707 .to_owned()
708}
709
710fn is_unsafe_verbatim(character: char) -> bool {
711 character.is_control()
712 || matches!(
713 character,
714 ALM | FSI | LRE | LRI | LRM | LRO | PDF | PDI | RLE | RLI | RLM | RLO
715 )
716 || matches!(
717 get_general_category(character),
718 GeneralCategory::LineSeparator | GeneralCategory::ParagraphSeparator
719 )
720}
721
722fn longest_word(value: &str) -> u16 {
725 let mut plain = String::with_capacity(value.len());
726 let mut escape = false;
727 for character in value.chars() {
728 match (escape, character) {
729 (false, '\u{1b}') => escape = true,
730 (false, _) => plain.push(character),
731 (true, 'm') => escape = false,
732 (true, _) => {}
733 }
734 }
735 let widest = plain
736 .split_whitespace()
737 .map(UnicodeWidthStr::width)
738 .max()
739 .unwrap_or(0);
740 u16::try_from(widest).unwrap_or(u16::MAX)
741}
742
743fn keep_words_whole(column: &mut Column, longest: u16) {
746 if longest > 0 {
747 let bound = longest.saturating_add(column.padding_width());
748 column.set_constraint(ColumnConstraint::LowerBoundary(Width::Fixed(bound)));
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use indoc::{formatdoc, indoc};
755
756 use super::{ListStyle, RecordStyle, RenderOptions, RowSeparation};
757 use crate::color::ColorMode;
758 use crate::document::{Document, Fields, Notice, NoticeLevel, Section, Table, Text};
759
760 #[test]
761 fn automatic_width_policy_is_explicit_and_overridable() {
762 let defaults = RenderOptions::new(ColorMode::Never);
763 assert_eq!(defaults.explicit_automatic_width_buffer(), None);
764 assert_eq!(
765 defaults.automatic_width_minimum(),
766 super::DEFAULT_MINIMUM_AUTOMATIC_WIDTH
767 );
768
769 let configured = defaults
770 .automatic_width_buffer(0)
771 .automatic_width_buffer_envs(&["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"])
772 .minimum_automatic_width(8);
773 assert_eq!(configured.explicit_automatic_width_buffer(), Some(0));
774 assert_eq!(
775 configured.automatic_width_buffer_env_names(),
776 ["APP_COLUMNS_BUFFER", "LEGACY_BUFFER"]
777 );
778 assert_eq!(configured.automatic_width_minimum(), 8);
779 }
780
781 #[test]
782 fn explicit_width_ignores_the_automatic_buffer() {
783 let document = Document::new().paragraph("one two three four five six");
784 let exact = document.render(RenderOptions::new(ColorMode::Never).width(16));
785 let buffered = document.render(
786 RenderOptions::new(ColorMode::Never)
787 .width(16)
788 .automatic_width_buffer(8),
789 );
790 assert_eq!(buffered, exact);
791 }
792
793 #[test]
794 fn colorless_document_is_deterministic() {
795 let document = Document::new()
796 .heading("status")
797 .fields(Fields::new().row("pending", Text::plain("2")))
798 .notice(Notice::new(NoticeLevel::Warning, "one stale row"));
799 let rendered = document.render(RenderOptions::new(ColorMode::Never).width(60));
800 let expected = indoc! {"
801 status
802
803 pending 2
804
805 warning · one stale row
806 "};
807 assert_eq!(rendered, expected);
808 assert!(!rendered.contains('\u{1b}'));
809 }
810
811 fn queue() -> Table {
812 Table::new(["id", "title"])
813 .row(["QCTL-014", "Let a ledger declare row separation"])
814 .row(["QCTL-015", "Write a ledger atomically"])
815 .row(["QCTL-016", "Name the next startable row"])
816 }
817
818 #[test]
819 fn borderless_record_right_aligns_keys() {
820 let rendered = Document::new()
821 .fields(
822 Fields::new()
823 .row("ledger", Text::plain("tasks.yaml"))
824 .row("active", Text::plain("QCTL-014")),
825 )
826 .render(
827 RenderOptions::new(ColorMode::Never)
828 .width(80)
829 .record_style(RecordStyle::KeysRight),
830 );
831 let expected = indoc! {"
832 ledger tasks.yaml
833 active QCTL-014
834 "};
835 assert_eq!(rendered, expected);
836 }
837
838 #[test]
839 fn left_aligned_record_pads_short_keys_on_the_right() {
840 let rendered = Document::new()
841 .fields(
842 Fields::new()
843 .row("id", Text::plain("QCTL-014"))
844 .row("outcome", Text::plain("done")),
845 )
846 .render(
847 RenderOptions::new(ColorMode::Never)
848 .width(80)
849 .record_style(RecordStyle::KeysLeft),
850 );
851 let expected = indoc! {"
852 id QCTL-014
853 outcome done
854 "};
855 assert_eq!(rendered, expected);
856 }
857
858 #[test]
859 fn header_rule_draws_one_rule_and_no_verticals() {
860 let rendered = Document::new().table(queue()).render(
861 RenderOptions::new(ColorMode::Never)
862 .width(80)
863 .list_style(ListStyle::HeaderRule),
864 );
865 let rules = rendered
866 .lines()
867 .filter(|line| line.starts_with('─'))
868 .count();
869 assert_eq!(rules, 1, "{rendered}");
870 assert_eq!(
871 rendered.lines().nth(1).map(|line| line.starts_with('─')),
872 Some(true)
873 );
874 assert!(!rendered.contains(['│', '┆', '┌', '└']), "{rendered}");
875 }
876
877 #[test]
878 fn rule_separation_draws_a_rule_between_rows() {
879 let rendered = Document::new().table(queue()).render(
880 RenderOptions::new(ColorMode::Never)
881 .width(80)
882 .list_style(ListStyle::Plain)
883 .row_separation(RowSeparation::Rule),
884 );
885 let lines = rendered.lines().collect::<Vec<_>>();
886 assert_eq!(lines.len(), 6, "{rendered}");
887 assert!(
888 lines[2].starts_with('─') && lines[4].starts_with('─'),
889 "{rendered}"
890 );
891 }
892
893 #[test]
894 fn blank_separation_leaves_one_empty_line_between_rows() {
895 let rendered = Document::new().table(queue()).render(
896 RenderOptions::new(ColorMode::Never)
897 .width(80)
898 .list_style(ListStyle::Plain)
899 .row_separation(RowSeparation::Blank),
900 );
901 let lines = rendered.lines().collect::<Vec<_>>();
902 assert_eq!(lines.len(), 6, "{rendered}");
903 assert!(lines[2].is_empty() && lines[4].is_empty(), "{rendered}");
904 }
905
906 #[test]
907 fn defaults_are_the_operator_picks() {
908 let options = RenderOptions::new(ColorMode::Never);
909 assert_eq!(options.record(), RecordStyle::KeysRight);
910 assert_eq!(options.list(), ListStyle::Grid);
911 assert_eq!(options.separation(), RowSeparation::None);
912 assert_eq!(options.fallback(), Some(80));
913 assert_eq!(options.fallback_width(None).fallback(), None);
914 assert_eq!(super::DEFAULT_COLUMN_BUFFER, 2);
915 }
916
917 fn operator_env(name: &str) -> Option<String> {
918 match name {
919 "CTL_CORE_RECORD_STYLE" => Some("boxed"),
920 "CTL_CORE_LIST_STYLE" => Some("header-rule"),
921 "CTL_CORE_ROW_SEPARATION" => Some("blank"),
922 _ => None,
923 }
924 .map(str::to_owned)
925 }
926
927 #[test]
928 fn the_environment_picks_each_style_the_owner_left_open() {
929 let open = RenderOptions::new(ColorMode::Never);
930 assert_eq!(open.record_with(operator_env), RecordStyle::Boxed);
931 assert_eq!(open.list_with(operator_env), ListStyle::HeaderRule);
932 assert_eq!(open.separation_with(operator_env), RowSeparation::Blank);
933
934 let owned = open
935 .record_style(RecordStyle::KeysLeft)
936 .list_style(ListStyle::Plain)
937 .row_separation(RowSeparation::Rule);
938 assert_eq!(owned.record_with(operator_env), RecordStyle::KeysLeft);
939 assert_eq!(owned.list_with(operator_env), ListStyle::Plain);
940 assert_eq!(owned.separation_with(operator_env), RowSeparation::Rule);
941 }
942
943 #[test]
944 fn style_env_names_can_be_replaced_or_disabled() {
945 let aliased = |name: &str| (name == "TOY_RECORD").then(|| "keys-left".to_owned());
946 let replaced = RenderOptions::new(ColorMode::Never)
947 .record_style_envs(&["TOY_RECORD", "CTL_CORE_RECORD_STYLE"]);
948 assert_eq!(replaced.record_with(aliased), RecordStyle::KeysLeft);
949
950 let disabled = RenderOptions::new(ColorMode::Never)
951 .record_style_envs(&[])
952 .list_style_envs(&[])
953 .row_separation_envs(&[]);
954 assert_eq!(disabled.record_with(operator_env), RecordStyle::KeysRight);
955 assert_eq!(disabled.list_with(operator_env), ListStyle::Grid);
956 assert_eq!(disabled.separation_with(operator_env), RowSeparation::None);
957 }
958
959 #[cfg(feature = "app")]
960 #[test]
961 fn an_unknown_style_value_is_ignored_and_the_warning_names_the_choices() {
962 let typo = |name: &str| (name == "CTL_CORE_RECORD_STYLE").then(|| "boxy".to_owned());
963 let options = RenderOptions::new(ColorMode::Never);
964 assert_eq!(options.record_with(typo), RecordStyle::KeysRight);
965 assert_eq!(
966 options.style_env_warnings(typo),
967 ["CTL_CORE_RECORD_STYLE=boxy is ignored; use one of keys-right, keys-left, boxed"]
968 );
969 assert_eq!(
970 options
971 .record_style(RecordStyle::Boxed)
972 .style_env_warnings(typo),
973 Vec::<String>::new()
974 );
975 }
976
977 #[test]
978 fn colored_document_has_ansi() {
979 let document = Document::new().heading("status");
980 let rendered = document.render(RenderOptions::new(ColorMode::Always).width(60));
981 assert!(rendered.contains('\u{1b}'));
982 }
983
984 #[test]
985 fn ids_are_bold_without_a_colour() {
986 let table = Table::new(["id", "title"])
987 .id_column(0)
988 .row(["QCTL-014", "Title"]);
989 let rendered = Document::new()
990 .table(table)
991 .paragraph(Text::new().id("QCTL-015"))
992 .render(RenderOptions::new(ColorMode::Always).width(60));
993 assert!(
994 rendered.contains("\u{1b}[1mQCTL-014\u{1b}[0m"),
995 "{rendered}"
996 );
997 assert!(
998 rendered.contains("\u{1b}[1mQCTL-015\u{1b}[0m"),
999 "{rendered}"
1000 );
1001 }
1002
1003 #[test]
1004 fn headings_wrap_to_the_width() {
1005 let rendered = Document::new()
1006 .heading("QCTL-001 a title that runs past the line")
1007 .section(Section::new(
1008 "a section title that also runs long",
1009 Document::new(),
1010 ))
1011 .render(RenderOptions::new(ColorMode::Never).width(20));
1012 assert!(rendered.lines().count() > 2, "{rendered}");
1013 assert!(
1014 rendered.lines().all(|line| line.chars().count() <= 20),
1015 "{rendered}"
1016 );
1017 }
1018
1019 #[test]
1020 fn long_words_run_past_the_width_instead_of_splitting() {
1021 let url = "https://example.test/releases/tag/pkg@1.2.3";
1022 let rendered = Document::new()
1023 .fields(Fields::new().row("release", Text::plain(url)))
1024 .paragraph(Text::plain(format!("see {url}")))
1025 .render(RenderOptions::new(ColorMode::Always).width(30));
1026 assert_eq!(rendered.matches(url).count(), 2, "{rendered}");
1027 }
1028
1029 #[test]
1030 fn narrow_table_stacks() {
1031 let table =
1032 Table::plain()
1033 .stacked_below(64, 2)
1034 .row(["-f", "--format", "Output representation"]);
1035 let rendered = Document::new()
1036 .table(table)
1037 .render(RenderOptions::new(ColorMode::Never).width(40));
1038 assert_eq!(rendered, " -f --format\n Output representation\n");
1039 }
1040
1041 #[test]
1042 fn verbatim_text_ignores_explicit_width() {
1043 let source = indoc! {"
1044 ```text
1045 this line stays longer than five
1046 ```
1047 "};
1048 let rendered = Document::new()
1049 .verbatim(source)
1050 .render(RenderOptions::new(ColorMode::Never).width(5));
1051 assert_eq!(rendered, source);
1052 }
1053
1054 #[test]
1055 fn verbatim_text_removes_terminal_bidi_and_line_controls() {
1056 let rendered = Document::new()
1057 .verbatim("\u{1b}]52;clipboard\u{7}\u{202e}\u{2028}\u{2029}\tvalue")
1058 .render(RenderOptions::new(ColorMode::Never).width(5));
1059 assert_eq!(rendered, "]52;clipboard\tvalue\n");
1060 }
1061
1062 #[test]
1063 fn verbatim_text_preserves_other_unicode_formatting() {
1064 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}";
1065 let rendered = Document::new()
1066 .verbatim(source)
1067 .render(RenderOptions::new(ColorMode::Never).width(5));
1068 assert_eq!(rendered, format!("{source}\n"));
1069 }
1070
1071 #[test]
1072 fn wrapped_stacked_labels_keep_indentation() {
1073 let table = Table::plain()
1074 .stacked_below(64, 1)
1075 .row(["one two three four five", "description"]);
1076 let rendered = Document::new()
1077 .table(table)
1078 .render(RenderOptions::new(ColorMode::Never).width(14));
1079 let expected = formatdoc! {"
1080 {label}one two
1081 {label}three four
1082 {label}five
1083 {description}description
1084 ",
1085 label = " ",
1086 description = " ",
1087 };
1088 assert_eq!(rendered, expected);
1089 }
1090}