1use 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
17pub const DEFAULT_COLUMN_BUFFER: u16 = 2;
19pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
21pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
23pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
25pub const DEFAULT_FALLBACK_WIDTH: u16 = 80;
27
28const RULE: LineStyle = LineStyle::none().fill('─').junction('─');
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33pub enum RecordStyle {
34 Boxed,
36 #[default]
38 KeysRight,
39 KeysLeft,
41}
42
43#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub enum ListStyle {
46 #[default]
48 Grid,
49 HeaderRule,
51 Plain,
53}
54
55#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
57pub enum RowSeparation {
58 #[default]
60 None,
61 Rule,
63 Blank,
65}
66
67#[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 #[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 #[must_use]
103 pub const fn record_style(mut self, style: RecordStyle) -> Self {
104 self.record_style = style;
105 self
106 }
107
108 #[must_use]
110 pub const fn list_style(mut self, style: ListStyle) -> Self {
111 self.list_style = style;
112 self
113 }
114
115 #[must_use]
117 pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
118 self.row_separation = separation;
119 self
120 }
121
122 #[must_use]
124 pub const fn record(self) -> RecordStyle {
125 self.record_style
126 }
127
128 #[must_use]
130 pub const fn list(self) -> ListStyle {
131 self.list_style
132 }
133
134 #[must_use]
136 pub const fn separation(self) -> RowSeparation {
137 self.row_separation
138 }
139
140 #[must_use]
142 pub const fn width(mut self, width: u16) -> Self {
143 self.width = Some(width);
144 self
145 }
146
147 #[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 #[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 #[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 #[must_use]
175 pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
176 self.fallback_width = width;
177 self
178 }
179
180 #[must_use]
182 pub const fn fallback(self) -> Option<u16> {
183 self.fallback_width
184 }
185
186 #[must_use]
188 pub const fn color(self) -> ColorMode {
189 self.color
190 }
191
192 #[must_use]
194 pub const fn explicit_width(self) -> Option<u16> {
195 self.width
196 }
197
198 #[must_use]
200 pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
201 self.automatic_width_buffer
202 }
203
204 #[must_use]
206 pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
207 self.automatic_width_buffer_envs
208 }
209
210 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
225pub struct Renderer {
226 options: RenderOptions,
227}
228
229impl Renderer {
230 #[must_use]
232 pub const fn new(options: RenderOptions) -> Self {
233 Self { options }
234 }
235
236 #[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 #[must_use]
507 pub fn render(&self, options: RenderOptions) -> String {
508 Renderer::new(options).render(self)
509 }
510}
511
512fn 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}