use crate::{config::core::NextestConfig, helpers::RESET_COLOR, user_config::UserConfig};
use indoc::formatdoc;
use nextest_filtering::FILTERSET_REFERENCE_MD;
use owo_colors::Style;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use smallvec::SmallVec;
use std::borrow::Cow;
use swrite::{SWrite, swrite};
use synoptic::{TokOpt, from_extension};
use tracing::warn;
use unicode_width::UnicodeWidthStr;
const SITE_BASE: &str = "https://nexte.st";
pub struct HelpDoc {
pub markdown: Cow<'static, str>,
pub site_dir: &'static [&'static str],
}
impl HelpDoc {
pub fn filterset() -> Self {
let markdown = formatdoc! {"
# Filterset DSL reference
This topic contains the full set of operators supported by the filterset DSL.
This reference is also available [on the nextest site](https://nexte.st/docs/filtersets/reference/).
{FILTERSET_REFERENCE_MD}
"};
Self {
markdown: Cow::Owned(markdown),
site_dir: &["docs", "filtersets"],
}
}
pub fn repo_config() -> Self {
Self {
markdown: Cow::Owned(repo_config_markdown()),
site_dir: &["docs", "configuration"],
}
}
pub fn user_config() -> Self {
Self {
markdown: Cow::Owned(user_config_markdown()),
site_dir: &["docs", "user-config"],
}
}
}
fn repo_config_markdown() -> String {
formatdoc! {"
# Configuration reference
This topic contains the full repository configuration reference for nextest.
This reference is also available [on the nextest site](https://nexte.st/docs/configuration/reference/).
{reference}
## Default configuration
The default configuration shipped with cargo-nextest is:
```toml
{default_config}
```
",
reference = NextestConfig::REFERENCE_MD.trim_end(),
default_config = NextestConfig::DEFAULT_CONFIG.trim_end(),
}
}
fn user_config_markdown() -> String {
formatdoc! {r#"
# User config reference
This topic contains the full user configuration reference for nextest.
This reference is also available [on the nextest site](https://nexte.st/docs/user-config/reference/).
For more information about how user configuration works, see the [user configuration overview](index.md).
## Configuration file location
User configuration is loaded from one of the following platform-specific locations:
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.
2. On Windows: `%APPDATA%\nextest\config.toml`, falling back to `%XDG_CONFIG_HOME%\nextest\config.toml`, then `%HOME%\.config\nextest\config.toml`.
For more information about configuration hierarchy, see [_Configuration hierarchy_](index.md#configuration-hierarchy).
{reference}
## Default configuration
The default user configuration is:
```toml
{default_config}
```
"#,
reference = UserConfig::REFERENCE_MD.trim_end(),
default_config = UserConfig::DEFAULT_CONFIG.trim_end(),
}
}
pub struct RenderOptions {
pub color: bool,
pub hyperlinks: bool,
pub width: usize,
}
pub fn render(doc: &HelpDoc, opts: RenderOptions) -> String {
let preprocessed = preprocess(&doc.markdown);
let rendered = render_markdown(&preprocessed, doc.site_dir, opts);
if !rendered.dropped.is_empty() {
warn!(
"help renderer dropped unsupported markdown events: {:?}",
rendered.dropped
);
}
rendered.output
}
fn preprocess(input: &str) -> String {
let lines: Vec<&str> = input.lines().collect();
let mut out: Vec<String> = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("<div") || trimmed == "</div>" {
i += 1;
continue;
}
if let Some(header) = admonition_header(trimmed) {
let indent = line.len() - trimmed.len();
let body_indent = indent + 4;
i += 1;
let mut body_lines: Vec<String> = Vec::new();
while i < lines.len() {
let l = lines[i];
if l.trim().is_empty() {
body_lines.push(String::new());
i += 1;
} else if leading_spaces(l) >= body_indent {
body_lines.push(l[l.ceil_char_boundary(body_indent)..].to_string());
i += 1;
} else {
break;
}
}
while body_lines.last().is_some_and(|s| s.is_empty()) {
body_lines.pop();
}
out.push(header);
out.push(">".to_string());
for b in body_lines {
if b.is_empty() {
out.push(">".to_string());
} else {
out.push(format!("> {b}"));
}
}
out.push(String::new());
continue;
}
out.push(line.to_string());
i += 1;
}
let joined = out.join("\n");
apply_shortcodes(&joined)
}
fn leading_spaces(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn admonition_header(trimmed: &str) -> Option<String> {
let rest = trimmed.strip_prefix("!!! ")?;
let (kind, title) = match rest.split_once(' ') {
Some((kind, title)) => (kind, Some(title.trim())),
None => (rest, None),
};
let label = capitalize(kind);
let title = title.and_then(|t| t.strip_prefix('"').and_then(|t| t.strip_suffix('"')));
Some(match title {
Some(title) => format!("> **{label}: {title}**"),
None => format!("> **{label}**"),
})
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn apply_shortcodes(input: &str) -> String {
let mut out = String::new();
let mut rest = input;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let after = &rest[start + "<!--".len()..];
let Some(end) = after.find("-->") else {
out.push_str(&rest[start..]);
return out;
};
let inner = after[..end].trim();
let version = inner
.strip_prefix("md:version")
.filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace));
if let Some(version) = version {
let version = version.trim();
if !version.is_empty() {
swrite!(out, "*(since {version})*");
}
}
rest = &after[end + "-->".len()..];
}
out.push_str(rest);
out
}
fn rewrite_url(url: &str, site_dir: &[&str]) -> Option<String> {
if url.starts_with("http://") || url.starts_with("https://") {
return Some(url.to_string());
}
let (path, anchor) = match url.split_once('#') {
Some((path, anchor)) => (path, Some(anchor)),
None => (url, None),
};
if path.is_empty() {
return None;
}
let mut segments: Vec<String> = site_dir.iter().map(|s| s.to_string()).collect();
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
assert!(
!segments.is_empty(),
"relative link in help doc escapes the site root: {url:?}"
);
segments.pop();
}
"index.md" => {}
other => segments.push(other.trim_end_matches(".md").to_string()),
}
}
let resolved = format!("{SITE_BASE}/{}/", segments.join("/"));
Some(match anchor {
Some(anchor) => format!("{resolved}#{anchor}"),
None => resolved,
})
}
const DEFINITION_INDENT: &str = " ";
const QUOTE_PREFIX: &str = "│ ";
const CODE_INDENT: &str = " ";
type StyleStack = SmallVec<[Style; 4]>;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
enum LinkTarget {
#[default]
Unlinked,
Linked(String),
}
impl LinkTarget {
fn is_linked(&self) -> bool {
match self {
LinkTarget::Unlinked => false,
LinkTarget::Linked(_) => true,
}
}
}
#[derive(Clone, Debug, Default)]
struct RunAttrs {
styles: StyleStack,
link: LinkTarget,
}
impl RunAttrs {
fn sync(&mut self, desired: &RunAttrs, color: bool, hyperlinks: bool, out: &mut String) {
if color {
write_style_diff(&mut self.styles, &desired.styles, out);
}
if hyperlinks {
sync_link(&mut self.link, &desired.link, out);
}
}
}
struct Span {
text: String,
desired: RunAttrs,
}
#[derive(Default)]
struct Word {
spans: Vec<Span>,
width: usize,
}
impl Word {
fn is_empty(&self) -> bool {
self.spans.is_empty()
}
fn push(&mut self, text: &str, desired: &RunAttrs) {
self.spans.push(Span {
text: text.to_string(),
desired: desired.clone(),
});
self.width += display_width(text);
}
fn take(&mut self) -> Vec<Span> {
self.width = 0;
std::mem::take(&mut self.spans)
}
}
#[derive(Default)]
struct Styles {
heading: Style,
emphasis: Style,
strong: Style,
strikethrough: Style,
code: Style,
term: Style,
link: Style,
toml_comment: Style,
toml_table: Style,
toml_string: Style,
toml_number: Style,
toml_boolean: Style,
}
impl Styles {
fn colorize(&mut self) {
self.heading = Style::new().bold().underline();
self.emphasis = Style::new().italic();
self.strong = Style::new().bold();
self.strikethrough = Style::new().strikethrough();
self.code = Style::new().cyan();
self.term = Style::new().green().bold();
self.link = Style::new().blue().underline();
self.toml_comment = Style::new().dimmed();
self.toml_table = Style::new().magenta().bold();
self.toml_string = Style::new().green();
self.toml_number = Style::new().yellow();
self.toml_boolean = Style::new().yellow();
}
fn toml_style(&self, kind: &str) -> Style {
match kind {
"comment" => self.toml_comment,
"table" => self.toml_table,
"string" => self.toml_string,
"digit" | "keyword" => self.toml_number,
"boolean" => self.toml_boolean,
other => {
panic!(
"unknown synoptic toml token kind {other:?} -- \
the built-in toml highlighter's token kinds may have changed"
);
}
}
}
}
#[derive(Default)]
struct LinePrefix {
segments: SmallVec<[String; 4]>,
next_override: Option<String>,
}
impl LinePrefix {
fn push(&mut self, prefix: &str) {
self.segments.push(prefix.to_string());
}
fn pop(&mut self) {
self.segments
.pop()
.expect("a prefix segment was pushed before this pop");
}
fn set_next_override(&mut self, marker: &str) {
let mut prefix = self.base();
prefix.push_str(marker);
self.next_override = Some(prefix);
}
fn take_next(&mut self) -> String {
self.next_override.take().unwrap_or_else(|| self.base())
}
fn base(&self) -> String {
self.segments.concat()
}
}
struct LineWriter {
out: String,
color: bool,
hyperlinks: bool,
width: usize,
prefix: LinePrefix,
line: LineState,
word: Word,
pending_space: Option<PendingSpace>,
desired_attrs: RunAttrs,
}
struct PendingSpace {
desired: RunAttrs,
}
#[derive(Clone, Debug)]
struct OpenLine {
column: usize,
emitted: RunAttrs,
}
#[derive(Clone, Debug)]
enum LineState {
Open(OpenLine),
AtStart,
AfterBlock {
prefix: String,
},
}
impl LineWriter {
fn new(color: bool, hyperlinks: bool, width: usize) -> Self {
LineWriter {
out: String::new(),
color,
hyperlinks,
width,
prefix: LinePrefix::default(),
line: LineState::AtStart,
word: Word::default(),
pending_space: None,
desired_attrs: RunAttrs::default(),
}
}
fn push_style(&mut self, style: Style) {
self.desired_attrs.styles.push(style);
}
fn pop_style(&mut self) {
self.desired_attrs.styles.pop();
}
fn set_link(&mut self, link: LinkTarget) {
self.desired_attrs.link = link;
}
fn take_link(&mut self) -> LinkTarget {
std::mem::take(&mut self.desired_attrs.link)
}
fn add_segment(&mut self, text: &str) {
if text.is_empty() {
return;
}
self.word.push(text, &self.desired_attrs);
}
fn add_space(&mut self) {
self.flush_word();
self.pending_space = Some(PendingSpace {
desired: self.desired_attrs.clone(),
});
}
fn flush_word(&mut self) {
if self.word.is_empty() {
return;
}
let pending = self.pending_space.take();
match &self.line {
LineState::Open(open) => {
if let Some(space) = pending {
if open.column + 1 + self.word.width > self.width {
self.end_line();
self.open_line();
} else {
self.emit_space(&space.desired);
}
}
}
LineState::AtStart | LineState::AfterBlock { .. } => self.open_line(),
}
self.emit_word();
}
fn emit_word(&mut self) {
let spans = self.word.take();
let Self {
out,
line,
color,
hyperlinks,
..
} = self;
let LineState::Open(open) = line else {
return;
};
for span in spans {
open.emitted.sync(&span.desired, *color, *hyperlinks, out);
out.push_str(&span.text);
open.column += display_width(&span.text);
}
}
fn emit_space(&mut self, desired: &RunAttrs) {
self.write_open(" ", desired);
}
fn write_open(&mut self, text: &str, desired: &RunAttrs) {
let Self {
out,
line,
color,
hyperlinks,
..
} = self;
let LineState::Open(open) = line else {
return;
};
open.emitted.sync(desired, *color, *hyperlinks, out);
out.push_str(text);
open.column += display_width(text);
}
fn verbatim(&mut self, s: &str) {
let mut desired = self.desired_attrs.clone();
desired.link = LinkTarget::Unlinked;
self.for_each_verbatim_line(s, |w, _, line| w.write_open(line, &desired));
}
fn verbatim_toml(&mut self, s: &str, palette: &Styles) {
let lines: Vec<String> = s.split('\n').map(str::to_string).collect();
let mut highlighter =
from_extension("toml", 4).expect("synoptic provides a built-in toml highlighter");
highlighter.run(&lines);
self.for_each_verbatim_line(s, |w, i, line| {
for token in highlighter.line(i, line) {
match token {
TokOpt::Some(text, kind) => {
let desired = RunAttrs {
styles: StyleStack::from_slice(&[palette.toml_style(&kind)]),
link: LinkTarget::Unlinked,
};
w.write_open(&text, &desired);
}
TokOpt::None(text) => w.write_open(&text, &RunAttrs::default()),
}
}
});
}
fn for_each_verbatim_line(
&mut self,
s: &str,
mut write_line: impl FnMut(&mut Self, usize, &str),
) {
let lines: Vec<&str> = s.split('\n').collect();
let last = lines.len() - 1;
for (i, &line) in lines.iter().enumerate() {
if i != 0 {
self.end_line();
}
if line.is_empty() {
if i != last {
self.open_line();
}
} else {
self.open_line();
write_line(self, i, line);
}
}
}
fn hard_break(&mut self) {
self.flush_word();
self.end_line();
}
fn push_prefix(&mut self, prefix: &str) {
self.prefix.push(prefix);
}
fn pop_prefix(&mut self) {
self.prefix.pop();
}
fn set_item_marker(&mut self, marker: &str) {
self.prefix.set_next_override(marker);
}
fn open_line(&mut self) {
match &self.line {
LineState::Open(_) => return,
LineState::AtStart => {}
LineState::AfterBlock { prefix } => {
if !self.out.is_empty() {
self.out.push_str(prefix.trim_end());
self.out.push('\n');
}
}
}
let prefix = self.prefix.take_next();
self.out.push_str(&prefix);
self.line = LineState::Open(OpenLine {
column: display_width(&prefix),
emitted: RunAttrs::default(),
});
}
fn end_line(&mut self) {
let LineState::Open(open) = &self.line else {
return;
};
let reset = self.color && !open.emitted.styles.is_empty();
let close_link = open.emitted.link.is_linked();
self.out.truncate(self.out.trim_end_matches(' ').len());
if close_link {
self.out.push_str("\x1b]8;;\x1b\\");
}
if reset {
self.out.push_str(RESET_COLOR);
}
self.out.push('\n');
self.line = LineState::AtStart;
}
fn end_block(&mut self) {
self.end_line();
self.line = LineState::AfterBlock {
prefix: self.prefix.base(),
};
}
fn finish(mut self) -> String {
self.flush_word();
self.end_line();
self.out
}
}
fn write_style_diff(emitted: &mut StyleStack, desired: &[Style], out: &mut String) {
if emitted.as_slice() == desired {
return;
}
let common = emitted.len();
if desired.len() > common && &desired[..common] == emitted.as_slice() {
for style in &desired[common..] {
out.push_str(&style.prefix_formatter().to_string());
}
} else {
if !emitted.is_empty() {
out.push_str(RESET_COLOR);
}
for style in desired {
out.push_str(&style.prefix_formatter().to_string());
}
}
*emitted = StyleStack::from_slice(desired);
}
fn sync_link(emitted: &mut LinkTarget, desired: &LinkTarget, out: &mut String) {
if *emitted == *desired {
return;
}
if emitted.is_linked() {
out.push_str("\x1b]8;;\x1b\\");
}
if let LinkTarget::Linked(url) = desired {
swrite!(out, "\x1b]8;;{url}\x1b\\");
}
*emitted = desired.clone();
}
struct Rendered {
output: String,
dropped: Vec<String>,
}
enum CodeBlockState {
Verbatim,
Toml { buffer: String },
}
fn render_markdown(
markdown: &str,
site_dir: &'static [&'static str],
opts: RenderOptions,
) -> Rendered {
let parser_options = Options::ENABLE_DEFINITION_LIST | Options::ENABLE_STRIKETHROUGH;
let mut palette = Styles::default();
if opts.color {
palette.colorize();
}
let mut renderer = Renderer {
writer: LineWriter::new(opts.color, opts.hyperlinks, opts.width),
palette,
site_dir,
block: BlockContext::Normal(InlineContext::Normal),
list_stack: Vec::new(),
dropped: Vec::new(),
};
for event in Parser::new_ext(markdown, parser_options) {
renderer.handle(event);
}
Rendered {
output: renderer.writer.finish(),
dropped: renderer.dropped,
}
}
#[derive(Clone, Copy)]
enum InlineContext {
Normal,
Term,
}
enum BlockContext {
Normal(InlineContext),
Code(CodeBlockState),
}
struct ListFrame {
ordinal: Option<u64>,
}
struct Renderer {
writer: LineWriter,
palette: Styles,
site_dir: &'static [&'static str],
block: BlockContext,
list_stack: Vec<ListFrame>,
dropped: Vec<String>,
}
impl Renderer {
fn handle(&mut self, event: Event<'_>) {
match event {
Event::Start(Tag::Paragraph) => {}
Event::End(TagEnd::Paragraph) => {
self.writer.flush_word();
self.writer.end_block();
}
Event::Start(Tag::Heading { .. }) => {
self.writer.end_block();
self.writer.push_style(self.palette.heading);
}
Event::End(TagEnd::Heading(_)) => {
self.writer.flush_word();
self.writer.pop_style();
self.writer.end_block();
}
Event::Start(Tag::BlockQuote(_)) => self.writer.push_prefix(QUOTE_PREFIX),
Event::End(TagEnd::BlockQuote(_)) => {
self.writer.flush_word();
self.writer.pop_prefix();
self.writer.end_block();
}
Event::Start(Tag::CodeBlock(kind)) => {
self.writer.flush_word();
self.writer.end_line();
self.writer.push_prefix(CODE_INDENT);
self.block = BlockContext::Code(self.code_block_state_for(&kind));
self.writer.push_style(self.palette.code);
}
Event::End(TagEnd::CodeBlock) => {
if let BlockContext::Code(CodeBlockState::Toml { buffer }) =
std::mem::replace(&mut self.block, BlockContext::Normal(InlineContext::Normal))
{
self.writer.verbatim_toml(&buffer, &self.palette);
}
self.writer.pop_style();
self.writer.pop_prefix();
self.writer.end_block();
}
Event::Start(Tag::List(start)) => {
self.list_stack.push(ListFrame { ordinal: start });
}
Event::End(TagEnd::List(_)) => {
self.list_stack.pop();
self.writer.end_block();
}
Event::Start(Tag::Item) => {
self.writer.flush_word();
self.writer.end_line();
let frame = self
.list_stack
.last_mut()
.expect("a list item is inside a list");
let marker = match &mut frame.ordinal {
Some(index) => {
let marker = format!("{index}. ");
*index += 1;
marker
}
None => "- ".to_string(),
};
let indent = " ".repeat(display_width(&marker));
self.writer.set_item_marker(&marker);
self.writer.push_prefix(&indent);
}
Event::End(TagEnd::Item) => {
self.writer.flush_word();
self.writer.pop_prefix();
self.writer.end_line();
}
Event::Start(Tag::DefinitionList) => {}
Event::End(TagEnd::DefinitionList) => {}
Event::Start(Tag::DefinitionListTitle) => {
self.writer.end_block();
self.writer.push_style(self.palette.term);
self.block = BlockContext::Normal(InlineContext::Term);
}
Event::End(TagEnd::DefinitionListTitle) => {
self.writer.flush_word();
self.block = BlockContext::Normal(InlineContext::Normal);
self.writer.pop_style();
self.writer.end_line();
}
Event::Start(Tag::DefinitionListDefinition) => {
self.writer.push_prefix(DEFINITION_INDENT);
}
Event::End(TagEnd::DefinitionListDefinition) => {
self.writer.flush_word();
self.writer.pop_prefix();
self.writer.end_block();
}
Event::Start(Tag::Emphasis) => self.writer.push_style(self.palette.emphasis),
Event::End(TagEnd::Emphasis) => self.writer.pop_style(),
Event::Start(Tag::Strong) => self.writer.push_style(self.palette.strong),
Event::End(TagEnd::Strong) => self.writer.pop_style(),
Event::Start(Tag::Strikethrough) => self.writer.push_style(self.palette.strikethrough),
Event::End(TagEnd::Strikethrough) => self.writer.pop_style(),
Event::Start(Tag::Link { dest_url, .. }) => {
if let Some(url) = rewrite_url(&dest_url, self.site_dir) {
if self.writer.hyperlinks {
self.writer.push_style(self.palette.link);
}
self.writer.set_link(LinkTarget::Linked(url));
}
}
Event::End(TagEnd::Link) => {
if let LinkTarget::Linked(url) = self.writer.take_link() {
if self.writer.hyperlinks {
self.writer.pop_style();
} else {
self.writer.add_space();
self.writer.add_segment(&format!("<{url}>"));
}
}
}
Event::Text(text) => match &mut self.block {
BlockContext::Code(CodeBlockState::Toml { buffer }) => buffer.push_str(&text),
BlockContext::Code(CodeBlockState::Verbatim) => self.writer.verbatim(&text),
BlockContext::Normal(InlineContext::Normal | InlineContext::Term) => {
self.push_text(&text)
}
},
Event::Code(code) => {
match self.block {
BlockContext::Normal(InlineContext::Term) => {
self.writer.add_segment(&code);
}
BlockContext::Normal(InlineContext::Normal) => {
self.writer.push_style(self.palette.code);
self.writer.add_segment(&code);
self.writer.pop_style();
}
BlockContext::Code(_) => {
unreachable!("inline code events cannot occur inside code blocks")
}
}
}
Event::SoftBreak => self.writer.add_space(),
Event::HardBreak => self.writer.hard_break(),
other => self.dropped.push(format!("{other:?}")),
}
}
fn push_text(&mut self, s: &str) {
let mut run_start = 0;
let mut run_is_ws: Option<bool> = None;
for (i, c) in s.char_indices() {
let ws = c.is_whitespace();
match run_is_ws {
None => {
run_is_ws = Some(ws);
run_start = i;
}
Some(prev) if prev == ws => {}
Some(prev) => {
self.emit_run(&s[run_start..i], prev);
run_is_ws = Some(ws);
run_start = i;
}
}
}
if let Some(prev) = run_is_ws {
self.emit_run(&s[run_start..], prev);
}
}
fn emit_run(&mut self, run: &str, is_ws: bool) {
if is_ws {
self.writer.add_space();
} else {
self.writer.add_segment(run);
}
}
fn code_block_state_for(&self, kind: &CodeBlockKind) -> CodeBlockState {
if !self.writer.color {
return CodeBlockState::Verbatim;
}
let CodeBlockKind::Fenced(info) = kind else {
return CodeBlockState::Verbatim;
};
match info.split_whitespace().next() {
Some("toml") => CodeBlockState::Toml {
buffer: String::new(),
},
_ => CodeBlockState::Verbatim,
}
}
}
fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::core::NextestConfig;
use nextest_filtering::FILTERSET_REFERENCE_MD;
#[test]
fn hyperlinks_gate_osc8() {
let with = render(
&HelpDoc::filterset(),
RenderOptions {
color: false,
hyperlinks: true,
width: 80,
},
);
assert!(
with.contains("\x1b]8;;https://nexte.st/"),
"emits OSC-8 hyperlinks"
);
assert!(
!with.contains("<https://nexte.st/"),
"no inline URL fallback"
);
let without = render(
&HelpDoc::filterset(),
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(!without.contains("\x1b]8;;"), "no OSC-8 when unsupported");
assert!(
without.contains("<https://nexte.st/"),
"inline URL fallback"
);
}
#[test]
fn multi_word_link_is_one_continuous_hyperlink() {
let doc = HelpDoc {
markdown: "[click here](https://example.com/page) and more text\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: true,
width: 80,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn wrapped_link_does_not_span_a_newline() {
let doc = HelpDoc {
markdown: "[alpha beta gamma](https://example.com/x)\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: true,
width: 12,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn color_nested_styles() {
let doc = HelpDoc {
markdown: "**bold _and italic_** then `code`.\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: true,
hyperlinks: false,
width: 80,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn ordered_list_indent_matches_marker_width() {
let doc = HelpDoc {
markdown: "1. aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 20,
},
);
let lines: Vec<&str> = rendered.lines().collect();
assert!(lines[0].starts_with("1. "), "first line: {:?}", lines[0]);
for cont in &lines[1..] {
assert!(
cont.starts_with(" ") && !cont.starts_with(" "),
"continuation indented to marker width (3): {cont:?}"
);
}
}
#[test]
fn nested_list_preserves_outer_ordinal() {
let doc = HelpDoc {
markdown: "1. first\n2. second\n - nested a\n - nested b\n3. third\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn code_block_preserves_blank_lines() {
let doc = HelpDoc {
markdown: "```\nfirst\n\nsecond\n```\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(
rendered.contains("first\n\n") && rendered.contains("second"),
"interior blank line preserved: {rendered:?}"
);
}
#[test]
fn list_item_label_before_code_block_keeps_marker_on_label() {
let doc = HelpDoc {
markdown: "- **Examples**:\n ```\n retries = 3\n ```\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn blockquote_code_block_blank_line_keeps_border() {
let doc = HelpDoc {
markdown: "> ```\n> a\n>\n> b\n> ```\n".into(),
site_dir: &[],
};
let rendered = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
insta::assert_snapshot!(rendered);
}
#[test]
fn admonition_with_unicode_indent_does_not_panic() {
let doc = HelpDoc {
markdown: "!!! note\n \u{a0}body text\n".into(),
site_dir: &[],
};
let _ = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
}
#[test]
fn rewrite_url_resolves_relative_links() {
let site_dir = HelpDoc::filterset().site_dir;
assert_eq!(
rewrite_url("https://example.com/x", site_dir).as_deref(),
Some("https://example.com/x"),
);
assert_eq!(
rewrite_url("http://example.com/x", site_dir).as_deref(),
Some("http://example.com/x"),
);
assert_eq!(rewrite_url("#binary-kinds", site_dir), None);
assert_eq!(
rewrite_url("../glossary.md#binary-id", site_dir).as_deref(),
Some("https://nexte.st/docs/glossary/#binary-id"),
);
assert_eq!(
rewrite_url("../configuration/test-groups.md", site_dir).as_deref(),
Some("https://nexte.st/docs/configuration/test-groups/"),
);
assert_eq!(
rewrite_url("../selecting.md", site_dir).as_deref(),
Some("https://nexte.st/docs/selecting/"),
);
let config_dir: &[&str] = &["docs", "configuration"];
assert_eq!(
rewrite_url("index.md", config_dir).as_deref(),
Some("https://nexte.st/docs/configuration/"),
);
assert_eq!(
rewrite_url("index.md#hierarchical-configuration", config_dir).as_deref(),
Some("https://nexte.st/docs/configuration/#hierarchical-configuration"),
);
assert_eq!(
rewrite_url("../user-config/index.md", config_dir).as_deref(),
Some("https://nexte.st/docs/user-config/"),
);
assert_eq!(
rewrite_url("../filtersets/index.md", config_dir).as_deref(),
Some("https://nexte.st/docs/filtersets/"),
);
}
#[test]
#[should_panic(expected = "relative link in help doc escapes the site root")]
fn rewrite_url_panics_on_escaping_links() {
let site_dir = HelpDoc::filterset().site_dir;
rewrite_url("../../../../foo.md", site_dir);
}
#[test]
fn apply_shortcodes_handles_versions_and_comments() {
assert_eq!(
apply_shortcodes("a <!-- md:version 0.9.1 --> b"),
"a *(since 0.9.1)* b",
);
assert_eq!(apply_shortcodes("a <!-- md:version --> b"), "a b");
assert_eq!(apply_shortcodes("a <!-- md:versionfoo --> b"), "a b");
assert_eq!(apply_shortcodes("a <!-- random comment --> b"), "a b");
assert_eq!(
apply_shortcodes("a <!-- unterminated"),
"a <!-- unterminated"
);
}
#[test]
fn reference_stays_within_supported_subset() {
let markdown = FILTERSET_REFERENCE_MD;
let mut rest = markdown;
while let Some(idx) = rest.find("<!-- md:") {
let after = &rest[idx + "<!-- md:".len()..];
let name: String = after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
assert_eq!(
name, "version",
"unsupported `md:{name}` shortcode in the filterset reference; \
the CLI help renderer only handles `md:version` (see apply_shortcodes)"
);
rest = after;
}
let preprocessed = preprocess(markdown);
let rendered = render_markdown(
&preprocessed,
HelpDoc::filterset().site_dir,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(
rendered.dropped.is_empty(),
"filterset reference uses markdown the CLI help renderer silently drops: {:?}",
rendered.dropped,
);
}
#[test]
fn repo_config_reference_stays_within_supported_subset() {
let markdown = NextestConfig::REFERENCE_MD;
let mut rest = markdown;
while let Some(idx) = rest.find("<!-- md:") {
let after = &rest[idx + "<!-- md:".len()..];
let name: String = after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
assert_eq!(
name, "version",
"unsupported `md:{name}` shortcode in the repo-config reference; \
the CLI help renderer only handles `md:version` (see apply_shortcodes)"
);
rest = after;
}
let preprocessed = preprocess(markdown);
let rendered = render_markdown(
&preprocessed,
&["docs", "configuration"],
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(
rendered.dropped.is_empty(),
"repo-config reference uses markdown the CLI help renderer silently drops: {:?}",
rendered.dropped,
);
}
#[test]
fn repo_config_markdown_includes_reference_and_defaults() {
let markdown = repo_config_markdown();
assert!(
markdown.contains("# Configuration reference"),
"repo-config topic includes the reference body"
);
assert!(
markdown.contains("## Default configuration"),
"repo-config topic appends a default configuration section"
);
assert!(
markdown.contains(NextestConfig::DEFAULT_CONFIG.trim_end()),
"repo-config topic embeds the default config verbatim"
);
}
#[test]
fn user_config_reference_stays_within_supported_subset() {
let markdown = UserConfig::REFERENCE_MD;
let mut rest = markdown;
while let Some(idx) = rest.find("<!-- md:") {
let after = &rest[idx + "<!-- md:".len()..];
let name: String = after
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
assert_eq!(
name, "version",
"unsupported `md:{name}` shortcode in the user-config reference; \
the CLI help renderer only handles `md:version` (see apply_shortcodes)"
);
rest = after;
}
let preprocessed = preprocess(&user_config_markdown());
let rendered = render_markdown(
&preprocessed,
&["docs", "user-config"],
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(
rendered.dropped.is_empty(),
"user-config reference uses markdown the CLI help renderer silently drops: {:?}",
rendered.dropped,
);
}
#[test]
fn user_config_markdown_includes_reference_and_defaults() {
let markdown = user_config_markdown();
assert!(
markdown.contains("# User config reference"),
"user-config topic includes the reference body"
);
assert!(
markdown.contains("## Default configuration"),
"user-config topic appends a default configuration section"
);
assert!(
markdown.contains(UserConfig::DEFAULT_CONFIG.trim_end()),
"user-config topic embeds the default config verbatim"
);
}
#[test]
fn toml_code_block_is_highlighted() {
let doc = HelpDoc {
markdown: "```toml\n[profile.ci]\n# run all tests\nfail-fast = false\nretries = 3\nslow-timeout = \"60s\"\n```\n".into(),
site_dir: &[],
};
let colored = render(
&doc,
RenderOptions {
color: true,
hyperlinks: false,
width: 80,
},
);
insta::assert_snapshot!("toml_highlight_color", colored);
let plain = render(
&doc,
RenderOptions {
color: false,
hyperlinks: false,
width: 80,
},
);
assert!(
!plain.contains('\x1b'),
"a toml block emits no ANSI when color is off: {plain:?}"
);
}
}