Skip to main content

ctl_core/
render.rs

1//! Render semantic documents without exposing the terminal engine.
2
3use std::fmt::Write as _;
4
5use comfy_table::presets::{NOTHING, UTF8_FULL, UTF8_FULL_CONDENSED};
6use comfy_table::{
7    Cell, CellAlignment, Column, ColumnConstraint, ContentArrangement, LineStyle,
8    Table as EngineTable, TableStyle, Width,
9};
10use unicode_bidi::format_chars::{ALM, FSI, LRE, LRI, LRM, LRO, PDF, PDI, RLE, RLI, RLM, RLO};
11use unicode_general_category::{GeneralCategory, get_general_category};
12use unicode_width::UnicodeWidthStr;
13
14use crate::color::ColorMode;
15use crate::document::{Block, Document, Fields, Notice, NoticeLevel, Role, Section, Table, Text};
16use crate::style::{ERROR, HEADING, ID, MUTED, OPTION, SUCCESS, VALUE, WARNING, styled};
17
18/// Default columns reserved from an automatically detected terminal width.
19pub const DEFAULT_COLUMN_BUFFER: u16 = 2;
20/// Default environment variable for overriding the automatic-width buffer.
21pub const DEFAULT_COLUMN_BUFFER_ENV: &str = "CTL_CORE_COLUMN_BUFFER";
22/// Default ordered environment lookup for the automatic-width buffer.
23pub const DEFAULT_COLUMN_BUFFER_ENVS: &[&str] = &[DEFAULT_COLUMN_BUFFER_ENV];
24/// Default floor for an automatically detected effective width.
25pub const DEFAULT_MINIMUM_AUTOMATIC_WIDTH: u16 = 20;
26/// Width used when neither the terminal nor `COLUMNS` gives one.
27pub const DEFAULT_FALLBACK_WIDTH: u16 = 80;
28/// Default environment variable for the operator's record style.
29pub const DEFAULT_RECORD_STYLE_ENV: &str = "CTL_CORE_RECORD_STYLE";
30/// Default ordered environment lookup for the record style.
31pub const DEFAULT_RECORD_STYLE_ENVS: &[&str] = &[DEFAULT_RECORD_STYLE_ENV];
32/// Default environment variable for the operator's list style.
33pub const DEFAULT_LIST_STYLE_ENV: &str = "CTL_CORE_LIST_STYLE";
34/// Default ordered environment lookup for the list style.
35pub const DEFAULT_LIST_STYLE_ENVS: &[&str] = &[DEFAULT_LIST_STYLE_ENV];
36/// Default environment variable for the operator's row separation.
37pub const DEFAULT_ROW_SEPARATION_ENV: &str = "CTL_CORE_ROW_SEPARATION";
38/// Default ordered environment lookup for the row separation.
39pub const DEFAULT_ROW_SEPARATION_ENVS: &[&str] = &[DEFAULT_ROW_SEPARATION_ENV];
40
41/// A light horizontal rule that runs through column gaps.
42const RULE: LineStyle = LineStyle::none().fill('─').junction('─');
43
44/// How a [`Fields`] record is framed.
45#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
46pub enum RecordStyle {
47    /// A full box around every key and value.
48    Boxed,
49    /// No frame. Keys are right-aligned in one column.
50    #[default]
51    KeysRight,
52    /// No frame. Keys are left-aligned in one column.
53    KeysLeft,
54}
55
56/// How a [`Table`] of rows is framed.
57#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
58pub enum ListStyle {
59    /// Outer frame, column rules, and a header rule.
60    #[default]
61    Grid,
62    /// One rule under the header and nothing else.
63    HeaderRule,
64    /// No rules at all.
65    Plain,
66}
67
68/// What separates consecutive rows of a [`Table`].
69#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
70pub enum RowSeparation {
71    /// Rows print on consecutive lines.
72    #[default]
73    None,
74    /// A horizontal rule between each pair of rows.
75    Rule,
76    /// A blank line between each pair of rows.
77    Blank,
78}
79
80/// A style an operator can name in an environment variable.
81pub(crate) trait EnvChoice: Copy + 'static {
82    /// Accepted values, in the order a warning lists them.
83    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
110/// The first variable in `names` that holds an accepted value.
111pub(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/// One line per variable in `names` whose value is not accepted.
122#[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
143/// A set, non-empty environment variable.
144pub(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/// Deterministic document rendering options.
156#[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    /// Build options with automatic terminal width, a
174    /// [`DEFAULT_COLUMN_BUFFER`]-column buffer, [`DEFAULT_COLUMN_BUFFER_ENV`]
175    /// as the operator override, and [`DEFAULT_FALLBACK_WIDTH`] when no
176    /// width is detected. Record style, list style, and row separation read
177    /// [`DEFAULT_RECORD_STYLE_ENV`], [`DEFAULT_LIST_STYLE_ENV`], and
178    /// [`DEFAULT_ROW_SEPARATION_ENV`] unless the owner sets them.
179    #[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    /// Frame [`Fields`] records in `style`. This beats the environment.
198    #[must_use]
199    pub const fn record_style(mut self, style: RecordStyle) -> Self {
200        self.record_style = Some(style);
201        self
202    }
203
204    /// Frame [`Table`] lists in `style`. This beats the environment.
205    #[must_use]
206    pub const fn list_style(mut self, style: ListStyle) -> Self {
207        self.list_style = Some(style);
208        self
209    }
210
211    /// Separate [`Table`] rows with `separation`. This beats the environment.
212    #[must_use]
213    pub const fn row_separation(mut self, separation: RowSeparation) -> Self {
214        self.row_separation = Some(separation);
215        self
216    }
217
218    /// Replace the ordered environment names read for the record style.
219    /// An empty slice disables lookup.
220    #[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    /// Replace the ordered environment names read for the list style.
227    /// An empty slice disables lookup.
228    #[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    /// Replace the ordered environment names read for the row separation.
235    /// An empty slice disables lookup.
236    #[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    /// Record framing: the owner's choice, then the environment, then the
243    /// default.
244    #[must_use]
245    pub fn record(self) -> RecordStyle {
246        self.record_with(process_env)
247    }
248
249    /// List framing: the owner's choice, then the environment, then the
250    /// default.
251    #[must_use]
252    pub fn list(self) -> ListStyle {
253        self.list_with(process_env)
254    }
255
256    /// Row separation: the owner's choice, then the environment, then the
257    /// default.
258    #[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    /// One line per style variable whose value is not accepted. A style the
282    /// owner set never reads its variables, so it never warns.
283    #[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    /// Force an explicit width. Tests and redirected renderers should use this.
308    #[must_use]
309    pub const fn width(mut self, width: u16) -> Self {
310        self.width = Some(width);
311        self
312    }
313
314    /// Override the automatic-width buffer. Zero explicitly disables it.
315    /// Explicit [`Self::width`] remains exact regardless of this value.
316    #[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    /// Replace the ordered environment names used to configure the buffer.
323    /// Pass aliases in precedence order or an empty slice to disable lookup.
324    #[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    /// Set the floor for automatically detected effective widths.
331    #[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    /// Lay out to `width` when neither the terminal nor `COLUMNS` gives one.
338    /// `None` renders tables at their natural width instead. This does not
339    /// touch [`Self::minimum_automatic_width`], which applies only to a
340    /// detected width.
341    #[must_use]
342    pub const fn fallback_width(mut self, width: Option<u16>) -> Self {
343        self.fallback_width = width;
344        self
345    }
346
347    /// Width used when none is detected, if any.
348    #[must_use]
349    pub const fn fallback(self) -> Option<u16> {
350        self.fallback_width
351    }
352
353    /// Color policy.
354    #[must_use]
355    pub const fn color(self) -> ColorMode {
356        self.color
357    }
358
359    /// Explicit width, when set.
360    #[must_use]
361    pub const fn explicit_width(self) -> Option<u16> {
362        self.width
363    }
364
365    /// Explicit automatic-width buffer, when the library owner set one.
366    #[must_use]
367    pub const fn explicit_automatic_width_buffer(self) -> Option<u16> {
368        self.automatic_width_buffer
369    }
370
371    /// Ordered environment names used for the automatic-width buffer.
372    #[must_use]
373    pub const fn automatic_width_buffer_env_names(self) -> &'static [&'static str] {
374        self.automatic_width_buffer_envs
375    }
376
377    /// Floor applied only to automatically detected widths.
378    #[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/// Semantic document renderer.
391#[derive(Clone, Copy, Debug, Eq, PartialEq)]
392pub struct Renderer {
393    options: RenderOptions,
394}
395
396impl Renderer {
397    /// Build a renderer.
398    #[must_use]
399    pub const fn new(options: RenderOptions) -> Self {
400        Self { options }
401    }
402
403    /// Render one document to a newline-terminated string.
404    #[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    /// Render with explicit semantic options.
683    #[must_use]
684    pub fn render(&self, options: RenderOptions) -> String {
685        Renderer::new(options).render(self)
686    }
687}
688
689/// Borderless styles leave cell padding at the line end; drop it so captured
690/// output has no trailing spaces.
691fn 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
722/// Width of the widest whitespace-separated word, ignoring the SGR escapes
723/// this renderer paints with.
724fn 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
743/// A URL or path split across lines cannot be copied back out, so a word
744/// wider than the column runs past the width instead of being broken.
745fn 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}