1use 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
20pub struct HelpDoc {
22 pub markdown: Cow<'static, str>,
26 pub site_dir: &'static [&'static str],
30}
31
32impl HelpDoc {
33 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 pub fn repo_config() -> Self {
52 Self {
53 markdown: Cow::Owned(repo_config_markdown()),
54 site_dir: &["docs", "configuration"],
55 }
56 }
57
58 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
124pub struct RenderOptions {
126 pub color: bool,
128 pub hyperlinks: bool,
130 pub width: usize,
132}
133
134pub 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
147fn 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 if trimmed.starts_with("<div") || trimmed == "</div>" {
159 i += 1;
160 continue;
161 }
162
163 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
211fn 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
235fn 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
263fn 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 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 assert!(
289 !segments.is_empty(),
290 "relative link in help doc escapes the site root: {url:?}"
291 );
292 segments.pop();
293 }
294 "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
312type StyleStack = SmallVec<[Style; 4]>;
317
318#[derive(Clone, Debug, Default, PartialEq, Eq)]
320enum LinkTarget {
321 #[default]
323 Unlinked,
324 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#[derive(Clone, Debug, Default)]
339struct RunAttrs {
340 styles: StyleStack,
341 link: LinkTarget,
342}
343
344impl RunAttrs {
345 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
357struct Span {
359 text: String,
360 desired: RunAttrs,
361}
362
363#[derive(Default)]
365struct Word {
366 spans: Vec<Span>,
368 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#[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 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 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#[derive(Default)]
452struct LinePrefix {
453 segments: SmallVec<[String; 4]>,
456 next_override: Option<String>,
458}
459
460impl LinePrefix {
461 fn push(&mut self, prefix: &str) {
463 self.segments.push(prefix.to_string());
464 }
465
466 fn pop(&mut self) {
470 self.segments
471 .pop()
472 .expect("a prefix segment was pushed before this pop");
473 }
474
475 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 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
493struct LineWriter {
497 out: String,
498 color: bool,
499 hyperlinks: bool,
500 width: usize,
501
502 prefix: LinePrefix,
503 line: LineState,
505
506 word: Word,
508 pending_space: Option<PendingSpace>,
509 desired_attrs: RunAttrs,
510}
511
512struct PendingSpace {
518 desired: RunAttrs,
519}
520
521#[derive(Clone, Debug)]
523struct OpenLine {
524 column: usize,
526 emitted: RunAttrs,
528}
529
530#[derive(Clone, Debug)]
531enum LineState {
532 Open(OpenLine),
535 AtStart,
538 AfterBlock {
540 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 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 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 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 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 fn verbatim(&mut self, s: &str) {
660 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 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 != 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 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 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 fn finish(mut self) -> String {
782 self.flush_word();
783 self.end_line();
784 self.out
785 }
786}
787
788fn 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
810fn 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
832enum CodeBlockState {
834 Verbatim,
836 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#[derive(Clone, Copy)]
870enum InlineContext {
871 Normal,
872 Term,
873}
874
875enum BlockContext {
877 Normal(InlineContext),
879 Code(CodeBlockState),
881}
882
883struct ListFrame {
885 ordinal: Option<u64>,
887}
888
889struct Renderer {
891 writer: LineWriter,
892 palette: Styles,
893 site_dir: &'static [&'static str],
894 block: BlockContext,
895 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 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 if let BlockContext::Code(CodeBlockState::Toml { buffer }) =
940 std::mem::replace(&mut self.block, BlockContext::Normal(InlineContext::Normal))
941 {
942 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 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 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 if let LinkTarget::Linked(url) = self.writer.take_link() {
1029 if self.writer.hyperlinks {
1030 self.writer.pop_style();
1031 } else {
1032 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 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 other => self.dropped.push(format!("{other:?}")),
1072 }
1073 }
1074
1075 fn push_text(&mut self, s: &str) {
1078 let mut run_start = 0;
1079 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 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}