Skip to main content

nextest_runner/
help_render.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Renders MkDocs help-topic documents (help topics) to the terminal.
5
6use crate::{config::core::NextestConfig, helpers::RESET_COLOR, user_config::UserConfig};
7use indoc::formatdoc;
8use nextest_filtering::FILTERSET_REFERENCE_MD;
9use owo_colors::Style;
10use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
11use smallvec::SmallVec;
12use std::borrow::Cow;
13use swrite::{SWrite, swrite};
14use synoptic::{TokOpt, from_extension};
15use tracing::warn;
16use unicode_width::UnicodeWidthStr;
17
18const SITE_BASE: &str = "https://nexte.st";
19
20/// A help topic document.
21pub struct HelpDoc {
22    /// The raw markdown content of the document.
23    ///
24    /// This is transformed by the renderer.
25    pub markdown: Cow<'static, str>,
26    /// The doc's directory within the site, e.g. `["docs", "filtersets"]`.
27    ///
28    /// This is used to resolve relative links within the document.
29    pub site_dir: &'static [&'static str],
30}
31
32impl HelpDoc {
33    /// The filterset reference document.
34    pub fn filterset() -> Self {
35        let markdown = formatdoc! {"
36                # Filterset DSL reference
37
38                This topic contains the full set of operators supported by the filterset DSL.
39
40                This reference is also available [on the nextest site](https://nexte.st/docs/filtersets/reference/).
41
42                {FILTERSET_REFERENCE_MD}
43            "};
44        Self {
45            markdown: Cow::Owned(markdown),
46            site_dir: &["docs", "filtersets"],
47        }
48    }
49
50    /// The repository configuration reference document.
51    pub fn repo_config() -> Self {
52        Self {
53            markdown: Cow::Owned(repo_config_markdown()),
54            site_dir: &["docs", "configuration"],
55        }
56    }
57
58    /// The user configuration reference document.
59    pub fn user_config() -> Self {
60        Self {
61            markdown: Cow::Owned(user_config_markdown()),
62            site_dir: &["docs", "user-config"],
63        }
64    }
65}
66
67fn repo_config_markdown() -> String {
68    formatdoc! {"
69            # Configuration reference
70
71            This topic contains the full repository configuration reference for nextest.
72
73            This reference is also available [on the nextest site](https://nexte.st/docs/configuration/reference/).
74
75            {reference}
76
77            ## Default configuration
78
79            The default configuration shipped with cargo-nextest is:
80
81            ```toml
82            {default_config}
83            ```
84        ",
85        reference = NextestConfig::REFERENCE_MD.trim_end(),
86        default_config = NextestConfig::DEFAULT_CONFIG.trim_end(),
87    }
88}
89
90fn user_config_markdown() -> String {
91    formatdoc! {r#"
92            # User config reference
93
94            This topic contains the full user configuration reference for nextest.
95
96            This reference is also available [on the nextest site](https://nexte.st/docs/user-config/reference/).
97
98            For more information about how user configuration works, see the [user configuration overview](index.md).
99
100            ## Configuration file location
101
102            User configuration is loaded from one of the following platform-specific locations:
103
104            1. On Linux, macOS, and other Unix platforms: `$XDG_CONFIG_HOME/nextest/config.toml`, or `~/.config/nextest/config.toml` if `$XDG_CONFIG_HOME` is unset or empty.
105            2. On Windows: `%APPDATA%\nextest\config.toml`, falling back to `%XDG_CONFIG_HOME%\nextest\config.toml`, then `%HOME%\.config\nextest\config.toml`.
106
107            For more information about configuration hierarchy, see [_Configuration hierarchy_](index.md#configuration-hierarchy).
108
109            {reference}
110
111            ## Default configuration
112
113            The default user configuration is:
114
115            ```toml
116            {default_config}
117            ```
118        "#,
119        reference = UserConfig::REFERENCE_MD.trim_end(),
120        default_config = UserConfig::DEFAULT_CONFIG.trim_end(),
121    }
122}
123
124/// Rendering options for help topic output.
125pub struct RenderOptions {
126    /// Whether to emit ANSI color and style codes.
127    pub color: bool,
128    /// Whether to emit OSC-8 hyperlinks for links.
129    pub hyperlinks: bool,
130    /// The maximum width, in columns, to wrap output to.
131    pub width: usize,
132}
133
134/// Renders a help topic document to terminal-ready text.
135pub fn render(doc: &HelpDoc, opts: RenderOptions) -> String {
136    let preprocessed = preprocess(&doc.markdown);
137    let rendered = render_markdown(&preprocessed, doc.site_dir, opts);
138    if !rendered.dropped.is_empty() {
139        warn!(
140            "help renderer dropped unsupported markdown events: {:?}",
141            rendered.dropped
142        );
143    }
144    rendered.output
145}
146
147/// Preprocesses MkDocs markdown into a plain CommonMark subset.
148fn preprocess(input: &str) -> String {
149    let lines: Vec<&str> = input.lines().collect();
150    let mut out: Vec<String> = Vec::with_capacity(lines.len());
151    let mut i = 0;
152    while i < lines.len() {
153        let line = lines[i];
154        let trimmed = line.trim_start();
155
156        // Drop raw <div> wrappers -- no real way to support them in terminal
157        // output.
158        if trimmed.starts_with("<div") || trimmed == "</div>" {
159            i += 1;
160            continue;
161        }
162
163        // Convert admonitions into block quotes.
164        if let Some(header) = admonition_header(trimmed) {
165            let indent = line.len() - trimmed.len();
166            let body_indent = indent + 4;
167            i += 1;
168
169            let mut body_lines: Vec<String> = Vec::new();
170            while i < lines.len() {
171                let l = lines[i];
172                if l.trim().is_empty() {
173                    body_lines.push(String::new());
174                    i += 1;
175                } else if leading_spaces(l) >= body_indent {
176                    body_lines.push(l[l.ceil_char_boundary(body_indent)..].to_string());
177                    i += 1;
178                } else {
179                    break;
180                }
181            }
182            while body_lines.last().is_some_and(|s| s.is_empty()) {
183                body_lines.pop();
184            }
185
186            out.push(header);
187            out.push(">".to_string());
188            for b in body_lines {
189                if b.is_empty() {
190                    out.push(">".to_string());
191                } else {
192                    out.push(format!("> {b}"));
193                }
194            }
195            out.push(String::new());
196            continue;
197        }
198
199        out.push(line.to_string());
200        i += 1;
201    }
202
203    let joined = out.join("\n");
204    apply_shortcodes(&joined)
205}
206
207fn leading_spaces(line: &str) -> usize {
208    line.len() - line.trim_start().len()
209}
210
211/// Parses a `!!! kind "Title"` admonition header into a block-quote header
212/// line.
213fn admonition_header(trimmed: &str) -> Option<String> {
214    let rest = trimmed.strip_prefix("!!! ")?;
215    let (kind, title) = match rest.split_once(' ') {
216        Some((kind, title)) => (kind, Some(title.trim())),
217        None => (rest, None),
218    };
219    let label = capitalize(kind);
220    let title = title.and_then(|t| t.strip_prefix('"').and_then(|t| t.strip_suffix('"')));
221    Some(match title {
222        Some(title) => format!("> **{label}: {title}**"),
223        None => format!("> **{label}**"),
224    })
225}
226
227fn capitalize(s: &str) -> String {
228    let mut chars = s.chars();
229    match chars.next() {
230        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
231        None => String::new(),
232    }
233}
234
235/// Replaces `<!-- md:version X -->` with inline text, and strips other HTML
236/// comments.
237fn apply_shortcodes(input: &str) -> String {
238    let mut out = String::new();
239    let mut rest = input;
240    while let Some(start) = rest.find("<!--") {
241        out.push_str(&rest[..start]);
242        let after = &rest[start + "<!--".len()..];
243        let Some(end) = after.find("-->") else {
244            out.push_str(&rest[start..]);
245            return out;
246        };
247        let inner = after[..end].trim();
248        let version = inner
249            .strip_prefix("md:version")
250            .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace));
251        if let Some(version) = version {
252            let version = version.trim();
253            if !version.is_empty() {
254                swrite!(out, "*(since {version})*");
255            }
256        }
257        rest = &after[end + "-->".len()..];
258    }
259    out.push_str(rest);
260    out
261}
262
263/// Rewrites a relative URL to an absolute `nexte.st` URL.
264///
265/// Returns `None` for same-page anchors, which are not rewritten.
266fn rewrite_url(url: &str, site_dir: &[&str]) -> Option<String> {
267    if url.starts_with("http://") || url.starts_with("https://") {
268        return Some(url.to_string());
269    }
270
271    let (path, anchor) = match url.split_once('#') {
272        Some((path, anchor)) => (path, Some(anchor)),
273        None => (url, None),
274    };
275
276    // The target is a same-page anchor (e.g. [foo](#foo)). The content it
277    // points to is already on screen, so drop the link.
278    if path.is_empty() {
279        return None;
280    }
281
282    let mut segments: Vec<String> = site_dir.iter().map(|s| s.to_string()).collect();
283    for part in path.split('/') {
284        match part {
285            "" | "." => {}
286            ".." => {
287                // mkdocs would warn about this as well.
288                assert!(
289                    !segments.is_empty(),
290                    "relative link in help doc escapes the site root: {url:?}"
291                );
292                segments.pop();
293            }
294            // With our configuration, mkdocs maps `index.md` to its containing
295            // directory's URL, so this should be ignored.
296            "index.md" => {}
297            other => segments.push(other.trim_end_matches(".md").to_string()),
298        }
299    }
300    let resolved = format!("{SITE_BASE}/{}/", segments.join("/"));
301
302    Some(match anchor {
303        Some(anchor) => format!("{resolved}#{anchor}"),
304        None => resolved,
305    })
306}
307
308const DEFINITION_INDENT: &str = "    ";
309const QUOTE_PREFIX: &str = "│ ";
310const CODE_INDENT: &str = "    ";
311
312/// A stack of desired or currently-applied styles.
313///
314/// A capacity of 4 should cover all body text and headings in practice, and
315/// most of them in theory.
316type StyleStack = SmallVec<[Style; 4]>;
317
318/// The target of a hyperlink for a run of text.
319#[derive(Clone, Debug, Default, PartialEq, Eq)]
320enum LinkTarget {
321    /// The text is not linked.
322    #[default]
323    Unlinked,
324    /// The text is linked to the given URL.
325    Linked(String),
326}
327
328impl LinkTarget {
329    fn is_linked(&self) -> bool {
330        match self {
331            LinkTarget::Unlinked => false,
332            LinkTarget::Linked(_) => true,
333        }
334    }
335}
336
337/// The presentation attributes of a run of text.
338#[derive(Clone, Debug, Default)]
339struct RunAttrs {
340    styles: StyleStack,
341    link: LinkTarget,
342}
343
344impl RunAttrs {
345    /// Updates the current attributes to the desired ones, writing the
346    /// corresponding escape sequences to transition over.
347    fn sync(&mut self, desired: &RunAttrs, color: bool, hyperlinks: bool, out: &mut String) {
348        if color {
349            write_style_diff(&mut self.styles, &desired.styles, out);
350        }
351        if hyperlinks {
352            sync_link(&mut self.link, &desired.link, out);
353        }
354    }
355}
356
357/// A styled run of text.
358struct Span {
359    text: String,
360    desired: RunAttrs,
361}
362
363/// A single word to be rendered.
364#[derive(Default)]
365struct Word {
366    /// The sequence of styled spans that make up this word.
367    spans: Vec<Span>,
368    /// The current width of this word, in display columns.
369    width: usize,
370}
371
372impl Word {
373    fn is_empty(&self) -> bool {
374        self.spans.is_empty()
375    }
376
377    fn push(&mut self, text: &str, desired: &RunAttrs) {
378        self.spans.push(Span {
379            text: text.to_string(),
380            desired: desired.clone(),
381        });
382        self.width += display_width(text);
383    }
384
385    fn take(&mut self) -> Vec<Span> {
386        self.width = 0;
387        std::mem::take(&mut self.spans)
388    }
389}
390
391/// The set of styles for the help topic renderer.
392#[derive(Default)]
393struct Styles {
394    heading: Style,
395    emphasis: Style,
396    strong: Style,
397    strikethrough: Style,
398    code: Style,
399    term: Style,
400    link: Style,
401    toml_comment: Style,
402    toml_table: Style,
403    toml_string: Style,
404    toml_number: Style,
405    toml_boolean: Style,
406}
407
408impl Styles {
409    fn colorize(&mut self) {
410        self.heading = Style::new().bold().underline();
411        self.emphasis = Style::new().italic();
412        self.strong = Style::new().bold();
413        self.strikethrough = Style::new().strikethrough();
414        self.code = Style::new().cyan();
415        self.term = Style::new().green().bold();
416        self.link = Style::new().blue().underline();
417        self.toml_comment = Style::new().dimmed();
418        self.toml_table = Style::new().magenta().bold();
419        self.toml_string = Style::new().green();
420        self.toml_number = Style::new().yellow();
421        self.toml_boolean = Style::new().yellow();
422    }
423
424    /// Maps synoptic's built-in toml token kinds onto our palette.
425    fn toml_style(&self, kind: &str) -> Style {
426        match kind {
427            "comment" => self.toml_comment,
428            "table" => self.toml_table,
429            "string" => self.toml_string,
430            "digit" | "keyword" => self.toml_number,
431            "boolean" => self.toml_boolean,
432            other => {
433                // We're choosing to panic on an unknown kind because we're
434                // targeting a small set of documents and we do snapshot tests
435                // for all of them. (synoptic really should return a structured
436                // enum rather than a string here!)
437                //
438                // Note that we use locked-tripwire to ensure cargo nextest
439                // cannot be installed without --locked, so a newer version of
440                // synoptic cannot come in and surprise us.
441                panic!(
442                    "unknown synoptic toml token kind {other:?} -- \
443                     the built-in toml highlighter's token kinds may have changed"
444                );
445            }
446        }
447    }
448}
449
450/// The prefix written at the start of each line.
451#[derive(Default)]
452struct LinePrefix {
453    // The stack of prefix segments, concatenated to form the prefix applied to
454    // every line, unless the next line has an override.
455    segments: SmallVec<[String; 4]>,
456    // A one-shot override for the next line, if set.
457    next_override: Option<String>,
458}
459
460impl LinePrefix {
461    /// Pushes a prefix segment.
462    fn push(&mut self, prefix: &str) {
463        self.segments.push(prefix.to_string());
464    }
465
466    /// Pops the most recently pushed prefix segment.
467    ///
468    /// Panics if no segment has been pushed.
469    fn pop(&mut self) {
470        self.segments
471            .pop()
472            .expect("a prefix segment was pushed before this pop");
473    }
474
475    /// Sets an override for the next prefix by appending `marker` to the base
476    /// prefix.
477    fn set_next_override(&mut self, marker: &str) {
478        let mut prefix = self.base();
479        prefix.push_str(marker);
480        self.next_override = Some(prefix);
481    }
482
483    /// Consumes the prefix for the next line.
484    fn take_next(&mut self) -> String {
485        self.next_override.take().unwrap_or_else(|| self.base())
486    }
487
488    fn base(&self) -> String {
489        self.segments.concat()
490    }
491}
492
493/// A line emitter.
494///
495/// This is the lower-level line-writing component. It is unaware of markdown.
496struct LineWriter {
497    out: String,
498    color: bool,
499    hyperlinks: bool,
500    width: usize,
501
502    prefix: LinePrefix,
503    /// The state of the current line being written.
504    line: LineState,
505
506    /// The current word, plus a pending space that can act as a line break.
507    word: Word,
508    pending_space: Option<PendingSpace>,
509    desired_attrs: RunAttrs,
510}
511
512/// A pending space that can also act as a line break.
513///
514/// Remembers the style and link currently active, so a space between two words
515/// corresponding to the same link, e.g. the space between `a` and `b` in `[a
516/// b](https://example.com)`, stays inside that link.
517struct PendingSpace {
518    desired: RunAttrs,
519}
520
521/// The mutable state of a line that is currently open.
522#[derive(Clone, Debug)]
523struct OpenLine {
524    /// The number of visible columns used so far in this line.
525    column: usize,
526    /// The presentation emitted so far on this line.
527    emitted: RunAttrs,
528}
529
530#[derive(Clone, Debug)]
531enum LineState {
532    /// The line has been opened: its prefix has been emitted, and content may
533    /// follow.
534    Open(OpenLine),
535    /// The line is empty. Opening it writes the line prefix override if
536    /// present, otherwise the prefix.
537    AtStart,
538    /// The line is empty.
539    AfterBlock {
540        /// The prefix to trim and write out on the blank line.
541        prefix: String,
542    },
543}
544
545impl LineWriter {
546    fn new(color: bool, hyperlinks: bool, width: usize) -> Self {
547        LineWriter {
548            out: String::new(),
549            color,
550            hyperlinks,
551            width,
552            prefix: LinePrefix::default(),
553            line: LineState::AtStart,
554            word: Word::default(),
555            pending_space: None,
556            desired_attrs: RunAttrs::default(),
557        }
558    }
559
560    fn push_style(&mut self, style: Style) {
561        self.desired_attrs.styles.push(style);
562    }
563
564    fn pop_style(&mut self) {
565        self.desired_attrs.styles.pop();
566    }
567
568    fn set_link(&mut self, link: LinkTarget) {
569        self.desired_attrs.link = link;
570    }
571
572    fn take_link(&mut self) -> LinkTarget {
573        std::mem::take(&mut self.desired_attrs.link)
574    }
575
576    /// Adds a styled segment into the current word.
577    fn add_segment(&mut self, text: &str) {
578        if text.is_empty() {
579            return;
580        }
581        self.word.push(text, &self.desired_attrs);
582    }
583
584    /// Adds a breakable space, styled by the surrounding context.
585    fn add_space(&mut self) {
586        self.flush_word();
587        self.pending_space = Some(PendingSpace {
588            desired: self.desired_attrs.clone(),
589        });
590    }
591
592    /// Emits the buffered word, wrapping to the provided width with a hanging
593    /// indent.
594    fn flush_word(&mut self) {
595        if self.word.is_empty() {
596            return;
597        }
598        let pending = self.pending_space.take();
599        match &self.line {
600            LineState::Open(open) => {
601                if let Some(space) = pending {
602                    if open.column + 1 + self.word.width > self.width {
603                        self.end_line();
604                        self.open_line();
605                    } else {
606                        self.emit_space(&space.desired);
607                    }
608                }
609            }
610            LineState::AtStart | LineState::AfterBlock { .. } => self.open_line(),
611        }
612        self.emit_word();
613    }
614
615    fn emit_word(&mut self) {
616        let spans = self.word.take();
617        let Self {
618            out,
619            line,
620            color,
621            hyperlinks,
622            ..
623        } = self;
624        let LineState::Open(open) = line else {
625            return;
626        };
627        for span in spans {
628            open.emitted.sync(&span.desired, *color, *hyperlinks, out);
629            out.push_str(&span.text);
630            open.column += display_width(&span.text);
631        }
632    }
633
634    fn emit_space(&mut self, desired: &RunAttrs) {
635        self.write_open(" ", desired);
636    }
637
638    /// Writes styled text to the current open line, syncing styles and advancing
639    /// the column.
640    fn write_open(&mut self, text: &str, desired: &RunAttrs) {
641        let Self {
642            out,
643            line,
644            color,
645            hyperlinks,
646            ..
647        } = self;
648        let LineState::Open(open) = line else {
649            return;
650        };
651        open.emitted.sync(desired, *color, *hyperlinks, out);
652        out.push_str(text);
653        open.column += display_width(text);
654    }
655
656    /// Writes text verbatim (without wrapping), preserving newlines.
657    ///
658    /// Used for code blocks.
659    fn verbatim(&mut self, s: &str) {
660        // Code blocks are never hyperlinked, but preserve the current styles.
661        let mut desired = self.desired_attrs.clone();
662        desired.link = LinkTarget::Unlinked;
663        self.for_each_verbatim_line(s, |w, _, line| w.write_open(line, &desired));
664    }
665
666    /// Writes verbatim text with TOML highlighting.
667    fn verbatim_toml(&mut self, s: &str, palette: &Styles) {
668        let lines: Vec<String> = s.split('\n').map(str::to_string).collect();
669        let mut highlighter =
670            from_extension("toml", 4).expect("synoptic provides a built-in toml highlighter");
671        highlighter.run(&lines);
672
673        self.for_each_verbatim_line(s, |w, i, line| {
674            for token in highlighter.line(i, line) {
675                match token {
676                    TokOpt::Some(text, kind) => {
677                        let desired = RunAttrs {
678                            styles: StyleStack::from_slice(&[palette.toml_style(&kind)]),
679                            link: LinkTarget::Unlinked,
680                        };
681                        w.write_open(&text, &desired);
682                    }
683                    TokOpt::None(text) => w.write_open(&text, &RunAttrs::default()),
684                }
685            }
686        });
687    }
688
689    fn for_each_verbatim_line(
690        &mut self,
691        s: &str,
692        mut write_line: impl FnMut(&mut Self, usize, &str),
693    ) {
694        let lines: Vec<&str> = s.split('\n').collect();
695        let last = lines.len() - 1;
696        for (i, &line) in lines.iter().enumerate() {
697            if i != 0 {
698                self.end_line();
699            }
700            if line.is_empty() {
701                // If i is not the very last line, insert a line that the next
702                // iteration will end (to insert a blank line). If i is the last
703                // line, then don't append a trailing line.
704                if i != last {
705                    self.open_line();
706                }
707            } else {
708                self.open_line();
709                write_line(self, i, line);
710            }
711        }
712    }
713
714    fn hard_break(&mut self) {
715        self.flush_word();
716        self.end_line();
717    }
718
719    fn push_prefix(&mut self, prefix: &str) {
720        self.prefix.push(prefix);
721    }
722
723    fn pop_prefix(&mut self) {
724        self.prefix.pop();
725    }
726
727    fn set_item_marker(&mut self, marker: &str) {
728        self.prefix.set_next_override(marker);
729    }
730
731    fn open_line(&mut self) {
732        match &self.line {
733            LineState::Open(_) => return,
734            LineState::AtStart => {}
735            LineState::AfterBlock { prefix } => {
736                // Preserve the block-quote border across blank lines.
737                if !self.out.is_empty() {
738                    self.out.push_str(prefix.trim_end());
739                    self.out.push('\n');
740                }
741            }
742        }
743        let prefix = self.prefix.take_next();
744        self.out.push_str(&prefix);
745        self.line = LineState::Open(OpenLine {
746            column: display_width(&prefix),
747            emitted: RunAttrs::default(),
748        });
749    }
750
751    fn end_line(&mut self) {
752        let LineState::Open(open) = &self.line else {
753            return;
754        };
755        let reset = self.color && !open.emitted.styles.is_empty();
756        let close_link = open.emitted.link.is_linked();
757        // Drop trailing spaces so that blank and prefix-only lines don't carry
758        // any whitespace. We use trim_end_matches rather than trim_end to avoid
759        // eating newlines. If we used trim_end, we'd potentially end up
760        // consuming the `\n` from the previous line.
761        self.out.truncate(self.out.trim_end_matches(' ').len());
762        if close_link {
763            self.out.push_str("\x1b]8;;\x1b\\");
764        }
765        if reset {
766            self.out.push_str(RESET_COLOR);
767        }
768        self.out.push('\n');
769        self.line = LineState::AtStart;
770    }
771
772    fn end_block(&mut self) {
773        self.end_line();
774        self.line = LineState::AfterBlock {
775            prefix: self.prefix.base(),
776        };
777    }
778
779    /// Finishes the renderer, flushing any pending word and returning the
780    /// accumulated output.
781    fn finish(mut self) -> String {
782        self.flush_word();
783        self.end_line();
784        self.out
785    }
786}
787
788/// Diffs the currently-emitted stack of styles with the desired set of styles,
789/// emitting the minimum number of style changes to match the target.
790fn write_style_diff(emitted: &mut StyleStack, desired: &[Style], out: &mut String) {
791    if emitted.as_slice() == desired {
792        return;
793    }
794    let common = emitted.len();
795    if desired.len() > common && &desired[..common] == emitted.as_slice() {
796        for style in &desired[common..] {
797            out.push_str(&style.prefix_formatter().to_string());
798        }
799    } else {
800        if !emitted.is_empty() {
801            out.push_str(RESET_COLOR);
802        }
803        for style in desired {
804            out.push_str(&style.prefix_formatter().to_string());
805        }
806    }
807    *emitted = StyleStack::from_slice(desired);
808}
809
810/// Syncs the current OSC 8 hyperlink with the desired link.
811///
812/// This is similar to `write_style_diff` above. The main purpose of this is to
813/// prevent splitting hyperlinks on word boundaries.
814fn sync_link(emitted: &mut LinkTarget, desired: &LinkTarget, out: &mut String) {
815    if *emitted == *desired {
816        return;
817    }
818    if emitted.is_linked() {
819        out.push_str("\x1b]8;;\x1b\\");
820    }
821    if let LinkTarget::Linked(url) = desired {
822        swrite!(out, "\x1b]8;;{url}\x1b\\");
823    }
824    *emitted = desired.clone();
825}
826
827struct Rendered {
828    output: String,
829    dropped: Vec<String>,
830}
831
832/// The current code block being rendered.
833enum CodeBlockState {
834    /// A code block without highlighting.
835    Verbatim,
836    /// A TOML code block.
837    Toml { buffer: String },
838}
839
840fn render_markdown(
841    markdown: &str,
842    site_dir: &'static [&'static str],
843    opts: RenderOptions,
844) -> Rendered {
845    let parser_options = Options::ENABLE_DEFINITION_LIST | Options::ENABLE_STRIKETHROUGH;
846    let mut palette = Styles::default();
847    if opts.color {
848        palette.colorize();
849    }
850    let mut renderer = Renderer {
851        writer: LineWriter::new(opts.color, opts.hyperlinks, opts.width),
852        palette,
853        site_dir,
854        block: BlockContext::Normal(InlineContext::Normal),
855        list_stack: Vec::new(),
856        dropped: Vec::new(),
857    };
858
859    for event in Parser::new_ext(markdown, parser_options) {
860        renderer.handle(event);
861    }
862    Rendered {
863        output: renderer.writer.finish(),
864        dropped: renderer.dropped,
865    }
866}
867
868/// The current inline context the renderer is in.
869#[derive(Clone, Copy)]
870enum InlineContext {
871    Normal,
872    Term,
873}
874
875/// Block-level context for the renderer.
876enum BlockContext {
877    /// The normal block context, with no code block open.
878    Normal(InlineContext),
879    /// A code block is open.
880    Code(CodeBlockState),
881}
882
883/// State maintained for a single open list.
884struct ListFrame {
885    /// The ordinal counter for ordered lists, or `None` for unordered lists.
886    ordinal: Option<u64>,
887}
888
889/// A renderer that translates pulldown events into `LineWriter` operations.
890struct Renderer {
891    writer: LineWriter,
892    palette: Styles,
893    site_dir: &'static [&'static str],
894    block: BlockContext,
895    // The stack of open lists.
896    list_stack: Vec<ListFrame>,
897    dropped: Vec<String>,
898}
899
900impl Renderer {
901    fn handle(&mut self, event: Event<'_>) {
902        match event {
903            Event::Start(Tag::Paragraph) => {}
904            Event::End(TagEnd::Paragraph) => {
905                self.writer.flush_word();
906                self.writer.end_block();
907            }
908
909            Event::Start(Tag::Heading { .. }) => {
910                self.writer.end_block();
911                self.writer.push_style(self.palette.heading);
912            }
913            Event::End(TagEnd::Heading(_)) => {
914                self.writer.flush_word();
915                self.writer.pop_style();
916                self.writer.end_block();
917            }
918
919            Event::Start(Tag::BlockQuote(_)) => self.writer.push_prefix(QUOTE_PREFIX),
920            Event::End(TagEnd::BlockQuote(_)) => {
921                self.writer.flush_word();
922                self.writer.pop_prefix();
923                self.writer.end_block();
924            }
925
926            Event::Start(Tag::CodeBlock(kind)) => {
927                // Flush inline text buffered by a tight list item (e.g. `-
928                // label:` before a fenced block), so the item marker stays on
929                // the label, not the first code line.
930                self.writer.flush_word();
931                self.writer.end_line();
932                self.writer.push_prefix(CODE_INDENT);
933                self.block = BlockContext::Code(self.code_block_state_for(&kind));
934                self.writer.push_style(self.palette.code);
935            }
936            Event::End(TagEnd::CodeBlock) => {
937                // Reset the block context to normal, grabbing the TOML buffer
938                // if it exists.
939                if let BlockContext::Code(CodeBlockState::Toml { buffer }) =
940                    std::mem::replace(&mut self.block, BlockContext::Normal(InlineContext::Normal))
941                {
942                    // (Note that non-TOML contexts were being rendered
943                    // just-in-time, not buffered.)
944                    self.writer.verbatim_toml(&buffer, &self.palette);
945                }
946                self.writer.pop_style();
947                self.writer.pop_prefix();
948                self.writer.end_block();
949            }
950
951            Event::Start(Tag::List(start)) => {
952                self.list_stack.push(ListFrame { ordinal: start });
953            }
954            Event::End(TagEnd::List(_)) => {
955                self.list_stack.pop();
956                self.writer.end_block();
957            }
958            Event::Start(Tag::Item) => {
959                // Flush any inline content buffered by the parent item before a
960                // nested item starts. (This is relevant for tight nested lists
961                // to avoid lines running into each other.)
962                self.writer.flush_word();
963                self.writer.end_line();
964                let frame = self
965                    .list_stack
966                    .last_mut()
967                    .expect("a list item is inside a list");
968                let marker = match &mut frame.ordinal {
969                    Some(index) => {
970                        let marker = format!("{index}. ");
971                        *index += 1;
972                        marker
973                    }
974                    None => "- ".to_string(),
975                };
976                let indent = " ".repeat(display_width(&marker));
977                self.writer.set_item_marker(&marker);
978                self.writer.push_prefix(&indent);
979            }
980            Event::End(TagEnd::Item) => {
981                self.writer.flush_word();
982                self.writer.pop_prefix();
983                self.writer.end_line();
984            }
985
986            Event::Start(Tag::DefinitionList) => {}
987            Event::End(TagEnd::DefinitionList) => {}
988            Event::Start(Tag::DefinitionListTitle) => {
989                self.writer.end_block();
990                self.writer.push_style(self.palette.term);
991                self.block = BlockContext::Normal(InlineContext::Term);
992            }
993            Event::End(TagEnd::DefinitionListTitle) => {
994                self.writer.flush_word();
995                self.block = BlockContext::Normal(InlineContext::Normal);
996                self.writer.pop_style();
997                self.writer.end_line();
998            }
999            Event::Start(Tag::DefinitionListDefinition) => {
1000                self.writer.push_prefix(DEFINITION_INDENT);
1001            }
1002            Event::End(TagEnd::DefinitionListDefinition) => {
1003                self.writer.flush_word();
1004                self.writer.pop_prefix();
1005                self.writer.end_block();
1006            }
1007
1008            Event::Start(Tag::Emphasis) => self.writer.push_style(self.palette.emphasis),
1009            Event::End(TagEnd::Emphasis) => self.writer.pop_style(),
1010            Event::Start(Tag::Strong) => self.writer.push_style(self.palette.strong),
1011            Event::End(TagEnd::Strong) => self.writer.pop_style(),
1012            Event::Start(Tag::Strikethrough) => self.writer.push_style(self.palette.strikethrough),
1013            Event::End(TagEnd::Strikethrough) => self.writer.pop_style(),
1014
1015            Event::Start(Tag::Link { dest_url, .. }) => {
1016                // None means this is a same-page anchor -- drop it and render
1017                // text only.
1018                if let Some(url) = rewrite_url(&dest_url, self.site_dir) {
1019                    if self.writer.hyperlinks {
1020                        self.writer.push_style(self.palette.link);
1021                    }
1022                    self.writer.set_link(LinkTarget::Linked(url));
1023                }
1024            }
1025            Event::End(TagEnd::Link) => {
1026                // (None was a same-page anchor -- see Event::Start(Tag::Link {
1027                // .. }) above.)
1028                if let LinkTarget::Linked(url) = self.writer.take_link() {
1029                    if self.writer.hyperlinks {
1030                        self.writer.pop_style();
1031                    } else {
1032                        // The terminal doesn't support hyperlinks, so show the
1033                        // URL inline in the surrounding style.
1034                        self.writer.add_space();
1035                        self.writer.add_segment(&format!("<{url}>"));
1036                    }
1037                }
1038            }
1039
1040            Event::Text(text) => match &mut self.block {
1041                BlockContext::Code(CodeBlockState::Toml { buffer }) => buffer.push_str(&text),
1042                BlockContext::Code(CodeBlockState::Verbatim) => self.writer.verbatim(&text),
1043                BlockContext::Normal(InlineContext::Normal | InlineContext::Term) => {
1044                    self.push_text(&text)
1045                }
1046            },
1047            Event::Code(code) => {
1048                match self.block {
1049                    BlockContext::Normal(InlineContext::Term) => {
1050                        // A definition term is already fully styled, so don't
1051                        // additionally style it with the code style. (Maybe we
1052                        // want to revisit this later?)
1053                        self.writer.add_segment(&code);
1054                    }
1055                    BlockContext::Normal(InlineContext::Normal) => {
1056                        self.writer.push_style(self.palette.code);
1057                        self.writer.add_segment(&code);
1058                        self.writer.pop_style();
1059                    }
1060                    BlockContext::Code(_) => {
1061                        unreachable!("inline code events cannot occur inside code blocks")
1062                    }
1063                }
1064            }
1065            Event::SoftBreak => self.writer.add_space(),
1066            Event::HardBreak => self.writer.hard_break(),
1067
1068            // Ignore other events such as images, raw HTML, tables, footnotes,
1069            // etc. We may want to add support for these in the future, but
1070            // they're not necessary for the current set of things we render.
1071            other => self.dropped.push(format!("{other:?}")),
1072        }
1073    }
1074
1075    /// Pushes text into the word buffer, splitting it into words and breakable
1076    /// spaces.
1077    fn push_text(&mut self, s: &str) {
1078        let mut run_start = 0;
1079        // A little state machine to track whether we're in a whitespace run.
1080        let mut run_is_ws: Option<bool> = None;
1081        for (i, c) in s.char_indices() {
1082            let ws = c.is_whitespace();
1083            match run_is_ws {
1084                None => {
1085                    run_is_ws = Some(ws);
1086                    run_start = i;
1087                }
1088                Some(prev) if prev == ws => {}
1089                Some(prev) => {
1090                    self.emit_run(&s[run_start..i], prev);
1091                    run_is_ws = Some(ws);
1092                    run_start = i;
1093                }
1094            }
1095        }
1096        if let Some(prev) = run_is_ws {
1097            self.emit_run(&s[run_start..], prev);
1098        }
1099    }
1100
1101    fn emit_run(&mut self, run: &str, is_ws: bool) {
1102        if is_ws {
1103            self.writer.add_space();
1104        } else {
1105            self.writer.add_segment(run);
1106        }
1107    }
1108
1109    fn code_block_state_for(&self, kind: &CodeBlockKind) -> CodeBlockState {
1110        if !self.writer.color {
1111            return CodeBlockState::Verbatim;
1112        }
1113        let CodeBlockKind::Fenced(info) = kind else {
1114            return CodeBlockState::Verbatim;
1115        };
1116        match info.split_whitespace().next() {
1117            Some("toml") => CodeBlockState::Toml {
1118                buffer: String::new(),
1119            },
1120            _ => CodeBlockState::Verbatim,
1121        }
1122    }
1123}
1124
1125fn display_width(s: &str) -> usize {
1126    UnicodeWidthStr::width(s)
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132    use crate::config::core::NextestConfig;
1133    use nextest_filtering::FILTERSET_REFERENCE_MD;
1134
1135    #[test]
1136    fn hyperlinks_gate_osc8() {
1137        let with = render(
1138            &HelpDoc::filterset(),
1139            RenderOptions {
1140                color: false,
1141                hyperlinks: true,
1142                width: 80,
1143            },
1144        );
1145        assert!(
1146            with.contains("\x1b]8;;https://nexte.st/"),
1147            "emits OSC-8 hyperlinks"
1148        );
1149        assert!(
1150            !with.contains("<https://nexte.st/"),
1151            "no inline URL fallback"
1152        );
1153
1154        let without = render(
1155            &HelpDoc::filterset(),
1156            RenderOptions {
1157                color: false,
1158                hyperlinks: false,
1159                width: 80,
1160            },
1161        );
1162        assert!(!without.contains("\x1b]8;;"), "no OSC-8 when unsupported");
1163        assert!(
1164            without.contains("<https://nexte.st/"),
1165            "inline URL fallback"
1166        );
1167    }
1168
1169    #[test]
1170    fn multi_word_link_is_one_continuous_hyperlink() {
1171        let doc = HelpDoc {
1172            markdown: "[click here](https://example.com/page) and more text\n".into(),
1173            site_dir: &[],
1174        };
1175        let rendered = render(
1176            &doc,
1177            RenderOptions {
1178                color: false,
1179                hyperlinks: true,
1180                width: 80,
1181            },
1182        );
1183        insta::assert_snapshot!(rendered);
1184    }
1185
1186    #[test]
1187    fn wrapped_link_does_not_span_a_newline() {
1188        let doc = HelpDoc {
1189            markdown: "[alpha beta gamma](https://example.com/x)\n".into(),
1190            site_dir: &[],
1191        };
1192        let rendered = render(
1193            &doc,
1194            RenderOptions {
1195                color: false,
1196                hyperlinks: true,
1197                width: 12,
1198            },
1199        );
1200        insta::assert_snapshot!(rendered);
1201    }
1202
1203    #[test]
1204    fn color_nested_styles() {
1205        let doc = HelpDoc {
1206            markdown: "**bold _and italic_** then `code`.\n".into(),
1207            site_dir: &[],
1208        };
1209        let rendered = render(
1210            &doc,
1211            RenderOptions {
1212                color: true,
1213                hyperlinks: false,
1214                width: 80,
1215            },
1216        );
1217        insta::assert_snapshot!(rendered);
1218    }
1219
1220    #[test]
1221    fn ordered_list_indent_matches_marker_width() {
1222        let doc = HelpDoc {
1223            markdown: "1. aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii\n".into(),
1224            site_dir: &[],
1225        };
1226        let rendered = render(
1227            &doc,
1228            RenderOptions {
1229                color: false,
1230                hyperlinks: false,
1231                width: 20,
1232            },
1233        );
1234        let lines: Vec<&str> = rendered.lines().collect();
1235        assert!(lines[0].starts_with("1. "), "first line: {:?}", lines[0]);
1236        for cont in &lines[1..] {
1237            assert!(
1238                cont.starts_with("   ") && !cont.starts_with("    "),
1239                "continuation indented to marker width (3): {cont:?}"
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn nested_list_preserves_outer_ordinal() {
1246        let doc = HelpDoc {
1247            markdown: "1. first\n2. second\n   - nested a\n   - nested b\n3. third\n".into(),
1248            site_dir: &[],
1249        };
1250        let rendered = render(
1251            &doc,
1252            RenderOptions {
1253                color: false,
1254                hyperlinks: false,
1255                width: 80,
1256            },
1257        );
1258        insta::assert_snapshot!(rendered);
1259    }
1260
1261    #[test]
1262    fn code_block_preserves_blank_lines() {
1263        let doc = HelpDoc {
1264            markdown: "```\nfirst\n\nsecond\n```\n".into(),
1265            site_dir: &[],
1266        };
1267        let rendered = render(
1268            &doc,
1269            RenderOptions {
1270                color: false,
1271                hyperlinks: false,
1272                width: 80,
1273            },
1274        );
1275        assert!(
1276            rendered.contains("first\n\n") && rendered.contains("second"),
1277            "interior blank line preserved: {rendered:?}"
1278        );
1279    }
1280
1281    #[test]
1282    fn list_item_label_before_code_block_keeps_marker_on_label() {
1283        let doc = HelpDoc {
1284            markdown: "- **Examples**:\n  ```\n  retries = 3\n  ```\n".into(),
1285            site_dir: &[],
1286        };
1287        let rendered = render(
1288            &doc,
1289            RenderOptions {
1290                color: false,
1291                hyperlinks: false,
1292                width: 80,
1293            },
1294        );
1295        insta::assert_snapshot!(rendered);
1296    }
1297
1298    #[test]
1299    fn blockquote_code_block_blank_line_keeps_border() {
1300        let doc = HelpDoc {
1301            markdown: "> ```\n> a\n>\n> b\n> ```\n".into(),
1302            site_dir: &[],
1303        };
1304        let rendered = render(
1305            &doc,
1306            RenderOptions {
1307                color: false,
1308                hyperlinks: false,
1309                width: 80,
1310            },
1311        );
1312        insta::assert_snapshot!(rendered);
1313    }
1314
1315    #[test]
1316    fn admonition_with_unicode_indent_does_not_panic() {
1317        let doc = HelpDoc {
1318            markdown: "!!! note\n   \u{a0}body text\n".into(),
1319            site_dir: &[],
1320        };
1321        let _ = render(
1322            &doc,
1323            RenderOptions {
1324                color: false,
1325                hyperlinks: false,
1326                width: 80,
1327            },
1328        );
1329    }
1330
1331    #[test]
1332    fn rewrite_url_resolves_relative_links() {
1333        let site_dir = HelpDoc::filterset().site_dir;
1334        assert_eq!(
1335            rewrite_url("https://example.com/x", site_dir).as_deref(),
1336            Some("https://example.com/x"),
1337        );
1338        assert_eq!(
1339            rewrite_url("http://example.com/x", site_dir).as_deref(),
1340            Some("http://example.com/x"),
1341        );
1342        assert_eq!(rewrite_url("#binary-kinds", site_dir), None);
1343        assert_eq!(
1344            rewrite_url("../glossary.md#binary-id", site_dir).as_deref(),
1345            Some("https://nexte.st/docs/glossary/#binary-id"),
1346        );
1347        assert_eq!(
1348            rewrite_url("../configuration/test-groups.md", site_dir).as_deref(),
1349            Some("https://nexte.st/docs/configuration/test-groups/"),
1350        );
1351        assert_eq!(
1352            rewrite_url("../selecting.md", site_dir).as_deref(),
1353            Some("https://nexte.st/docs/selecting/"),
1354        );
1355
1356        // index.md resolves to the corresponding directory URL.
1357        let config_dir: &[&str] = &["docs", "configuration"];
1358        assert_eq!(
1359            rewrite_url("index.md", config_dir).as_deref(),
1360            Some("https://nexte.st/docs/configuration/"),
1361        );
1362        assert_eq!(
1363            rewrite_url("index.md#hierarchical-configuration", config_dir).as_deref(),
1364            Some("https://nexte.st/docs/configuration/#hierarchical-configuration"),
1365        );
1366        assert_eq!(
1367            rewrite_url("../user-config/index.md", config_dir).as_deref(),
1368            Some("https://nexte.st/docs/user-config/"),
1369        );
1370        assert_eq!(
1371            rewrite_url("../filtersets/index.md", config_dir).as_deref(),
1372            Some("https://nexte.st/docs/filtersets/"),
1373        );
1374    }
1375
1376    #[test]
1377    #[should_panic(expected = "relative link in help doc escapes the site root")]
1378    fn rewrite_url_panics_on_escaping_links() {
1379        let site_dir = HelpDoc::filterset().site_dir;
1380        rewrite_url("../../../../foo.md", site_dir);
1381    }
1382
1383    #[test]
1384    fn apply_shortcodes_handles_versions_and_comments() {
1385        assert_eq!(
1386            apply_shortcodes("a <!-- md:version 0.9.1 --> b"),
1387            "a *(since 0.9.1)* b",
1388        );
1389        assert_eq!(apply_shortcodes("a <!-- md:version --> b"), "a  b");
1390        assert_eq!(apply_shortcodes("a <!-- md:versionfoo --> b"), "a  b");
1391        assert_eq!(apply_shortcodes("a <!-- random comment --> b"), "a  b");
1392        assert_eq!(
1393            apply_shortcodes("a <!-- unterminated"),
1394            "a <!-- unterminated"
1395        );
1396    }
1397
1398    #[test]
1399    fn reference_stays_within_supported_subset() {
1400        let markdown = FILTERSET_REFERENCE_MD;
1401
1402        let mut rest = markdown;
1403        while let Some(idx) = rest.find("<!-- md:") {
1404            let after = &rest[idx + "<!-- md:".len()..];
1405            let name: String = after
1406                .chars()
1407                .take_while(|c| c.is_alphanumeric() || *c == '_')
1408                .collect();
1409            assert_eq!(
1410                name, "version",
1411                "unsupported `md:{name}` shortcode in the filterset reference; \
1412                 the CLI help renderer only handles `md:version` (see apply_shortcodes)"
1413            );
1414            rest = after;
1415        }
1416
1417        let preprocessed = preprocess(markdown);
1418        let rendered = render_markdown(
1419            &preprocessed,
1420            HelpDoc::filterset().site_dir,
1421            RenderOptions {
1422                color: false,
1423                hyperlinks: false,
1424                width: 80,
1425            },
1426        );
1427        assert!(
1428            rendered.dropped.is_empty(),
1429            "filterset reference uses markdown the CLI help renderer silently drops: {:?}",
1430            rendered.dropped,
1431        );
1432    }
1433
1434    #[test]
1435    fn repo_config_reference_stays_within_supported_subset() {
1436        let markdown = NextestConfig::REFERENCE_MD;
1437
1438        let mut rest = markdown;
1439        while let Some(idx) = rest.find("<!-- md:") {
1440            let after = &rest[idx + "<!-- md:".len()..];
1441            let name: String = after
1442                .chars()
1443                .take_while(|c| c.is_alphanumeric() || *c == '_')
1444                .collect();
1445            assert_eq!(
1446                name, "version",
1447                "unsupported `md:{name}` shortcode in the repo-config reference; \
1448                 the CLI help renderer only handles `md:version` (see apply_shortcodes)"
1449            );
1450            rest = after;
1451        }
1452
1453        let preprocessed = preprocess(markdown);
1454        let rendered = render_markdown(
1455            &preprocessed,
1456            &["docs", "configuration"],
1457            RenderOptions {
1458                color: false,
1459                hyperlinks: false,
1460                width: 80,
1461            },
1462        );
1463        assert!(
1464            rendered.dropped.is_empty(),
1465            "repo-config reference uses markdown the CLI help renderer silently drops: {:?}",
1466            rendered.dropped,
1467        );
1468    }
1469
1470    #[test]
1471    fn repo_config_markdown_includes_reference_and_defaults() {
1472        let markdown = repo_config_markdown();
1473        assert!(
1474            markdown.contains("# Configuration reference"),
1475            "repo-config topic includes the reference body"
1476        );
1477        assert!(
1478            markdown.contains("## Default configuration"),
1479            "repo-config topic appends a default configuration section"
1480        );
1481        assert!(
1482            markdown.contains(NextestConfig::DEFAULT_CONFIG.trim_end()),
1483            "repo-config topic embeds the default config verbatim"
1484        );
1485    }
1486
1487    #[test]
1488    fn user_config_reference_stays_within_supported_subset() {
1489        let markdown = UserConfig::REFERENCE_MD;
1490
1491        let mut rest = markdown;
1492        while let Some(idx) = rest.find("<!-- md:") {
1493            let after = &rest[idx + "<!-- md:".len()..];
1494            let name: String = after
1495                .chars()
1496                .take_while(|c| c.is_alphanumeric() || *c == '_')
1497                .collect();
1498            assert_eq!(
1499                name, "version",
1500                "unsupported `md:{name}` shortcode in the user-config reference; \
1501                 the CLI help renderer only handles `md:version` (see apply_shortcodes)"
1502            );
1503            rest = after;
1504        }
1505
1506        let preprocessed = preprocess(&user_config_markdown());
1507        let rendered = render_markdown(
1508            &preprocessed,
1509            &["docs", "user-config"],
1510            RenderOptions {
1511                color: false,
1512                hyperlinks: false,
1513                width: 80,
1514            },
1515        );
1516        assert!(
1517            rendered.dropped.is_empty(),
1518            "user-config reference uses markdown the CLI help renderer silently drops: {:?}",
1519            rendered.dropped,
1520        );
1521    }
1522
1523    #[test]
1524    fn user_config_markdown_includes_reference_and_defaults() {
1525        let markdown = user_config_markdown();
1526        assert!(
1527            markdown.contains("# User config reference"),
1528            "user-config topic includes the reference body"
1529        );
1530        assert!(
1531            markdown.contains("## Default configuration"),
1532            "user-config topic appends a default configuration section"
1533        );
1534        assert!(
1535            markdown.contains(UserConfig::DEFAULT_CONFIG.trim_end()),
1536            "user-config topic embeds the default config verbatim"
1537        );
1538    }
1539
1540    #[test]
1541    fn toml_code_block_is_highlighted() {
1542        let doc = HelpDoc {
1543            markdown: "```toml\n[profile.ci]\n# run all tests\nfail-fast = false\nretries = 3\nslow-timeout = \"60s\"\n```\n".into(),
1544            site_dir: &[],
1545        };
1546        let colored = render(
1547            &doc,
1548            RenderOptions {
1549                color: true,
1550                hyperlinks: false,
1551                width: 80,
1552            },
1553        );
1554        insta::assert_snapshot!("toml_highlight_color", colored);
1555
1556        let plain = render(
1557            &doc,
1558            RenderOptions {
1559                color: false,
1560                hyperlinks: false,
1561                width: 80,
1562            },
1563        );
1564        assert!(
1565            !plain.contains('\x1b'),
1566            "a toml block emits no ANSI when color is off: {plain:?}"
1567        );
1568    }
1569}