use super::*;
mod secondary_parse_plan {
use super::*;
const FLAVORS: [MarkdownFlavor; 12] = crate::format_proof::ALL_FLAVORS;
fn verify_context(ctx: &LintContext<'_>) {
let legacy = if ctx.content.contains('$') {
crate::utils::parser_options::code_span_ranges_without_math(ctx.content)
} else {
Vec::new()
};
for byte in 0..=ctx.content.len() + 1 {
let expected =
ctx.is_in_code_span_byte(byte) || legacy.iter().any(|&(start, end)| start <= byte && byte < end);
assert_eq!(
ctx.is_in_code_span_byte_with_or_without_math(byte),
expected,
"{:?} at {byte}: {:?}",
ctx.flavor,
ctx.content
);
}
if !ctx.parser_has_math && ctx.flavor != MarkdownFlavor::MDX {
assert!(ctx.code_spans_without_math_cache.get().is_none());
}
}
#[test]
fn secondary_parse_plan_preserves_every_reading_and_flavor() {
for content in [
"$a`b\n",
"`$`$`\n",
"$$unclosed\n",
"| left | right |\n| --- | --- |\n| $x ` y | z` |\n",
"Costs $10 and `code` with é.\n",
"A $x `y$ and z` end.\n\nThen `w` code.\n",
"$$a `b`$$ and `c`\n",
"`$` and `$unclosed`, and escaped \\$ text\n",
"<!-- $ -->\n\n<div title=\"$\">`x`</div>\n",
"[link $ `x`](dest/$) and \n\n[ref]: /$\n",
"```sh\necho $x `expr`\n```\n",
"!!! note\n\n `$x` and `y`\n",
"<Card title={\"$\"}>\n`x` and $12\n</Card>\n",
"<span>\n `$a`\n</span>\n",
"> $ no math\r\n> `a`\r\n\r\n- `$b`\r",
"Term\n: $x and `y`\n",
] {
for flavor in FLAVORS {
for ending in ["\n", "\r\n", "\r"] {
let source = content.replace('\n', ending);
verify_context(&LintContext::new(&source, flavor, None));
}
}
}
}
#[test]
fn secondary_parse_plan_carries_math_facts_through_handoff() {
for content in ["- costs $10 and `x`\n", "- $a `b$ and c`\n", "- plain `x`\n"] {
for flavor in FLAVORS {
let (parsed, _) = document_parser::ParsedDocument::parse_for_guard(content);
let actual = LintContext::new_with_handoff(content, flavor, None, Some(parsed.into_handoff()));
let fresh = LintContext::new(content, flavor, None);
assert_eq!(actual.parser_has_math, fresh.parser_has_math);
verify_context(&actual);
}
}
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(64))]
#[test]
fn secondary_parse_plan_matches_generated_legacy_queries(
pieces in proptest::collection::vec(
proptest::sample::select(vec![
"$", "$$", "`", "``", "```", "{", "}", "[", "]", "(", ")", "<", ">", "\\",
"*", "_", "&", "a", " ", "\n", "\r", "\t", "é", "!!! note\n", "1. ", ": x\n",
"[a]: x\n", "<!--", "-->", "\"", "\n- ", "+ ",
]),
0..55,
),
) {
let content = pieces.concat();
for flavor in FLAVORS {
for ending in ["\n", "\r\n", "\r"] {
let source = content.replace('\n', ending);
verify_context(&LintContext::new(&source, flavor, None));
}
}
}
}
}
#[test]
fn cached_tag_queries_match_owned_public_views() {
let source = "# Heading\n\n<span title=\"é\">text</span>\n\n<Card\n title=\"value\">body</Card>\n\n`<tag>`\n\n<!-- <hidden> -->\n\n{::comment}\n<span>hidden</span>\n{:/comment}\n";
for flavor in [MarkdownFlavor::Standard, MarkdownFlavor::MDX, MarkdownFlavor::Kramdown] {
for jsx_first in [false, true] {
let ctx = LintContext::new(source, flavor, None);
if jsx_first {
let _ = ctx.is_in_jsx_component_tag(0);
} else {
let _ = ctx.is_in_html_tag(0);
}
let html = ctx.html_tags();
let jsx = ctx.jsx_component_tags();
for byte in 0..=source.len() + 1 {
let contains = |tags: &[HtmlTag]| tags.iter().any(|t| t.byte_offset <= byte && byte < t.byte_end);
assert_eq!(ctx.is_in_html_tag(byte), contains(&html), "{flavor:?} HTML {byte}");
assert_eq!(
ctx.is_in_jsx_component_tag(byte),
contains(&jsx),
"{flavor:?} JSX {byte}"
);
}
assert!(Arc::ptr_eq(&html, &ctx.html_tags()));
assert!(Arc::ptr_eq(&jsx, &ctx.jsx_component_tags()));
}
}
}
#[test]
fn link_target_policy_accepts_canonical_working_directory_paths() {
let base = std::env::current_dir().expect("test process should have a working directory");
let working_root = base.join("workspace");
let canonical_root = base.join("canonical-workspace");
let roots = [working_root.as_path(), canonical_root.as_path()];
let canonical_target = canonical_root.join("docs").join("b.md");
let canonical_extensionless_target = canonical_root.join("docs").join("b");
let policy = LinkTargetPolicy::from_paths_with_roots(["docs/b.md"], true, roots);
assert_eq!(
policy.resolve_supplied(&canonical_target),
Some(canonical_target.clone())
);
assert_eq!(
policy.resolve_supplied(&canonical_extensionless_target),
Some(canonical_target.clone())
);
let working_target = working_root.join("docs").join("b.md");
let absolute_policy = LinkTargetPolicy::from_paths_with_roots([working_target], true, roots);
assert!(absolute_policy.contains(&canonical_target));
}
#[cfg(unix)]
#[test]
fn link_target_policy_reads_an_absolute_path_through_a_symlinked_ancestor() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("real/docs")).unwrap();
std::fs::write(root.join("real/docs/b.md"), "").unwrap();
std::os::unix::fs::symlink(root.join("real"), root.join("alias")).unwrap();
let supplied = root.join("alias/docs/b.md");
let policy = LinkTargetPolicy::from_paths_with_roots([&supplied], true, [root.join("real")]);
assert!(policy.contains(&supplied), "the spelling as supplied");
assert!(
policy.contains(Path::new("docs/b.md")),
"the working-directory-relative spelling of the same file"
);
}
#[test]
fn link_target_policy_resolves_directories_implied_by_supplied_paths() {
let base = std::env::current_dir().expect("test process should have a working directory");
let root = base.join("workspace");
let policy = LinkTargetPolicy::from_paths_with_roots(["docs/sub/x.md"], false, [root.as_path()]);
for dir in ["docs", "docs/", "./docs", "docs/sub", "docs/sub/"] {
assert_eq!(
policy.resolve_supplied(Path::new(dir)),
Some(PathBuf::from(dir.trim_start_matches("./").trim_end_matches('/'))),
"{dir} holds a supplied path"
);
}
assert_eq!(
policy.resolve_supplied(&root.join("docs")),
Some(root.join("docs")),
"the root-joined spelling of a directory resolves like the relative one"
);
assert_eq!(
policy.resolve_supplied(&root),
Some(root.clone()),
"the working root holds every supplied path"
);
assert_eq!(
policy.resolve_supplied(&base),
None,
"the working root's parent holds the batch but is not part of it"
);
for missing in ["doc", "doc/", "docs/su", "nope", "docs/sub/x.md/extra"] {
assert_eq!(policy.resolve_supplied(Path::new(missing)), None, "{missing}");
}
assert!(
!policy.contains(Path::new("docs")),
"a directory resolves as a link target but is not a supplied document"
);
}
#[test]
fn link_target_policy_bounds_directories_of_paths_outside_the_working_root() {
let base = std::env::current_dir().expect("test process should have a working directory");
let root = base.join("workspace");
let outside = base.join("elsewhere").join("site");
let policy = LinkTargetPolicy::from_paths_with_roots(
[outside.join("a.md"), outside.join("docs").join("x.md")],
false,
[root.as_path()],
);
assert_eq!(
policy.resolve_supplied(&outside.join("docs")),
Some(outside.join("docs"))
);
assert_eq!(
policy.resolve_supplied(&outside),
Some(outside.clone()),
"the deepest directory holding every outside path is their root"
);
assert_eq!(policy.resolve_supplied(&base.join("elsewhere")), None);
assert_eq!(policy.resolve_supplied(&base), None);
}
#[test]
fn link_target_policy_bounds_directories_of_relative_paths_above_the_working_root() {
let base = std::env::current_dir().expect("test process should have a working directory");
let root = base.join("workspace");
let policy = LinkTargetPolicy::from_paths_with_roots(
["../elsewhere/site/a.md", "../elsewhere/site/docs/x.md"],
false,
[root.as_path()],
);
assert_eq!(
policy.resolve_supplied(Path::new("../elsewhere/site/docs")),
Some(PathBuf::from("../elsewhere/site/docs"))
);
assert_eq!(
policy.resolve_supplied(Path::new("../elsewhere/site")),
Some(PathBuf::from("../elsewhere/site"))
);
assert_eq!(policy.resolve_supplied(Path::new("../elsewhere")), None);
assert_eq!(policy.resolve_supplied(Path::new("..")), None);
assert_eq!(policy.resolve_supplied(&base.join("elsewhere")), None);
assert_eq!(policy.resolve_supplied(&base), None);
}
#[test]
fn link_target_policy_prefers_a_supplied_document_over_a_same_named_directory() {
let policy = LinkTargetPolicy::from_paths_with_roots(["docs.md", "docs/b.md"], false, Vec::<PathBuf>::new());
assert_eq!(
policy.resolve_supplied(Path::new("docs")),
Some(PathBuf::from("docs.md"))
);
}
#[test]
fn test_empty_content() {
let ctx = LintContext::new("", MarkdownFlavor::Standard, None);
assert_eq!(ctx.content, "");
assert_eq!(ctx.line_offsets, vec![0]);
assert_eq!(ctx.offset_to_line_col(0), (1, 1));
assert_eq!(ctx.lines.len(), 0);
}
#[test]
fn test_single_line() {
let ctx = LintContext::new("# Hello", MarkdownFlavor::Standard, None);
assert_eq!(ctx.content, "# Hello");
assert_eq!(ctx.line_offsets, vec![0]);
assert_eq!(ctx.offset_to_line_col(0), (1, 1));
assert_eq!(ctx.offset_to_line_col(3), (1, 4));
}
#[test]
fn only_a_commonmark_atx_opening_records_a_heading() {
let heading_level = |content: &str| {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let line = &ctx.lines[0];
(
line.heading.as_ref().map(|heading| heading.level),
line.atx_missing_space,
)
};
let missing = |level| Some(AtxMissingSpace { level });
assert_eq!(heading_level("# Title\n"), (Some(1), None));
assert_eq!(heading_level("#\tTitle\n"), (Some(1), None));
assert_eq!(heading_level("#\n"), (Some(1), None));
assert_eq!(heading_level("###### Six\n"), (Some(6), None));
assert_eq!(heading_level("#Title\n"), (None, missing(1)));
assert_eq!(heading_level("#title\n"), (None, missing(1)));
assert_eq!(heading_level("##x\n"), (None, missing(2)));
assert_eq!(heading_level("#\u{a0}Title\n"), (None, missing(1)));
assert_eq!(heading_level("\u{a0}# Title\n"), (None, None));
assert_eq!(heading_level("#######\n"), (None, None));
assert_eq!(heading_level("#######x\n"), (None, None));
}
#[test]
fn a_blockquote_atx_heading_may_be_empty() {
for (content, level) in [("> #\n", 1), (">> ##\n", 2), ("> # \n", 1)] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(1).map(|parsed| parsed.heading);
assert_eq!(heading.map(|heading| heading.level), Some(level), "{content:?}");
assert_eq!(heading.map(|heading| heading.text.as_str()), Some(""), "{content:?}");
}
let ctx = LintContext::new("> #x\n", MarkdownFlavor::Standard, None);
assert!(ctx.heading_on_line(1).is_none());
}
#[test]
fn a_no_space_line_is_setext_heading_text() {
let ctx = LintContext::new("#hashtag\n---\n", MarkdownFlavor::Standard, None);
let heading = ctx.lines[0].heading.as_deref().expect("setext heading");
assert_eq!((heading.level, heading.text.as_str()), (2, "#hashtag"));
assert!(matches!(heading.style, HeadingStyle::Setext2));
assert_eq!(ctx.lines[0].atx_missing_space, None);
let ctx = LintContext::new("#Hashtag\nmore\n===\n", MarkdownFlavor::Standard, None);
let heading = ctx.lines[1].heading.as_deref().expect("setext heading");
assert_eq!((heading.level, heading.text_lines), (1, 2));
assert!(ctx.lines[0].is_setext_heading_text);
assert_eq!(ctx.lines[0].atx_missing_space, None);
}
#[test]
fn a_no_space_line_continues_a_list_item() {
let ctx = LintContext::new("- a\n#Todo\n- b\n", MarkdownFlavor::Standard, None);
assert!(ctx.lines[1].heading.is_none());
assert_eq!(ctx.list_blocks.len(), 1, "{:?}", ctx.list_blocks);
let control = LintContext::new("- a\n# todo\n- b\n", MarkdownFlavor::Standard, None);
assert_eq!(control.list_blocks.len(), 2, "{:?}", control.list_blocks);
}
#[test]
fn parsed_headings_cover_document_and_blockquote_semantics() {
let content = "# Top\nSetext {#setext}\n=====\n> ## Quoted ## {#quoted}\n>> ### Nested\n#lowercase\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 4);
assert_eq!(
headings
.iter()
.map(|parsed| (parsed.line_num, parsed.heading.text.as_str(), parsed.blockquote_depth))
.collect::<Vec<_>>(),
vec![(1, "Top", 0), (2, "Setext", 0), (4, "Quoted", 1), (5, "Nested", 2),]
);
assert!(headings[1].is_setext());
assert!(headings[2].is_blockquote());
assert_eq!(headings[2].heading.custom_id.as_deref(), Some("quoted"));
assert!(headings[2].heading.has_closing_sequence);
assert_eq!(headings[2].heading.closing_sequence, "##");
assert_eq!(headings[2].text_byte_range(content), 34..40);
assert_eq!(&content[headings[2].text_byte_range(content)], "Quoted");
assert_eq!(&content[headings[1].text_byte_range(content)], "Setext");
assert!(ctx.lines[5].heading.is_none());
assert_eq!(ctx.lines[5].atx_missing_space, Some(AtxMissingSpace { level: 1 }));
assert!(
headings
.iter()
.all(|heading| heading.heading.text_lines == 1 && heading.first_line_num() == heading.line_num)
);
assert!(std::ptr::eq(headings[0].first_line_info(), headings[0].line_info));
let top_level_valid_lines: Vec<_> = ctx.valid_headings().map(|heading| heading.line_num).collect();
assert_eq!(top_level_valid_lines, vec![1, 2]);
assert_eq!(ctx.heading_on_line(4).map(|heading| heading.blockquote_depth), Some(1));
assert!(ctx.heading_on_line(3).is_none());
assert!(ctx.heading_on_line(0).is_none());
}
#[test]
fn parsed_headings_exclude_non_markdown_regions() {
let content =
"---\ntitle: '# Front matter'\n---\n\n```md\n> ## Code\n```\n\n<div>\n> ## Raw HTML\n</div>\n\n# Visible\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx
.headings()
.map(|heading| (heading.line_num, heading.heading.text.as_str()))
.collect();
assert_eq!(headings, vec![(13, "Visible")]);
}
#[test]
fn parsed_blockquote_headings_include_markdown_enabled_html() {
let content = "<div markdown>\n> ## Rendered\n</div>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx
.headings()
.map(|heading| {
(
heading.line_num,
heading.heading.text.as_str(),
heading.blockquote_depth,
)
})
.collect();
assert_eq!(headings, vec![(2, "Rendered", 1)]);
}
#[test]
fn parsed_blockquote_headings_respect_mkdocs_snippet_markers() {
let content = "> # -8<- [start:section]\n> # Actual\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let headings: Vec<_> = ctx
.headings()
.map(|heading| (heading.line_num, heading.heading.text.as_str()))
.collect();
assert_eq!(headings, vec![(2, "Actual")]);
}
#[test]
fn parsed_blockquote_headings_exclude_opaque_bodies_and_comments() {
for (content, flavor) in [
("$$\n> # Equation\n$$\n", MarkdownFlavor::Standard),
("%%\n> # Hidden\n%%\n", MarkdownFlavor::Obsidian),
("> <!--\n> # Hidden\n> -->\n", MarkdownFlavor::Standard),
("Text <!--\n> # Hidden\n-->\n", MarkdownFlavor::Standard),
] {
let ctx = LintContext::new(content, flavor, None);
assert_eq!(ctx.headings().count(), 0, "{flavor:?} {content:?}");
}
let ctx = LintContext::new("> # Shown\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.headings().count(), 1);
}
#[test]
fn parsed_blockquote_headings_exclude_indented_paragraph_continuations() {
for content in [
"> paragraph\n> ## B\n",
">> paragraph\n>> ## B\n",
"- item\n\n > paragraph\n > ## B\n",
"> o\n> > # x\n",
"> > o\n> > > # x\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.headings().count(), 0, "{content:?}");
}
for content in ["> paragraph\n> ## B\n", "> paragraph\n>\n> ## B\n"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.headings().count(), 1, "{content:?}");
}
let ctx = LintContext::new("> paragraph\n>\n> ## B\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.headings().count(), 0);
}
fn setext_headings(content: &str, flavor: MarkdownFlavor) -> Vec<(usize, String, u8, usize)> {
LintContext::new(content, flavor, None)
.headings()
.filter(super::types::ParsedHeading::is_setext)
.map(|heading| {
(
heading.line_num,
heading.heading.text.clone(),
heading.heading.level,
heading.blockquote_depth,
)
})
.collect()
}
#[test]
fn setext_text_line_is_any_paragraph_text() {
for (content, text, level) in [
("*Label*\n===\n", "*Label*", 1),
("**Practice**\n---\n", "**Practice**", 2),
("+x\n===\n", "+x", 1),
("-x\n---\n", "-x", 2),
("2024 plans\n===\n", "2024 plans", 1),
("<span>Title</span>\n===\n", "<span>Title</span>", 1),
("<https://example.com>\n---\n", "<https://example.com>", 2),
("===\n---\n", "===", 2),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(1, text.to_string(), level, 0)],
"{content:?}"
);
}
assert_eq!(
setext_headings("Intro\n2. foo\n===\n", MarkdownFlavor::Standard),
vec![(2, "Intro 2. foo".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("Title\n===\n---\n", MarkdownFlavor::Standard),
vec![(1, "Title".to_string(), 1, 0)]
);
}
#[test]
fn setext_underline_inside_an_html_block_is_html() {
for content in [
"<span>\n===\n",
"Intro\n\n<span class=\"x\">\n---\n",
"</span>\n===\n",
"<span/>\n===\n",
" <a href=\"x\">\n===\n",
"<span>\nTitle\n===\n",
"<span>\n> Title\n> ===\n",
"> <span>\n> Title\n> ===\n",
"> <div>\n> Title\n> ===\n",
"> <pre>\n> Title\n> ===\n",
"- <span>\n Title\n ===\n",
"- <div>\n Title\n ===\n",
"[^a]: <span>\n Title\n ===\n\nRef[^a]\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
for (content, line, text, depth) in [
("<span> x\n===\n", 1, "<span> x", 0),
("<span>\n\nTitle\n===\n", 3, "Title", 0),
("> <span>\n\n> Title\n> ===\n", 3, "Title", 1),
("<div>\n- a\n\n Title\n===\n", 4, "Title", 0),
("---\nhtml: |\n\n <span>\n---\nTitle\n===\n", 6, "Title", 0),
("<pre>\n</pre>\n10. <div>\n\n Title\n ===\n", 5, "Title", 0),
("> <span>\nTitle\n===\n", 2, "Title", 0),
("- <span>\nTitle\n===\n", 2, "Title", 0),
("[^a]: <span>\nTitle\n===\n\nRef[^a]\n", 2, "Title", 0),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(line, text.to_string(), 1, depth)],
"{content:?}"
);
}
assert_eq!(
setext_headings("Intro\n<span>\n===\n", MarkdownFlavor::Standard).len(),
1
);
let content = "%%\n\n<span>\n%%\nTitle\n===\n";
assert_eq!(
setext_headings(content, MarkdownFlavor::Obsidian),
vec![(5, "Title".to_string(), 1, 0)]
);
assert!(setext_headings(content, MarkdownFlavor::Standard).is_empty());
let content = "::: note\n\n<span>\n:::\nTitle\n===\n";
assert_eq!(
setext_headings(content, MarkdownFlavor::AzureDevOps),
vec![(5, "Title".to_string(), 1, 0)]
);
assert!(setext_headings(content, MarkdownFlavor::Standard).is_empty());
}
#[test]
fn setext_underline_below_a_list_item_is_paragraph_text() {
for content in [
"- item\n===\n",
"* * x\n===\n",
"1) item\n===\n",
"Intro\n1. foo\n===\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
}
#[test]
fn empty_list_item_under_a_paragraph_opens_nothing() {
for (content, line, depth) in [
("Title\n- \n", 1, 0),
("Title\n-\t\n", 1, 0),
("Title\n - \n", 1, 0),
("Title\n- \n x\n", 1, 0),
("> Title\n> - \n", 1, 1),
("- a\n\n Title\n - \n", 3, 0),
("[^a]: intro\n\n Title\n - \n\nRef[^a]\n", 3, 0),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(line, "Title".to_string(), 2, depth)],
"{content:?}"
);
}
for content in [
"Title\n* \n",
"Title\n+ \n",
"Title\n1. \n",
"Title\n1) \n",
"Title\n- - \n",
"Title\n\n- \n",
"- Title\n- \n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
assert_eq!(
setext_headings("Title\n* \n===\n", MarkdownFlavor::Standard),
vec![(2, "Title *".to_string(), 1, 0)]
);
}
#[test]
fn setext_heading_text_is_the_whole_paragraph() {
let content = "Intro.\n\nFirst line\nsecond line\n===\n\nBody.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1);
let heading = &headings[0];
assert_eq!((heading.first_line_num(), heading.line_num), (3, 4));
assert_eq!(heading.heading.text_lines, 2);
assert_eq!(heading.heading.text, "First line second line");
assert_eq!(heading.heading.raw_text, "First line second line");
assert_eq!(heading.heading.slug_text, "First line second line");
assert_eq!(heading.heading.content_column, 0);
assert_eq!(heading.first_line_info().byte_offset, 8);
assert_eq!(heading.line_info.byte_offset, 19);
assert_eq!(&content[heading.text_byte_range(content)], "First line\nsecond line");
let text_lines: Vec<bool> = ctx.lines.iter().map(|line| line.is_setext_heading_text).collect();
assert_eq!(text_lines, vec![false, false, true, true, false, false, false]);
assert!(
ctx.lines[2].heading.is_none(),
"the heading is recorded on the last line"
);
assert!(ctx.heading_on_line(3).is_none());
assert_eq!(ctx.heading_on_line(4).map(|heading| heading.first_line_num()), Some(3));
let valid: Vec<_> = ctx
.valid_headings()
.map(|heading| {
(
heading.first_line_num(),
heading.line_num,
heading.first_line_info().byte_offset,
)
})
.collect();
assert_eq!(valid, vec![(3, 4, 8)]);
assert_eq!(
setext_headings("One\ntwo\nthree\n---\n", MarkdownFlavor::Standard),
vec![(3, "One two three".to_string(), 2, 0)]
);
assert_eq!(
setext_headings("Foo\\\nBar\n===\n", MarkdownFlavor::Standard),
vec![(2, "Foo Bar".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("Foo \nBar\n===\n", MarkdownFlavor::Standard),
vec![(2, "Foo Bar".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("Foo\\\\\nBar\n===\n", MarkdownFlavor::Standard),
vec![(2, "Foo\\\\ Bar".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("1. a\n\n First\nsecond\n ===\n", MarkdownFlavor::Standard),
vec![(4, "First second".to_string(), 1, 0)]
);
let content = " First\nsecond {#custom}\n===\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(2).expect("a heading on the last text line");
assert_eq!(heading.heading.text, "First second");
assert_eq!(heading.heading.custom_id.as_deref(), Some("custom"));
assert_eq!(heading.heading.content_column, 2);
assert_eq!(&content[heading.text_byte_range(content)], "First\nsecond");
}
#[test]
fn link_reference_definitions_above_the_text_stay_outside_the_heading() {
let content = "[ref]: /url\nHeading\n=======\n\n[ref]\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1);
assert_eq!((headings[0].first_line_num(), headings[0].line_num), (2, 2));
assert_eq!(headings[0].heading.text, "Heading");
assert_eq!(headings[0].heading.text_lines, 1);
assert_eq!(&content[headings[0].text_byte_range(content)], "Heading");
let text_lines: Vec<bool> = ctx.lines.iter().map(|line| line.is_setext_heading_text).collect();
assert_eq!(text_lines, vec![false, true, false, false, false]);
for (content, expected) in [
(
"[a]: /a\n[b]: /b \"t\"\nHeading\n===\n",
vec![(3, "Heading".to_string(), 1, 0)],
),
(
"[a]: /a\n [b]: /b\nHeading\n===\n",
vec![(3, "Heading".to_string(), 1, 0)],
),
(
"[foo]: /url\n\"title\"\nHeading\n===\n",
vec![(3, "Heading".to_string(), 1, 0)],
),
(
"[foo]: /url\n\"title\" ok\nHeading\n===\n",
vec![(3, "\"title\" ok Heading".to_string(), 1, 0)],
),
("[foo]:\n/url\nTitle\n===\n", vec![(3, "Title".to_string(), 1, 0)]),
("[foo]:\n/url \"t\"\nTitle\n===\n", vec![(3, "Title".to_string(), 1, 0)]),
(
"[foo]:\n/url\n\"t\"\nTitle\n===\n",
vec![(4, "Title".to_string(), 1, 0)],
),
(
"[foo]:\n /url\n \"t\"\nTitle\n===\n",
vec![(4, "Title".to_string(), 1, 0)],
),
("[foo]:\n<a b>\nTitle\n===\n", vec![(3, "Title".to_string(), 1, 0)]),
("[a]: /a\n[b]:\n/b\nTitle\n===\n", vec![(4, "Title".to_string(), 1, 0)]),
(
"[foo]:\n/url\n\"t\" ok\nTitle\n===\n",
vec![(4, "\"t\" ok Title".to_string(), 1, 0)],
),
(
"[foo]:\n/url \"t\" ok\nTitle\n===\n",
vec![(3, "[foo]: /url \"t\" ok Title".to_string(), 1, 0)],
),
("[foo]:\n===\n", vec![(1, "[foo]:".to_string(), 1, 0)]),
("[foo]:\n/url\n\"t\"\n===\n", vec![]),
("[foo]:\n/url\n===\nBar\n---\n", vec![(4, "=== Bar".to_string(), 2, 0)]),
(
"- [foo]: /url\n Heading\n ===\n",
vec![(2, "Heading".to_string(), 1, 0)],
),
(
"> [ref]: /url\n> Heading\n> ===\n",
vec![(2, "Heading".to_string(), 1, 1)],
),
("[foo]: /url\n===\n[foo]\n", vec![]),
("[foo]: /url\n---\n", vec![]),
("[foo]: /url\n===\nBar\n---\n", vec![(3, "=== Bar".to_string(), 2, 0)]),
("[foo]: /url\n---\nBar\n===\n", vec![(3, "--- Bar".to_string(), 1, 0)]),
("[^a]: note\nHeading\n===\n", vec![]),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
expected,
"{content:?}"
);
}
}
#[test]
fn a_hard_break_backslash_goes_with_the_line_ending_it_marks() {
for (content, text) in [
("Foo\\\nBar\n===\n", "Foo Bar"),
("Foo\\\r\nBar\r\n===\r\n", "Foo Bar"),
("Foo `a\\\nb` tail\n===\n", "Foo `a\\ b` tail"),
("Foo\\ \nBar\n===\n", "Foo\\ Bar"),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(2, text.to_string(), 1, 0)],
"{content:?}"
);
}
}
#[test]
fn the_text_range_ends_where_the_display_text_ends() {
for (content, text, source_text) in [
("Title.\n{#id}\n===\n", "Title.", "Title."),
("Title.\n<a name=\"x\"></a>\n===\n", "Title.", "Title."),
("Title.\nmore {#id}\n===\n", "Title. more", "Title.\nmore"),
("> Title.\n> {#id}\n> ===\n", "Title.", "Title."),
("# Foo. ##\n", "Foo.", "Foo."),
("# Foo ## {#id}\n", "Foo", "Foo"),
("# Title. <a id=\"y\"></a>\n", "Title.", "Title."),
("# Foo <a id=\"x\"></a> ##\n", "Foo", "Foo"),
("# <a id=\"x\"></a> Foo\n", "Foo", "Foo"),
(
"# Foo <a id=\"\u{1f4a5}\"></a> bar.\n",
"Foo bar.",
"Foo <a id=\"\u{1f4a5}\"></a> bar.",
),
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.headings().next().expect("a heading");
assert_eq!(heading.heading.text, text, "{content:?}");
assert_eq!(&content[heading.text_byte_range(content)], source_text, "{content:?}");
}
}
#[test]
fn setext_headings_below_a_block_inside_a_container() {
for (content, line, depth) in [
("10. item\n\n ```\n code\n ```\n\n Title\n ===\n", 7, 0),
("10. item\n ```\n code\n ```\n Title\n ===\n", 5, 0),
("10. ```\n code\n ```\n\n Title\n ===\n", 5, 0),
("10. item\n\n code\n\n Title\n ===\n", 5, 0),
("10. item\n\n <div>\n x\n </div>\n\n Title\n ===\n", 7, 0),
("- item\n\n ```\n code\n ```\n\n Title\n ===\n", 7, 0),
(
"[^a]: intro\n\n ```\n code\n ```\n\n Title\n ===\n\nRef[^a]\n",
7,
0,
),
(
"> 10. a\n>\n> ```\n> c\n> ```\n>\n> Title\n> ===\n",
7,
1,
),
("```\n- a\n ```\n\n Title\n===\n", 5, 0),
("~~~\nx\n~~~\n10. ```\n c\n ```\n\n Title\n ===\n", 8, 0),
("~~~\nx\n~~~\n10. ```\n - a\n ```\n\n Title\n ===\n", 8, 0),
(" x\n10. ```\n c\n ```\n\n Title\n ===\n", 6, 0),
(
"~~~\nx\n~~~\n> 10. ```\n> c\n> ```\n>\n> Title\n> ===\n",
8,
1,
),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(line, "Title".to_string(), 1, depth)],
"{content:?}"
);
}
for content in [
"10. item\n\n ```\n code\n ```\n\n Title\n===\n",
"~~~\nx\n~~~\n- ```\n c\n ```\n\n Title\n===\n",
"10. item\n\n ```\n code\n ```\n\n Title\n ===\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
}
#[test]
fn setext_underline_indent_is_limited_to_three_spaces() {
let heading = vec![(1, "Foo".to_string(), 1, 0)];
assert_eq!(setext_headings("Foo\n ===\n", MarkdownFlavor::Standard), heading);
assert!(setext_headings("Foo\n ===\n", MarkdownFlavor::Standard).is_empty());
assert!(setext_headings("- item\n\n Heading\n\t ===\n", MarkdownFlavor::Standard).is_empty());
assert_eq!(setext_headings("Foo\n ===\n", MarkdownFlavor::MDX), heading);
assert_eq!(
setext_headings("!!! note\n Foo\n ===\n", MarkdownFlavor::MkDocs),
vec![(2, "Foo".to_string(), 1, 0)]
);
}
#[test]
fn container_marker_lines_hold_no_setext_text() {
for (content, flavor, line, text, text_lines) in [
("!!! note\n Foo\n ===\n", MarkdownFlavor::MkDocs, 2, "Foo", 1),
(
"!!! note\n First\n second\n ===\n",
MarkdownFlavor::MkDocs,
3,
"First second",
2,
),
(
"!!! note\n !!! tip\n Foo\n ===\n",
MarkdownFlavor::MkDocs,
3,
"Foo",
1,
),
("=== \"Tab\"\n Foo\n ===\n", MarkdownFlavor::MkDocs, 2, "Foo", 1),
("/// note\nFoo\n===\n///\n", MarkdownFlavor::MkDocs, 2, "Foo", 1),
(
"/// outer\n/// inner\nFoo\n///\nBar\n===\n///\n",
MarkdownFlavor::MkDocs,
5,
"Bar",
1,
),
(":::{note}\nFoo\n===\n:::\n", MarkdownFlavor::MyST, 2, "Foo", 1),
(
"::::{note}\n:::{tip}\nFoo\n:::\nBar\n===\n::::\n",
MarkdownFlavor::MyST,
5,
"Bar",
1,
),
("::: {.note}\nFoo\n===\n:::\n", MarkdownFlavor::Pandoc, 2, "Foo", 1),
] {
let ctx = LintContext::new(content, flavor, None);
let headings: Vec<_> = ctx
.headings()
.filter(super::types::ParsedHeading::is_setext)
.map(|heading| {
(
heading.line_num,
heading.heading.text.clone(),
heading.heading.text_lines,
)
})
.collect();
assert_eq!(headings, vec![(line, text.to_string(), text_lines)], "{content:?}");
}
for (content, flavor, markers) in [
(
"!!! note\n Foo\n ===\n",
MarkdownFlavor::MkDocs,
vec![true, false, false],
),
("=== \"Tab\"\n Foo\n", MarkdownFlavor::MkDocs, vec![true, false]),
("/// note\nFoo\n///\n", MarkdownFlavor::MkDocs, vec![true, false, true]),
(":::{note}\nFoo\n:::\n", MarkdownFlavor::MyST, vec![true, false, true]),
(
"/// note\n```\n///\n```\n",
MarkdownFlavor::MkDocs,
vec![true, false, false, false],
),
(
"::: {.note}\nFoo\n:::\n",
MarkdownFlavor::Pandoc,
vec![false, false, false],
),
("!!! note\n Foo\n", MarkdownFlavor::Standard, vec![false, false]),
] {
let ctx = LintContext::new(content, flavor, None);
let flags: Vec<bool> = ctx.lines.iter().map(|line| line.is_container_marker).collect();
assert_eq!(flags, markers, "{content:?}");
}
}
#[test]
fn setext_headings_inside_footnote_definitions() {
for (content, line, level) in [
("[^a]: intro\n\n Heading\n ===\n", 3, 1),
("[^a]: intro\n\n Heading\n ===\n", 3, 1),
("[^a]: intro\n\n Heading\n ---\n", 3, 2),
("[^a]: intro\n\n\tHeading\n\t===\n", 3, 1),
("[^a]: intro\n\n Heading\n\t===\n", 3, 1),
(" [^a]: intro\n\n Heading\n ===\n", 3, 1),
("- [^a]: intro\n\n Heading\n ===\n", 3, 1),
("Para\n[^a]: intro\n\n Heading\n ===\n", 4, 1),
("[^a]: intro\n\n [^b]: x\n\n Heading\n ===\n", 5, 1),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(line, "Heading".to_string(), level, 0)],
"{content:?}"
);
}
assert_eq!(
setext_headings("[^a[b]: Heading\n===\n", MarkdownFlavor::Standard),
vec![(1, "[^a[b]: Heading".to_string(), 1, 0)]
);
for content in [
">\t[^a]: intro\n>\n>\t Heading\n>\t ===\n",
">\t[^a]: intro\n>\n>\t Heading\n>\t ===\n",
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(3, "Heading".to_string(), 1, 1)],
"{content:?}"
);
}
for content in [
"[^a]: Heading\n ===\n",
"> [^a]: Heading\n> ===\n",
"[^a]: Heading\n===\n",
"[^a]: intro\n\n Heading\n===\n",
"[^a]: intro\n\n Heading\n ===\n",
" [^a]: intro\n\n Heading\n ===\n",
"[^a]: intro\n\n Heading\n ===\n",
"[^a]: intro\n\n\tHeading\n\t ===\n",
">\t[^a]: intro\n>\n>\t Heading\n>\t ===\n",
"[^a]: intro\n\n [^b]: x\n Heading\n ===\n",
"[^a]: intro\n\n [^b]: x\n\n Heading\n ===\n",
"[^a]: intro\n\nHeading\n ===\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
}
#[test]
fn setext_headings_after_a_blank_line_inside_a_blockquote() {
for (content, line, depth) in [
("> - intro\n>\n> Heading\n> ===\n", 3, 1),
("> [^a]: intro\n>\n> Heading\n> ===\n", 3, 1),
("> - a\n> > b\n>\n> Heading\n> ===\n", 4, 1),
("> - a\n> > b\n>\n> > Heading\n> > ===\n", 4, 2),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(line, "Heading".to_string(), 1, depth)],
"{content:?}"
);
}
assert!(
setext_headings(
"> - intro\n>\n> Heading\n> ===\n",
MarkdownFlavor::Standard
)
.is_empty()
);
}
#[test]
fn setext_headings_inside_blockquotes() {
let content = "> Title\n> ===\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1);
let heading = &headings[0];
assert_eq!((heading.line_num, heading.blockquote_depth), (1, 1));
assert_eq!(heading.heading.style, HeadingStyle::Setext1);
assert_eq!(heading.heading.text, "Title");
assert_eq!(heading.heading.marker, "===");
assert_eq!((heading.heading.marker_column, heading.heading.content_column), (2, 2));
assert_eq!(heading.text_byte_range(content), 2..7);
assert!(
ctx.lines[0].heading.is_none(),
"a quoted heading is not a top-level one"
);
assert_eq!(
setext_headings("> First\n> second\n> ===\n", MarkdownFlavor::Standard),
vec![(2, "First second".to_string(), 1, 1)]
);
assert_eq!(
setext_headings("> First\nmiddle\n> last\n> ===\n", MarkdownFlavor::Standard),
vec![(3, "First middle last".to_string(), 1, 1)]
);
let content = "> First\n> second\n> ===\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(2).expect("a heading on the last text line");
assert_eq!((heading.first_line_num(), heading.heading.text_lines), (1, 2));
assert_eq!(&content[heading.text_byte_range(content)], "First\n> second");
assert!(
ctx.lines.iter().all(|line| !line.is_setext_heading_text),
"a quoted heading is reported through headings(), not per line"
);
assert_eq!(
setext_headings("> - item\n> ---\n", MarkdownFlavor::Standard),
vec![(1, "item".to_string(), 2, 1)]
);
assert_eq!(
setext_headings("- First\n second\n ===\n", MarkdownFlavor::Standard),
vec![(2, "First second".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("> > Deep\n> > ---\n", MarkdownFlavor::Standard),
vec![(1, "Deep".to_string(), 2, 2)]
);
assert_eq!(
setext_headings("> Title\n> ===\n", MarkdownFlavor::Standard),
vec![(1, "Title".to_string(), 1, 1)]
);
for content in [">\tTitle\n>\t ===\n", " > Title\n >\t ===\n"] {
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(1, "Title".to_string(), 1, 1)],
"{content:?}"
);
}
assert_eq!(
setext_headings("> > Title\n> >\t ===\n", MarkdownFlavor::Standard),
vec![(1, "Title".to_string(), 1, 2)]
);
for content in [
"> Title\n>\t ===\n",
">\t> Title\n>\t>\t ===\n",
"> Title\n>\t\t===\n",
"> > Deep\n> ---\n",
"> Quoted\n===\n",
"> Quoted\n---\n",
"> Title\n> ===\n",
"> Foo\n> ===\n",
"> First\nsecond\n> ===\n",
"> > First\n> second\n> > ===\n",
"> - a\nb\n> ---\n",
"- > a\n b\n > ---\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}"
);
}
}
#[test]
fn setext_underline_and_mdx_jsx_elements() {
assert!(setext_headings("Text\n<Card />\n===\n", MarkdownFlavor::MDX).is_empty());
assert_eq!(
setext_headings("Para\n<Card />\nText\n===\n", MarkdownFlavor::MDX),
vec![(3, "Text".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("Heading <Badge>new</Badge>\n---\n", MarkdownFlavor::MDX),
vec![(1, "Heading <Badge>new</Badge>".to_string(), 2, 0)]
);
assert_eq!(
setext_headings("<Card>\ntext\n===\n</Card>\n", MarkdownFlavor::MDX),
vec![(2, "text".to_string(), 1, 0)]
);
assert_eq!(
setext_headings("<Card\n title=\"x\"\n>\ntext\n===\n</Card>\n", MarkdownFlavor::MDX),
vec![(4, "text".to_string(), 1, 0)]
);
for content in [
"<Card>{x}\ntext\n===\n</Card>\n",
"<Card><Inner />\ntext\n===\n</Card>\n",
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::MDX),
vec![(2, "text".to_string(), 1, 0)],
"{content:?}"
);
}
for content in [
"<Outer>\n<Inner>\ntext\n</Inner>\n===\n</Outer>\n",
"<Outer>\ntext\n<Inner />\n===\n</Outer>\n",
"<Card>{x}\n===\n</Card>\n",
"- <Card />\n text\n===\n",
] {
assert!(setext_headings(content, MarkdownFlavor::MDX).is_empty(), "{content:?}");
}
assert_eq!(
setext_headings("- <Card />\n text\n ===\n", MarkdownFlavor::MDX),
vec![(2, "text".to_string(), 1, 0)]
);
}
#[test]
fn setext_underline_below_an_mdx_flow_expression() {
for content in [
"{x}\n===\n",
" {x}\n===\n",
"Intro\n\n{\n x\n}\n===\n",
"{[\n 1,\n]}\n===\n",
"{/* c */}\n===\n",
"text\n{x}\n===\n",
"> {x}\n> ===\n",
"<Card>\n{x}\n===\n</Card>\n",
"{x}<Card />\n===\n",
"- {x}\n text\n===\n",
] {
assert!(setext_headings(content, MarkdownFlavor::MDX).is_empty(), "{content:?}");
}
for (content, line, text) in [
("{x}\ntext\n===\n", 2, "text"),
("<Card>\n{x}\ntext\n===\n</Card>\n", 3, "text"),
("<Card>{x}\n{y}</Card>\ntext\n===\n", 3, "text"),
("- {x}\n text\n ===\n", 2, "text"),
("{x} and text\n===\n", 1, "{x} and text"),
("{x} {y}\n===\n", 1, "{x} {y}"),
] {
assert_eq!(
setext_headings(content, MarkdownFlavor::MDX),
vec![(line, text.to_string(), 1, 0)],
"{content:?}"
);
}
}
#[test]
fn setext_underline_below_a_colon_fence_depends_on_the_flavor() {
let content = "::: note\n---\n\n# H\n";
assert!(setext_headings(content, MarkdownFlavor::Pandoc).is_empty());
assert_eq!(
setext_headings(content, MarkdownFlavor::Standard),
vec![(1, "::: note".to_string(), 2, 0)]
);
}
#[test]
fn test_multi_line() {
let content = "# Title\n\nSecond line\nThird line";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.line_offsets, vec![0, 8, 9, 21]);
assert_eq!(ctx.offset_to_line_col(0), (1, 1)); assert_eq!(ctx.offset_to_line_col(8), (2, 1)); assert_eq!(ctx.offset_to_line_col(9), (3, 1)); assert_eq!(ctx.offset_to_line_col(15), (3, 7)); assert_eq!(ctx.offset_to_line_col(21), (4, 1)); }
#[test]
fn test_is_blank_follows_commonmark_blank_line() {
let content = "a\n \t \n\u{a0}\n\u{2003}\n> \n> \u{a0}\n\u{3000}b\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let blank: Vec<bool> = ctx.lines.iter().map(|l| l.is_blank).collect();
assert_eq!(blank[..7], [false, true, false, false, true, false, false]);
}
#[test]
fn test_line_info() {
let content = "# Title\n indented\n\ncode:\n```rust\nfn main() {}\n```";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.lines.len(), 7);
let line1 = &ctx.lines[0];
assert_eq!(line1.content(ctx.content), "# Title");
assert_eq!(line1.byte_offset, 0);
assert_eq!(line1.indent, 0);
assert!(!line1.is_blank);
assert!(!line1.in_code_block);
assert!(line1.list_item.is_none());
let line2 = &ctx.lines[1];
assert_eq!(line2.content(ctx.content), " indented");
assert_eq!(line2.byte_offset, 8);
assert_eq!(line2.indent, 4);
assert!(!line2.is_blank);
let line3 = &ctx.lines[2];
assert_eq!(line3.content(ctx.content), "");
assert!(line3.is_blank);
assert_eq!(ctx.line_info(1).map(|l| l.indent), Some(0));
assert_eq!(ctx.line_info(2).map(|l| l.indent), Some(4));
assert_eq!(ctx.line_info(1).map(|l| l.byte_offset), Some(0));
assert_eq!(ctx.line_info(2).map(|l| l.byte_offset), Some(8));
}
#[test]
fn test_list_item_detection() {
let content = "- Unordered item\n * Nested item\n1. Ordered item\n 2) Nested ordered\n\nNot a list";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let line1 = &ctx.lines[0];
assert!(line1.list_item.is_some());
let list1 = line1.list_item.as_ref().unwrap();
assert_eq!(list1.marker, "-");
assert!(!list1.is_ordered);
assert_eq!(list1.marker_column, 0);
assert_eq!(list1.content_column, 2);
let line2 = &ctx.lines[1];
assert!(line2.list_item.is_some());
let list2 = line2.list_item.as_ref().unwrap();
assert_eq!(list2.marker, "*");
assert_eq!(list2.marker_column, 2);
let line3 = &ctx.lines[2];
assert!(line3.list_item.is_some());
let list3 = line3.list_item.as_ref().unwrap();
assert_eq!(list3.marker, "1.");
assert!(list3.is_ordered);
assert_eq!(list3.number, Some(1));
let line6 = &ctx.lines[5];
assert!(line6.list_item.is_none());
}
#[test]
fn parsed_list_views_preserve_existing_cache_semantics() {
let fixtures = [
(
MarkdownFlavor::Standard,
"- root\n 1. ordered child\n continuation\n * sibling\n\n> 10. quoted\n> - nested\n\nparagraph\n\t- tabbed\n\n - indented code\n",
),
(
MarkdownFlavor::MkDocs,
"!!! note\n - item\n + nested\n\n=== tab\n 1. ordered\n - child\n",
),
(
MarkdownFlavor::Kramdown,
"{::nomarkdown}\n- hidden\n{:/nomarkdown}\n\n- visible\n - child\n",
),
(MarkdownFlavor::AzureDevOps, ":::python\n- code\n:::\n\n> - visible\n"),
];
for (flavor, content) in fixtures {
let ctx = LintContext::new(content, flavor, None);
let raw_items: Vec<_> = ctx
.lines
.iter()
.enumerate()
.filter_map(|(idx, line)| {
line.list_item.as_deref().map(|item| {
(
idx + 1,
item.marker.as_str(),
item.is_ordered,
item.number,
item.marker_column,
item.content_column,
line.blockquote.as_ref().map_or(0, |bq| bq.nesting_level),
line.blockquote.as_ref().map_or(0, |bq| bq.prefix.len()),
)
})
})
.collect();
let parsed_items: Vec<_> = ctx
.list_items()
.map(|item| {
(
item.line_num(),
item.marker(),
item.is_ordered(),
item.number(),
item.marker_column(),
item.content_column(),
item.blockquote_depth(),
item.blockquote_prefix_len(),
)
})
.collect();
assert_eq!(parsed_items, raw_items, "list item view drifted for {flavor:?}");
for line_num in 0..=ctx.lines.len() + 1 {
let raw = line_num
.checked_sub(1)
.and_then(|idx| ctx.lines.get(idx))
.and_then(|line| line.list_item.as_deref());
let parsed = ctx.list_item_on_line(line_num);
assert_eq!(parsed.is_some(), raw.is_some(), "lookup drifted at line {line_num}");
if let (Some(parsed), Some(raw)) = (parsed, raw) {
assert_eq!(parsed.marker(), raw.marker);
assert_eq!(
parsed.marker_byte_offset(),
parsed.line_info().byte_offset + raw.marker_column
);
}
}
let parsed_blocks = ctx.parsed_list_blocks();
assert_eq!(parsed_blocks.len(), ctx.list_blocks.len());
assert_eq!(parsed_blocks.is_empty(), ctx.list_blocks.is_empty());
for (index, (parsed, raw)) in parsed_blocks.into_iter().zip(&ctx.list_blocks).enumerate() {
assert_eq!(
parsed_blocks.get(index).map(ParsedListBlock::start_line),
Some(raw.start_line)
);
assert_eq!(parsed.start_line(), raw.start_line);
assert_eq!(parsed.end_line(), raw.end_line);
assert_eq!(parsed.is_ordered(), raw.is_ordered);
assert_eq!(parsed.marker(), raw.marker.as_deref());
assert_eq!(parsed.blockquote_prefix(), raw.blockquote_prefix);
assert_eq!(parsed.nesting_level(), raw.nesting_level);
assert_eq!(parsed.max_marker_width(), raw.max_marker_width);
assert_eq!(
parsed.items().map(ParsedListItem::line_num).collect::<Vec<_>>(),
raw.item_lines
.iter()
.copied()
.filter(|line| ctx.list_item_on_line(*line).is_some())
.collect::<Vec<_>>()
);
}
assert!(parsed_blocks.get(parsed_blocks.len()).is_none());
assert_eq!(ctx.has_list_items(), !raw_items.is_empty());
assert_eq!(ctx.has_unordered_list_items(), raw_items.iter().any(|item| !item.2));
}
}
#[test]
fn parsed_list_block_items_keep_source_order_and_container_depth() {
let content = "- root\n - child\n> 1. quoted\n> + nested\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.list_items()
.map(|item| (item.line_num(), item.marker(), item.blockquote_depth()))
.collect::<Vec<_>>(),
vec![(1, "-", 0), (2, "-", 0), (3, "1.", 1), (4, "+", 1)]
);
let block_lines: Vec<Vec<_>> = ctx
.parsed_list_blocks()
.into_iter()
.map(|block| block.items().map(ParsedListItem::line_num).collect())
.collect();
assert_eq!(block_lines, vec![vec![1, 2, 3, 4]]);
}
#[test]
fn list_block_item_groups_split_a_block_into_its_lists() {
let content = "- a\n - a1\n - a2\n- b\n - b1\n\t - b1x\n - b2\n- c\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{:?}", ctx.list_blocks);
let groups: Vec<Vec<usize>> = ctx
.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect();
assert_eq!(groups, vec![vec![1, 4, 8], vec![2, 3], vec![5, 7], vec![6]]);
}
#[test]
fn list_block_item_groups_split_on_a_marker_type_change() {
let groups = |content: &str| -> Vec<Vec<usize>> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{content:?}: {:?}", ctx.list_blocks);
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect()
};
for content in [
"- p\n - a\n - b\n * c\n\n * d\n",
"- p\n - a\n - b\n + c\n\n + d\n",
"- p\n 1. a\n 2. b\n 1) c\n\n 2) d\n",
"- p\n - a\n - b\n 1. c\n\n 2. d\n",
"- p\n 1. a\n 2. b\n - c\n\n - d\n",
] {
assert_eq!(groups(content), vec![vec![1], vec![2, 3], vec![4, 6]], "{content:?}");
}
assert_eq!(groups("- a\n- b\n* c\n\n* d\n"), vec![vec![1, 2], vec![3, 5]]);
assert_eq!(groups("1. a\n2. b\n1) c\n\n2) d\n"), vec![vec![1, 2], vec![3, 5]]);
for content in [
"- p\n - a\n - b\n - c\n\n - d\n",
"- p\n 1. a\n 2. b\n 7. c\n\n 4. d\n",
"- p\n 1) a\n 2) b\n 3) c\n\n 4) d\n",
] {
assert_eq!(groups(content), vec![vec![1], vec![2, 3, 4, 6]], "{content:?}");
}
assert_eq!(groups("- a\n- b\n- c\n\n- d\n"), vec![vec![1, 2, 3, 5]]);
}
#[test]
fn list_block_item_groups_read_ancestry_from_columns() {
let groups = |content: &str| -> Vec<Vec<usize>> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{content:?}: {:?}", ctx.list_blocks);
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect()
};
assert_eq!(
groups(" - parent\n - child a\n - child b\n\n - sibling a\n\n - sibling b\n"),
vec![vec![1, 5, 7], vec![2, 3]]
);
assert_eq!(groups(" - first\n- second\n"), vec![vec![1, 2]]);
assert_eq!(groups("- a\n - b\n - c\n - d\n"), vec![vec![1, 2, 3, 4]]);
assert_eq!(groups("- a\n - b\n - c\n"), vec![vec![1], vec![2, 3]]);
assert_eq!(groups("9. a\n10. b\n\n11. c\n"), vec![vec![1, 2, 4]]);
assert_eq!(groups("- a\n\n - b\n"), vec![vec![1], vec![3]]);
assert_eq!(
groups("* parent\n - child a\n - child b\n * sibling\n"),
vec![vec![1], vec![2, 3], vec![4]]
);
assert_eq!(groups("- a\n > - b\n > - c\n- d\n"), vec![vec![1, 4], vec![2, 3]]);
assert_eq!(groups("> - a\n> > - b\n> - c\n"), vec![vec![1, 3], vec![2]]);
assert_eq!(groups("1. a\n> - b\n2. c\n"), vec![vec![1], vec![2], vec![3]]);
assert_eq!(groups("> - a\n>> - b\n"), vec![vec![1], vec![2]]);
assert_eq!(
groups(" > - parent\n> - child a\n>\n> - child b\n"),
vec![vec![1], vec![2, 4]]
);
assert_eq!(groups(" > - a\n> - b\n> - c\n"), vec![vec![1, 3], vec![2]]);
assert_eq!(groups(">- a\n> - b\n"), vec![vec![1, 2]]);
assert_eq!(groups("> - a\n>- b\n"), vec![vec![1, 2]]);
assert_eq!(groups("> - a\n>\t- b\n"), vec![vec![1], vec![2]]);
assert_eq!(groups("- a\n > - b\n > - c\n- e\n"), vec![vec![1, 4], vec![2, 3]]);
}
#[test]
fn list_block_item_groups_close_a_nested_list_on_ancestor_content() {
let groups = |content: &str| -> Vec<Vec<usize>> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{content:?}: {:?}", ctx.list_blocks);
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect()
};
assert_eq!(
groups("- p\n - a\n - b\n\n With:\n\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3], vec![7, 9]]
);
assert_eq!(
groups("- p\n - a\n\n More about a.\n\n - b\n"),
vec![vec![1], vec![2, 6]]
);
assert_eq!(groups("- p\n - a\nlazy tail of a\n - b\n"), vec![vec![1], vec![2, 4]]);
assert_eq!(
groups("- p\n - a\n - a1\n\n With:\n\n - b\n - b1\n"),
vec![vec![1], vec![2], vec![3], vec![7], vec![8]]
);
assert_eq!(
groups("> - p\n> - a\n> - b\n>\n> With:\n>\n> - c\n> - d\n"),
vec![vec![1], vec![2, 3], vec![7, 8]]
);
assert_eq!(
groups("- p\n - a\n - b\n <!-- parent comment -->\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3], vec![5, 7]]
);
assert_eq!(
groups("- p\n - a\n - b\n <?php echo 1; ?>\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3], vec![5, 7]]
);
assert_eq!(
groups("- p\n - a\n - b\n >\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3], vec![5, 7]]
);
assert_eq!(
groups("> - p\n> - a\n> - b\n> >\n> - c\n>\n> - d\n"),
vec![vec![1], vec![2, 3], vec![5, 7]]
);
assert_eq!(
groups("- p\n - a\n - b <!-- start\n continues\n -->\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3, 6, 8]]
);
assert_eq!(
groups("- p\n - a\n >\n parent\n - c\n\n - d\n"),
vec![vec![1], vec![2], vec![5, 7]]
);
assert_eq!(
groups("- p\n - a\n -\n text\n - c\n\n - d\n"),
vec![vec![1], vec![2, 3], vec![5, 7]]
);
assert_eq!(
groups("- p\n - a\n > quote\n parent\n - c\n\n - d\n"),
vec![vec![1], vec![2, 5, 7]]
);
for (content, second_list) in [
("- p\n - a\n - ```\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - # h\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - ***\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - code\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - <div>\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - - # h\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - -\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - >\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - > code\n more\n - c\n\n - d\n", vec![5, 7]),
("- p\n - a\n - 1. ```\n more\n - c\n\n - d\n", vec![5, 7]),
(
"- p\n - a\n - | h |\n | --- |\n more\n - c\n\n - d\n",
vec![6, 8],
),
("- p\n - a\n - <!-- c\n more\n -->\n - c\n\n - d\n", vec![6, 8]),
] {
assert_eq!(groups(content), vec![vec![1], vec![2, 3], second_list], "{content:?}");
}
for content in [
"- p\n - a\n - - b1\n more\n - c\n\n - d\n",
"- p\n - a\n - text\n more\n - c\n\n - d\n",
"- p\n - a\n - #tag\n more\n - c\n\n - d\n",
"- p\n - a\n - ```lang`bad\n more\n - c\n\n - d\n",
"- p\n - a\n - > text\n more\n - c\n\n - d\n",
"- p\n - a\n - - text\n more\n - c\n\n - d\n",
"- p\n - a\n - 1234567890. x\n more\n - c\n\n - d\n",
] {
assert_eq!(groups(content), vec![vec![1], vec![2, 3, 5, 7]], "{content:?}");
}
}
#[test]
fn list_block_item_groups_end_with_the_item_that_holds_their_blockquote() {
let groups = |content: &str| -> Vec<Vec<usize>> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{content:?}: {:?}", ctx.list_blocks);
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect()
};
assert_eq!(
groups("- p\n >- b1\n >\n >- b2\n>- c\n>- d\n"),
vec![vec![1], vec![2, 4], vec![5, 6]]
);
assert_eq!(groups("- p\n >- b x\n>- c x\n"), vec![vec![1], vec![2], vec![3]]);
assert_eq!(groups("- p\n > - b1\n>\n > - b2\n"), vec![vec![1], vec![2], vec![4]]);
assert_eq!(groups("- p\n > - b1\n\n > - b2\n"), vec![vec![1], vec![2], vec![4]]);
assert_eq!(
groups("- p\n > - b1\n > - b2\n\n > - b3\n"),
vec![vec![1], vec![2, 3], vec![5]]
);
assert_eq!(
groups("> - a\n> - b\n> - c\n\n> - d\n"),
vec![vec![1], vec![2, 3], vec![5]]
);
assert_eq!(groups("> - a\n> - b\n>\n> - c\n"), vec![vec![1], vec![2, 4]]);
assert_eq!(
groups("- p\n > > - x\n >\n > > - y\n"),
vec![vec![1], vec![2], vec![4]]
);
assert_eq!(groups("- p\n > > - x\n > >\n > > - y\n"), vec![vec![1], vec![2, 4]]);
assert_eq!(groups("- p\n > - b1\n lazy\n > - b2\n"), vec![vec![1], vec![2, 4]]);
assert_eq!(groups("- p\n >- b1\ntext\n >- b2\n"), vec![vec![1], vec![2, 4]]);
assert_eq!(groups("- p\n > - b1\n - q\n"), vec![vec![1], vec![2], vec![3]]);
assert_eq!(groups("- p\n > - a\n >> - b\n"), vec![vec![1], vec![2], vec![3]]);
}
#[test]
fn list_block_item_groups_close_the_list_at_the_block_level_too() {
let groups = |content: &str| -> Vec<Vec<usize>> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.list_blocks.len(), 1, "{content:?}: {:?}", ctx.list_blocks);
ctx.list_blocks
.iter()
.flat_map(|block| ctx.list_block_item_groups(block))
.collect()
};
assert_eq!(groups("- p\n```\n```\n- q\n"), vec![vec![1], vec![4]]);
assert_eq!(groups("- p\n<!-- x -->\n- q\n"), vec![vec![1], vec![3]]);
assert_eq!(groups("- p\n```\n```\n - b\n- c\n"), vec![vec![1], vec![4, 5]]);
assert_eq!(groups("- p\n<!-- x -->\n - b\n- c\n"), vec![vec![1], vec![3, 4]]);
assert_eq!(
groups("- p\n```\n```\n > - b\n lazy\n> - c\n"),
vec![vec![1], vec![4, 6]]
);
assert_eq!(groups("> - a\n> - b\n\n> - c\n"), vec![vec![1, 2], vec![4]]);
assert_eq!(groups("- p\nlazy\n- q\n"), vec![vec![1, 3]]);
assert_eq!(groups("- p\n\n more\n- q\n"), vec![vec![1, 4]]);
assert_eq!(groups("- p\n\n ```\n ```\n- q\n"), vec![vec![1, 5]]);
}
#[test]
fn commonmark_ordered_lists_preserve_raw_membership_and_start_values() {
let fixtures = [
(
MarkdownFlavor::Standard,
"11. outer\n 1. nested\n 2. nested\n12. outer\n\nparagraph\n\n1. restart\n2. restart\n\n> 7. quoted\n> 8. quoted\n",
),
(
MarkdownFlavor::MkDocs,
"1. outer\n 9. nested\n 10. nested\n2. outer\n\n!!! note\n 4. inside\n 5. inside\n",
),
(
MarkdownFlavor::Pandoc,
"(@example) example marker\n\n3) parenthesized\n4) parenthesized\n\n1. ordinary\n",
),
];
for (flavor, content) in fixtures {
let ctx = LintContext::new(content, flavor, None);
assert!(ctx.commonmark_ordered_lists_cache.get().is_none());
let raw = crate::utils::code_block_utils::CodeBlockUtils::detect_code_blocks_and_spans(content);
let mut groups: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new();
for line_num in 1..=ctx.lines.len() {
if ctx.list_item_on_line(line_num).is_some_and(ParsedListItem::is_ordered)
&& let Some(&list_id) = raw.line_to_list.get(&line_num)
{
groups.entry(list_id).or_default().push(line_num);
}
}
let mut expected: Vec<_> = groups
.into_iter()
.map(|(list_id, mut item_lines)| {
item_lines.sort_unstable();
(raw.list_start_values.get(&list_id).copied().unwrap_or(1), item_lines)
})
.collect();
expected.sort_by_key(|(_, lines)| lines.first().copied().unwrap_or(0));
let lists = ctx.commonmark_ordered_lists();
assert!(ctx.commonmark_ordered_lists_cache.get().is_some());
let actual: Vec<_> = lists
.into_iter()
.map(|list| {
(
list.start_value(),
list.items().map(ParsedListItem::line_num).collect::<Vec<_>>(),
)
})
.collect();
assert_eq!(actual, expected, "CommonMark grouping drifted for {flavor:?}");
assert_eq!(lists.len(), expected.len());
assert_eq!(lists.is_empty(), expected.is_empty());
for (index, expected_list) in expected.iter().enumerate() {
let list = lists.get(index).expect("index comes from expected list collection");
assert_eq!(list.start_value(), expected_list.0);
}
assert!(lists.get(lists.len()).is_none());
}
}
#[test]
fn commonmark_ordered_lists_keep_nested_groups_source_ordered() {
let content = "11. outer\n 1. nested\n 2. nested\n12. outer\n\ntext\n\n1. restart\n2. restart\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let groups: Vec<_> = ctx
.commonmark_ordered_lists()
.into_iter()
.map(|list| {
(
list.start_value(),
list.items().map(ParsedListItem::line_num).collect::<Vec<_>>(),
)
})
.collect();
assert_eq!(groups, vec![(11, vec![1, 4]), (1, vec![2, 3]), (1, vec![8, 9])]);
}
#[test]
fn test_offset_to_line_col_edge_cases() {
let content = "a\nb\nc";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.offset_to_line_col(0), (1, 1)); assert_eq!(ctx.offset_to_line_col(1), (1, 2)); assert_eq!(ctx.offset_to_line_col(2), (2, 1)); assert_eq!(ctx.offset_to_line_col(3), (2, 2)); assert_eq!(ctx.offset_to_line_col(4), (3, 1)); assert_eq!(ctx.offset_to_line_col(5), (3, 2)); }
#[test]
fn test_offset_to_line_col_non_ascii() {
let content = "你好x";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.offset_to_line_col(0), (1, 1)); assert_eq!(ctx.offset_to_line_col(3), (1, 2)); assert_eq!(ctx.offset_to_line_col(6), (1, 3)); }
#[test]
fn source_location_queries_define_utf8_crlf_and_eof_boundaries() {
let content = "éx\r\n中\nlast";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.line_start_byte(1), Some(0));
assert_eq!(ctx.line_start_byte(2), Some(5));
assert_eq!(ctx.line_start_byte(3), Some(9));
assert_eq!(ctx.line_start_byte(0), None);
assert_eq!(ctx.line_start_byte(4), None);
assert_eq!(ctx.line_column_byte_range(1, 2), 2..2);
assert_eq!(ctx.line_column_byte_range_with_length(1, 1, 2), 0..3);
assert_eq!(ctx.line_text_byte_range(2, 1, 2), 5..8);
assert_eq!(ctx.line_content_byte_range(1), 0..3);
assert_eq!(ctx.whole_line_byte_range(1), 0..5);
assert_eq!(ctx.line_span_byte_range(1, 2), 0..9);
assert_eq!(ctx.line_content_byte_range(3), 9..13);
assert_eq!(ctx.whole_line_byte_range(3), 9..13);
let eof = content.len();
assert_eq!(ctx.line_column_byte_range(99, 99), eof..eof);
assert_eq!(ctx.line_content_byte_range(99), eof..eof);
}
#[test]
fn test_mdx_esm_blocks() {
let content = r##"import {Chart} from './snowfall.js'
export const year = 2023
# Last year's snowfall
In {year}, the snowfall was above average.
It was followed by a warm spring which caused
flood conditions in many of the nearby rivers.
<Chart color="#fcb32c" year={year} />
"##;
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
assert_eq!(ctx.lines.len(), 10);
assert!(ctx.lines[0].in_esm_block, "Line 1 (import) should be in_esm_block");
assert!(ctx.lines[1].in_esm_block, "Line 2 (export) should be in_esm_block");
assert!(!ctx.lines[2].in_esm_block, "Line 3 (blank) should NOT be in_esm_block");
assert!(
!ctx.lines[3].in_esm_block,
"Line 4 (heading) should NOT be in_esm_block"
);
assert!(!ctx.lines[4].in_esm_block, "Line 5 (blank) should NOT be in_esm_block");
assert!(!ctx.lines[5].in_esm_block, "Line 6 (text) should NOT be in_esm_block");
}
#[test]
fn test_mdx_esm_blocks_not_detected_in_standard_flavor() {
let content = r#"import {Chart} from './snowfall.js'
export const year = 2023
# Last year's snowfall
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
!ctx.lines[0].in_esm_block,
"Line 1 should NOT be in_esm_block in Standard flavor"
);
assert!(
!ctx.lines[1].in_esm_block,
"Line 2 should NOT be in_esm_block in Standard flavor"
);
}
#[test]
fn test_blockquote_with_indented_content() {
let content = r#"# Heading
> -S socket-path
> More text
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.lines.get(2).is_some_and(|l| l.blockquote.is_some()),
"Line 3 should be a blockquote"
);
assert!(
ctx.lines.get(3).is_some_and(|l| l.blockquote.is_some()),
"Line 4 should be a blockquote"
);
let bq3 = ctx.lines.get(2).unwrap().blockquote.as_ref().unwrap();
assert_eq!(bq3.content, "-S socket-path");
assert_eq!(bq3.nesting_level, 1);
assert!(bq3.has_multiple_spaces_after_marker);
let bq4 = ctx.lines.get(3).unwrap().blockquote.as_ref().unwrap();
assert_eq!(bq4.content, "More text");
assert_eq!(bq4.nesting_level, 1);
}
#[test]
fn test_blockquote_spaced_nested_markers_are_detected() {
let content = r#"> > Nested quote content
> > Additional line
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let bq1 = ctx.lines.first().unwrap().blockquote.as_ref().unwrap();
assert_eq!(bq1.nesting_level, 2);
assert_eq!(bq1.prefix, "> > ");
assert_eq!(bq1.content, "Nested quote content");
let bq2 = ctx.lines.get(1).unwrap().blockquote.as_ref().unwrap();
assert_eq!(bq2.nesting_level, 2);
assert_eq!(bq2.prefix, "> > ");
assert_eq!(bq2.content, "Additional line");
}
#[test]
fn test_ref_def_with_angle_bracket_destination_containing_space() {
let content = "[docs]: <./has space.md>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.reference_definitions().len(),
1,
"angle-bracket destination must parse"
);
assert_eq!(ctx.reference_definitions()[0].id, "docs");
assert_eq!(
ctx.reference_definitions()[0].url,
"./has space.md",
"URL should be the destination content, not the angle-bracketed form"
);
assert_eq!(ctx.reference_definitions()[0].title, None);
}
#[test]
fn test_ref_def_with_angle_bracket_destination_and_title() {
let content = "[docs]: <./has space.md> \"Help me\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].url, "./has space.md");
assert_eq!(ctx.reference_definitions()[0].title.as_deref(), Some("Help me"));
}
#[test]
fn test_ref_def_multiline_title_on_next_line() {
let content = "[ref]: https://example.com\n \"the title\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title.as_deref(), Some("the title"));
let quote = content.find('"').unwrap();
assert!(
ctx.is_in_reference_def(quote),
"title line should be inside the ref def"
);
let def = &ctx.reference_definitions()[0];
assert_eq!(
def.title_byte_start,
Some(quote),
"title_byte_start is the opening quote"
);
assert_eq!(
def.title_byte_end,
Some(quote + "\"the title\"".len()),
"title_byte_end is one past the closing quote"
);
assert_eq!(
def.byte_end,
content.trim_end_matches('\n').len(),
"byte_end reaches end of title line"
);
}
#[test]
fn test_ref_def_single_quote_and_paren_title_on_next_line() {
for content in [
"[ref]: https://example.com\n 'the title'\n",
"[ref]: https://example.com\n (the title)\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1, "input: {content:?}");
assert_eq!(
ctx.reference_definitions()[0].title.as_deref(),
Some("the title"),
"input: {content:?}"
);
}
}
#[test]
fn test_ref_def_blank_line_breaks_multiline_title() {
let content = "[ref]: https://example.com\n\n\"not a title\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title, None);
let quote = content.find('"').unwrap();
assert!(
!ctx.is_in_reference_def(quote),
"a blank-separated quoted line is not part of the ref def"
);
}
#[test]
fn test_ref_def_non_title_next_line_not_consumed() {
let content = "[ref]: https://example.com\nordinary paragraph text\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title, None);
let para = content.find("ordinary").unwrap();
assert!(
!ctx.is_in_reference_def(para),
"a following paragraph is not part of the ref def"
);
}
#[test]
fn test_ref_def_inline_title_unaffected_by_lookahead() {
let content = "[ref]: https://example.com \"inline\"\n\"separate\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title.as_deref(), Some("inline"));
let sep = content.find("\"separate\"").unwrap();
assert!(
!ctx.is_in_reference_def(sep),
"the inline-title def must not absorb the next line"
);
}
#[test]
fn test_ref_def_multiline_title_in_blockquote() {
let content = "> [ref]: https://example.com\n> \"the title\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title.as_deref(), Some("the title"));
}
#[test]
fn test_ref_def_paren_title_with_escaped_parens() {
let content = "[docs]: https://example.com (title \\(x\\))\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].url, "https://example.com");
assert_eq!(
ctx.reference_definitions()[0].title.as_deref(),
Some("title (x)"),
"title must be unescaped to match pulldown-cmark's parsed value"
);
}
#[test]
fn test_mkdocs_admonition_link_with_paren_title() {
let content = "!!! note\n See [doc](https://example.com (paren title)) here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let link = ctx
.links()
.iter()
.find(|l| l.url == "https://example.com")
.expect("MkDocs fallback must surface the link");
assert_eq!(
link.title.as_deref(),
Some("paren title"),
"paren-form title must be captured by the MkDocs link fallback"
);
}
#[test]
fn test_mkdocs_admonition_image_with_paren_title() {
let content = "!!! note\n See ) here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let img = ctx
.images()
.iter()
.find(|i| i.url == "https://example.com/x.png")
.expect("MkDocs fallback must surface the image");
assert_eq!(
img.title.as_deref(),
Some("paren title"),
"paren-form title must be captured by the MkDocs image fallback"
);
}
#[test]
fn parsed_link_queries_preserve_document_order_and_half_open_ranges() {
let content = "[one](a)\n and [two](c)\n\n[Docs]: ./guide.md\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.links_on_line(1)
.iter()
.map(|link| link.text.as_ref())
.collect::<Vec<_>>(),
["one"]
);
assert_eq!(
ctx.links_on_line(2)
.iter()
.map(|link| link.text.as_ref())
.collect::<Vec<_>>(),
["two"]
);
assert!(ctx.links_on_line(3).is_empty());
assert_eq!(
ctx.images_on_line(2)
.iter()
.map(|image| image.alt_text.as_ref())
.collect::<Vec<_>>(),
["pic"]
);
let first_link_start = content.find("[one]").unwrap();
let second_link_start = content.find("[two]").unwrap();
let image_start = content.find("![pic]").unwrap();
let first_link = ctx.link_starting_at(first_link_start).unwrap();
assert!(std::ptr::eq(ctx.link_containing(first_link_start).unwrap(), first_link));
assert!(std::ptr::eq(
ctx.link_containing(first_link.byte_end - 1).unwrap(),
first_link
));
assert!(ctx.link_containing(first_link.byte_end).is_none());
assert_eq!(ctx.links_starting_before_or_at(second_link_start).len(), 2);
let image = ctx.image_starting_at(image_start).unwrap();
assert!(std::ptr::eq(ctx.image_containing(image_start).unwrap(), image));
assert!(std::ptr::eq(ctx.image_containing(image.byte_end - 1).unwrap(), image));
assert!(ctx.image_containing(image.byte_end).is_none());
let definition = ctx.reference_definition("DOCS").unwrap();
assert_eq!(definition.url, "./guide.md");
assert_eq!(ctx.get_reference_url("docs"), Some("./guide.md"));
}
#[test]
fn parsed_links_remain_ordered_after_regex_fallbacks() {
let content = "\
# Document
See [undefined-ref] for details.
Some text with [another-undef-ref] here.
Short text [link](https://example.com/a-very-long-destination).
";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.links()
.windows(2)
.all(|pair| { (pair[0].line, pair[0].byte_offset) <= (pair[1].line, pair[1].byte_offset) })
);
assert!(
ctx.images()
.windows(2)
.all(|pair| { (pair[0].line, pair[0].byte_offset) <= (pair[1].line, pair[1].byte_offset) })
);
}
#[test]
fn test_wiki_embed_has_no_alt_text() {
for (content, url) in [
("![[image.png]]\n", "image.png"),
("![[image.png|300]]\n", "image.png"),
("![[image.png|Some description]]\n", "image.png"),
("![[subfolder/image.png|640x480]]\n", "subfolder/image.png"),
] {
for flavor in [MarkdownFlavor::Obsidian, MarkdownFlavor::Standard] {
let ctx = LintContext::new(content, flavor, None);
assert_eq!(ctx.images().len(), 1, "{flavor:?}: {content:?}");
assert_eq!(ctx.images()[0].url, url, "{flavor:?}: {content:?}");
assert_eq!(
ctx.images()[0].alt_text,
"",
"{flavor:?}: {content:?} has no alt-text slot"
);
}
}
}
fn first_link_text(content: &str) -> String {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
ctx.links()
.first()
.unwrap_or_else(|| panic!("the link must be parsed: {content:?}"))
.text
.to_string()
}
#[test]
fn test_label_holding_an_unmatched_backtick_reads_to_its_bracket() {
let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
assert_eq!(first_link_text(content), "literal `");
}
#[test]
fn test_label_holding_inline_html_reads_to_its_bracket() {
let content = "[<i title=\"[\">text</i>](missing.md)\n";
assert_eq!(first_link_text(content), "<i title=\"[\">text</i>");
}
#[test]
fn test_label_holding_a_two_backtick_code_span_reads_to_its_bracket() {
let content = "[``a\n](./exists.md)``](exists.md)\n";
assert_eq!(first_link_text(content), "``a\n](./exists.md)``");
}
#[test]
fn test_label_holding_an_escaped_bracket_reads_to_its_bracket() {
let content = "[a\\]b](url)\n";
assert_eq!(first_link_text(content), "a\\]b");
}
#[test]
fn test_label_holding_a_nested_image_reads_to_its_bracket() {
let content = "[](y.md)\n";
assert_eq!(first_link_text(content), "");
}
#[test]
fn test_label_holding_a_collapsed_reference_image_reads_to_its_bracket() {
let content = "[![alt][]](target1.md)\n\n[alt]: exists.png\n";
assert_eq!(first_link_text(content), "![alt][]");
}
#[test]
fn test_label_holding_a_collapsed_reference_image_between_text_reads_to_its_bracket() {
let content = "[before ![alt][] after](target4.md)\n\n[alt]: exists.png\n";
assert_eq!(first_link_text(content), "before ![alt][] after");
}
#[test]
fn test_label_ending_in_whitespace_after_a_collapsed_reference_image_reads_to_its_bracket() {
let content = "[![alt][] ](t.md)\n\n[alt]: exists.png\n";
assert_eq!(first_link_text(content), "![alt][] ");
}
#[test]
fn test_reference_label_holding_a_collapsed_reference_image_reads_to_its_bracket() {
let content = "[![alt][]][ref]\n\n[alt]: exists.png\n[ref]: target5.md\n";
assert_eq!(first_link_text(content), "![alt][]");
}
#[test]
fn test_an_empty_label_reads_as_empty() {
let content = "[](url)\n";
assert_eq!(first_link_text(content), "");
}
#[test]
fn test_a_label_wrapping_onto_an_indented_line_keeps_the_break_and_the_indent() {
let content = "- [wrapped\n text](./gone.md)\n";
assert_eq!(first_link_text(content), "wrapped\n text");
}
#[test]
fn test_a_label_ending_in_whitespace_reads_to_its_bracket() {
let content = "[ \n ](url)\n";
assert_eq!(first_link_text(content), " \n ");
}
#[test]
fn test_a_label_wrapping_inside_a_blockquote_keeps_the_marker() {
let content = "> [a\n> ](url)\n";
assert_eq!(first_link_text(content), "a\n> ");
}
#[test]
fn test_wikilink_text_survives_a_nested_image() {
let content = "[[Target|Display  more]]\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let link = ctx
.links()
.iter()
.find(|l| l.url == "Target")
.expect("the wikilink must be parsed");
assert_eq!(link.text, "Display alt more");
}
#[test]
fn test_ref_def_angle_bracket_destination_with_escaped_brackets() {
let content = "[id]: <a\\<b\\>c>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.reference_definitions().len(),
1,
"escaped angle-bracket destination must round-trip through the regex"
);
assert_eq!(ctx.reference_definitions()[0].id, "id");
assert_eq!(ctx.reference_definitions()[0].title, None);
}
#[test]
fn test_ref_def_label_with_escaped_closing_bracket() {
let content = "[ref1\\[\\]]: https://example.com/ref1\n[ref2\\]]: https://example.com/ref2\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let ids: Vec<&str> = ctx.reference_definitions().iter().map(|d| d.id.as_str()).collect();
assert_eq!(
ids,
vec!["ref1\\[\\]", "ref2\\]"],
"an escaped `]` must not terminate the label scan"
);
assert_eq!(ctx.reference_definitions()[0].url, "https://example.com/ref1");
assert_eq!(ctx.reference_definitions()[1].url, "https://example.com/ref2");
}
#[test]
fn test_ref_def_double_quoted_title_with_escaped_quote() {
let content = "[id]: https://example.com \"he said \\\"hi\\\"\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].url, "https://example.com");
assert_eq!(
ctx.reference_definitions()[0].title.as_deref(),
Some("he said \"hi\""),
"title must be unescaped to match pulldown-cmark's parsed value"
);
}
#[test]
fn test_ref_def_single_quoted_title_with_escaped_quote() {
let content = "[id]: https://example.com 'it\\'s fine'\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].url, "https://example.com");
assert_eq!(
ctx.reference_definitions()[0].title.as_deref(),
Some("it's fine"),
"title must be unescaped to match pulldown-cmark's parsed value"
);
}
#[test]
fn test_ref_def_url_unescapes_backslash_escapes() {
let content = "[id]: https://e.com/path\\(1\\)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(
ctx.reference_definitions()[0].url,
"https://e.com/path(1)",
"URL must be unescaped per CommonMark §6.1"
);
}
#[test]
fn test_ref_def_unescape_preserves_non_punctuation_backslash() {
let content = "[id]: https://e.com/p\\ath \"a\\b c\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(
ctx.reference_definitions()[0].url,
"https://e.com/p\\ath",
"backslash before non-punctuation must remain in URL"
);
assert_eq!(
ctx.reference_definitions()[0].title.as_deref(),
Some("a\\b c"),
"backslash before non-punctuation must remain in title"
);
}
#[test]
fn test_footnote_definitions_not_parsed_as_reference_defs() {
let content = r#"# Title
A footnote[^1].
[^1]: This is the footnote content.
[^note]: Another footnote with [link](https://example.com).
[regular]: ./path.md "A real reference definition"
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.reference_definitions().len(),
1,
"Footnotes should not be parsed as reference definitions"
);
assert_eq!(ctx.reference_definitions()[0].id, "regular");
assert_eq!(ctx.reference_definitions()[0].url, "./path.md");
assert_eq!(
ctx.reference_definitions()[0].title,
Some("A real reference definition".to_string())
);
}
#[test]
fn test_footnote_with_inline_link_not_misidentified() {
let content = r#"# Title
A footnote[^1].
[^1]: [link](https://www.google.com).
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.reference_definitions().is_empty(),
"Footnote with inline link should not create a reference definition"
);
}
#[test]
fn test_various_footnote_formats_excluded() {
let content = r#"[^1]: Numeric footnote
[^note]: Named footnote
[^a]: Single char footnote
[^long-footnote-name]: Long named footnote
[^123abc]: Mixed alphanumeric
[ref1]: ./file1.md
[ref2]: ./file2.md
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.reference_definitions().len(),
2,
"Only regular reference definitions should be parsed"
);
let ids: Vec<&str> = ctx.reference_definitions().iter().map(|r| r.id.as_str()).collect();
assert!(ids.contains(&"ref1"));
assert!(ids.contains(&"ref2"));
assert!(!ids.iter().any(|id| id.starts_with('^')));
}
#[test]
fn test_has_char_tracked_characters() {
let content =
"# Heading\n* list item\n_emphasis_ and -hyphen-\n+ plus\n> quote\n| table |\n[link]\n`code`\n<html>\n!image";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.has_char('#'), "Should detect hash");
assert!(ctx.has_char('*'), "Should detect asterisk");
assert!(ctx.has_char('_'), "Should detect underscore");
assert!(ctx.has_char('-'), "Should detect hyphen");
assert!(ctx.has_char('+'), "Should detect plus");
assert!(ctx.has_char('>'), "Should detect gt");
assert!(ctx.has_char('|'), "Should detect pipe");
assert!(ctx.has_char('['), "Should detect bracket");
assert!(ctx.has_char('`'), "Should detect backtick");
assert!(ctx.has_char('<'), "Should detect lt");
assert!(ctx.has_char('!'), "Should detect exclamation");
assert!(ctx.has_char('\n'), "Should detect newline");
}
#[test]
fn test_has_char_absent_characters() {
let content = "Simple text without special chars";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.has_char('#'), "Should not detect hash");
assert!(!ctx.has_char('*'), "Should not detect asterisk");
assert!(!ctx.has_char('_'), "Should not detect underscore");
assert!(!ctx.has_char('-'), "Should not detect hyphen");
assert!(!ctx.has_char('+'), "Should not detect plus");
assert!(!ctx.has_char('>'), "Should not detect gt");
assert!(!ctx.has_char('|'), "Should not detect pipe");
assert!(!ctx.has_char('['), "Should not detect bracket");
assert!(!ctx.has_char('`'), "Should not detect backtick");
assert!(!ctx.has_char('<'), "Should not detect lt");
assert!(!ctx.has_char('!'), "Should not detect exclamation");
assert!(!ctx.has_char('\n'), "Should not detect newline in single line");
}
#[test]
fn test_has_char_fallback_for_untracked() {
let content = "Text with @mention and $dollar and %percent";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.has_char('@'), "Should detect @ via fallback");
assert!(ctx.has_char('$'), "Should detect $ via fallback");
assert!(ctx.has_char('%'), "Should detect % via fallback");
assert!(!ctx.has_char('^'), "Should not detect absent ^ via fallback");
}
#[test]
fn test_char_count_tracked_characters() {
let content =
"## Heading ##\n***bold***\n__emphasis__\n---\n+++\n>> nested\n|| table ||\n[[link]]\n``code``\n<<html>>\n!!";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.char_count('#'), 4, "Should count 4 hashes");
assert_eq!(ctx.char_count('*'), 6, "Should count 6 asterisks");
assert_eq!(ctx.char_count('_'), 4, "Should count 4 underscores");
assert_eq!(ctx.char_count('-'), 3, "Should count 3 hyphens");
assert_eq!(ctx.char_count('+'), 3, "Should count 3 pluses");
assert_eq!(ctx.char_count('>'), 4, "Should count 4 gt (2 nested + 2 in <<html>>)");
assert_eq!(ctx.char_count('|'), 4, "Should count 4 pipes");
assert_eq!(ctx.char_count('['), 2, "Should count 2 brackets");
assert_eq!(ctx.char_count('`'), 4, "Should count 4 backticks");
assert_eq!(ctx.char_count('<'), 2, "Should count 2 lt");
assert_eq!(ctx.char_count('!'), 2, "Should count 2 exclamations");
assert_eq!(ctx.char_count('\n'), 10, "Should count 10 newlines");
}
#[test]
fn test_char_count_zero_for_absent() {
let content = "Plain text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.char_count('#'), 0);
assert_eq!(ctx.char_count('*'), 0);
assert_eq!(ctx.char_count('_'), 0);
assert_eq!(ctx.char_count('\n'), 0);
}
#[test]
fn test_char_count_fallback_for_untracked() {
let content = "@@@ $$ %%%";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.char_count('@'), 3, "Should count 3 @ via fallback");
assert_eq!(ctx.char_count('$'), 2, "Should count 2 $ via fallback");
assert_eq!(ctx.char_count('%'), 3, "Should count 3 % via fallback");
assert_eq!(ctx.char_count('^'), 0, "Should count 0 for absent char");
}
#[test]
fn test_char_count_empty_content() {
let ctx = LintContext::new("", MarkdownFlavor::Standard, None);
assert_eq!(ctx.char_count('#'), 0);
assert_eq!(ctx.char_count('*'), 0);
assert_eq!(ctx.char_count('@'), 0);
assert!(!ctx.has_char('#'));
assert!(!ctx.has_char('@'));
}
#[test]
fn test_is_in_html_tag_simple() {
let content = "<div>content</div>";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_tag(0), "Position 0 (<) should be in tag");
assert!(ctx.is_in_html_tag(1), "Position 1 (d) should be in tag");
assert!(ctx.is_in_html_tag(4), "Position 4 (>) should be in tag");
assert!(!ctx.is_in_html_tag(5), "Position 5 (c) should not be in tag");
assert!(!ctx.is_in_html_tag(10), "Position 10 (t) should not be in tag");
assert!(ctx.is_in_html_tag(12), "Position 12 (<) should be in tag");
assert!(ctx.is_in_html_tag(17), "Position 17 (>) should be in tag");
}
#[test]
fn test_is_in_html_tag_self_closing() {
let content = "Text <br/> more text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_html_tag(0), "Position 0 should not be in tag");
assert!(!ctx.is_in_html_tag(4), "Position 4 (space) should not be in tag");
assert!(ctx.is_in_html_tag(5), "Position 5 (<) should be in tag");
assert!(ctx.is_in_html_tag(8), "Position 8 (/) should be in tag");
assert!(ctx.is_in_html_tag(9), "Position 9 (>) should be in tag");
assert!(!ctx.is_in_html_tag(10), "Position 10 (space) should not be in tag");
}
#[test]
fn test_is_in_html_tag_with_attributes() {
let content = r#"<a href="url" class="link">text</a>"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_tag(0), "Start of tag");
assert!(ctx.is_in_html_tag(10), "Inside href attribute");
assert!(ctx.is_in_html_tag(20), "Inside class attribute");
assert!(ctx.is_in_html_tag(26), "End of opening tag");
assert!(!ctx.is_in_html_tag(27), "Start of content");
assert!(!ctx.is_in_html_tag(30), "End of content");
assert!(ctx.is_in_html_tag(31), "Start of closing tag");
}
#[test]
fn test_is_in_html_tag_multiline() {
let content = "<div\n class=\"test\"\n>\ncontent\n</div>";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_tag(0), "Start of multiline tag");
assert!(ctx.is_in_html_tag(5), "After first newline in tag");
assert!(ctx.is_in_html_tag(15), "Inside attribute");
let closing_bracket_pos = content.find(">\n").unwrap();
assert!(!ctx.is_in_html_tag(closing_bracket_pos + 2), "Content after tag");
}
#[test]
fn test_is_in_html_tag_with_url_attributes() {
let content = r#"<input name="fields[url]" value="https://www.example.com">"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert_eq!(tags.len(), 1, "Should detect one HTML tag");
assert_eq!(tags[0].tag_name, "input");
assert!(!tags[0].is_self_closing);
assert!(ctx.is_in_html_tag(35), "URL position should be inside HTML tag");
}
#[test]
fn test_is_in_html_tag_self_closing_with_slash() {
let content = "<br />";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].tag_name, "br");
assert!(tags[0].is_self_closing);
}
#[test]
fn test_html_tags_respect_backslash_escaped_openers() {
for content in [r"\<x-keyboard>", r"\\\<x-keyboard>"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.html_tags().is_empty(), "escaped opener in {content:?}");
}
for content in [r"<x-keyboard>", r"\\<x-keyboard>", r"\\\\<x-keyboard>"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert_eq!(tags.len(), 1, "active opener in {content:?}");
assert_eq!(tags[0].tag_name, "x-keyboard");
}
}
#[test]
fn test_is_in_html_tag_nested_angle_brackets() {
let content = r#"<a href="{{< ref "../common-parameters" >}}">"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert!(!tags.is_empty(), "Should detect at least one tag fragment");
}
#[test]
fn test_html_tag_window_ends_inside_multibyte_char() {
let content = format!("<a{}的", "a".repeat(4093));
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
let _ = ctx.html_tags();
let content2 = format!("<a {}的>", "b".repeat(5000));
let ctx2 = LintContext::new(&content2, MarkdownFlavor::Standard, None);
let _ = ctx2.html_tags(); }
#[test]
fn test_is_in_html_tag_no_tags() {
let content = "Plain text without any HTML";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for i in 0..content.len() {
assert!(!ctx.is_in_html_tag(i), "Position {i} should not be in tag");
}
}
#[test]
fn test_is_in_jinja_range_expression() {
let content = "Hello {{ name }}!";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_jinja_range(0), "H should not be in Jinja");
assert!(!ctx.is_in_jinja_range(5), "Space before Jinja should not be in Jinja");
assert!(ctx.is_in_jinja_range(6), "First brace should be in Jinja");
assert!(ctx.is_in_jinja_range(7), "Second brace should be in Jinja");
assert!(ctx.is_in_jinja_range(10), "name should be in Jinja");
assert!(ctx.is_in_jinja_range(14), "Closing brace should be in Jinja");
assert!(ctx.is_in_jinja_range(15), "Second closing brace should be in Jinja");
assert!(!ctx.is_in_jinja_range(16), "! should not be in Jinja");
}
#[test]
fn test_is_in_jinja_range_statement() {
let content = "{% if condition %}content{% endif %}";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_jinja_range(0), "Start of Jinja statement");
assert!(ctx.is_in_jinja_range(5), "condition should be in Jinja");
assert!(ctx.is_in_jinja_range(17), "End of opening statement");
assert!(!ctx.is_in_jinja_range(18), "content should not be in Jinja");
assert!(ctx.is_in_jinja_range(25), "Start of endif");
assert!(ctx.is_in_jinja_range(32), "endif should be in Jinja");
}
#[test]
fn test_is_in_jinja_range_multiple() {
let content = "{{ a }} and {{ b }}";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_jinja_range(0));
assert!(ctx.is_in_jinja_range(3));
assert!(ctx.is_in_jinja_range(6));
assert!(!ctx.is_in_jinja_range(8));
assert!(!ctx.is_in_jinja_range(11));
assert!(ctx.is_in_jinja_range(12));
assert!(ctx.is_in_jinja_range(15));
assert!(ctx.is_in_jinja_range(18));
}
#[test]
fn test_is_in_jinja_range_no_jinja() {
let content = "Plain text with single braces but not Jinja";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for i in 0..content.len() {
assert!(!ctx.is_in_jinja_range(i), "Position {i} should not be in Jinja");
}
}
#[test]
fn test_is_in_link_title_with_title() {
let content = r#"[ref]: https://example.com "Title text"
Some content."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
let def = &ctx.reference_definitions()[0];
assert!(def.title_byte_start.is_some());
assert!(def.title_byte_end.is_some());
let title_start = def.title_byte_start.unwrap();
let title_end = def.title_byte_end.unwrap();
assert!(!ctx.is_in_link_title(10), "URL should not be in title");
assert!(ctx.is_in_link_title(title_start), "Title start should be in title");
assert!(
ctx.is_in_link_title(title_start + 5),
"Middle of title should be in title"
);
assert!(ctx.is_in_link_title(title_end - 1), "End of title should be in title");
assert!(
!ctx.is_in_link_title(title_end),
"After title end should not be in title"
);
}
#[test]
fn test_is_in_link_title_without_title() {
let content = "[ref]: https://example.com\n\nSome content.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
let def = &ctx.reference_definitions()[0];
assert!(def.title_byte_start.is_none());
assert!(def.title_byte_end.is_none());
for i in 0..content.len() {
assert!(!ctx.is_in_link_title(i), "Position {i} should not be in title");
}
}
#[test]
fn test_is_in_link_title_multiple_refs() {
let content = r#"[ref1]: /url1 "Title One"
[ref2]: /url2
[ref3]: /url3 "Title Three"
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 3);
let ref1 = ctx.reference_definitions().iter().find(|r| r.id == "ref1").unwrap();
assert!(ref1.title_byte_start.is_some());
let ref2 = ctx.reference_definitions().iter().find(|r| r.id == "ref2").unwrap();
assert!(ref2.title_byte_start.is_none());
let ref3 = ctx.reference_definitions().iter().find(|r| r.id == "ref3").unwrap();
assert!(ref3.title_byte_start.is_some());
if let (Some(start), Some(end)) = (ref1.title_byte_start, ref1.title_byte_end) {
assert!(ctx.is_in_link_title(start + 1));
assert!(!ctx.is_in_link_title(end + 5));
}
if let (Some(start), Some(_end)) = (ref3.title_byte_start, ref3.title_byte_end) {
assert!(ctx.is_in_link_title(start + 1));
}
}
#[test]
fn test_is_in_link_title_single_quotes() {
let content = "[ref]: /url 'Single quoted title'\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
let def = &ctx.reference_definitions()[0];
if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
assert!(ctx.is_in_link_title(start));
assert!(ctx.is_in_link_title(start + 5));
assert!(!ctx.is_in_link_title(end));
}
}
#[test]
fn test_is_in_link_title_parentheses() {
let content = "[ref]: /url (Parenthesized title)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
if ctx.reference_definitions().is_empty() {
for i in 0..content.len() {
assert!(!ctx.is_in_link_title(i));
}
} else {
let def = &ctx.reference_definitions()[0];
if let (Some(start), Some(end)) = (def.title_byte_start, def.title_byte_end) {
assert!(ctx.is_in_link_title(start));
assert!(ctx.is_in_link_title(start + 5));
assert!(!ctx.is_in_link_title(end));
} else {
for i in 0..content.len() {
assert!(!ctx.is_in_link_title(i));
}
}
}
}
#[test]
fn test_is_in_link_title_no_refs() {
let content = "Just plain text without any reference definitions.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.reference_definitions().is_empty());
for i in 0..content.len() {
assert!(!ctx.is_in_link_title(i));
}
}
#[test]
fn test_math_spans_inline() {
let content = "Text with inline math $[f](x)$ in it.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1, "Should detect one inline math span");
let span = &math_spans[0];
assert!(!span.is_display, "Should be inline math, not display");
assert_eq!(span.content, "[f](x)", "Content should be extracted correctly");
}
#[test]
fn test_math_spans_display_single_line() {
let content = "$$X(\\zeta) = \\mathcal Z [x](\\zeta)$$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1, "Should detect one display math span");
let span = &math_spans[0];
assert!(span.is_display, "Should be display math");
assert!(
span.content.contains("[x](\\zeta)"),
"Content should contain the link-like pattern"
);
}
#[test]
fn test_math_spans_display_multiline() {
let content = "Before\n\n$$\n[x](\\zeta) = \\sum_k x(k)\n$$\n\nAfter";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1, "Should detect one display math span");
let span = &math_spans[0];
assert!(span.is_display, "Should be display math");
}
#[test]
fn test_is_in_math_span() {
let content = "Text $[f](x)$ more text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_start = content.find('$').unwrap();
let math_end = content.rfind('$').unwrap() + 1;
assert!(
ctx.is_in_math_span(math_start + 1),
"Position inside math span should return true"
);
assert!(
ctx.is_in_math_span(math_start + 3),
"Position inside math span should return true"
);
assert!(!ctx.is_in_math_span(0), "Position before math span should return false");
assert!(
!ctx.is_in_math_span(math_end + 1),
"Position after math span should return false"
);
}
#[test]
fn test_math_spans_mixed_with_code() {
let content = "Math $[f](x)$ and code `[g](y)` mixed";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
let code_spans = ctx.code_spans();
assert_eq!(math_spans.len(), 1, "Should have one math span");
assert_eq!(code_spans.len(), 1, "Should have one code span");
assert_eq!(math_spans[0].content, "[f](x)");
assert_eq!(code_spans[0].content, "[g](y)");
}
#[test]
fn test_math_spans_no_math() {
let content = "Regular text without any math at all.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert!(math_spans.is_empty(), "Should have no math spans");
}
#[test]
fn test_math_spans_multiple() {
let content = "First $a$ and second $b$ and display $$c$$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 3, "Should detect three math spans");
let inline_count = math_spans.iter().filter(|s| !s.is_display).count();
let display_count = math_spans.iter().filter(|s| s.is_display).count();
assert_eq!(inline_count, 2, "Should have two inline math spans");
assert_eq!(display_count, 1, "Should have one display math span");
}
#[test]
fn test_is_in_math_span_boundary_positions() {
let content = "$[f](x)$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1, "Should have one math span");
let span = &math_spans[0];
assert!(
ctx.is_in_math_span(span.byte_offset),
"Start position should be in span"
);
assert!(
ctx.is_in_math_span(span.byte_offset + 1),
"Position after start should be in span"
);
assert!(
ctx.is_in_math_span(span.byte_end - 1),
"Position at end-1 should be in span"
);
assert!(
!ctx.is_in_math_span(span.byte_end),
"Position at byte_end should NOT be in span (exclusive)"
);
}
#[test]
fn test_math_spans_at_document_start() {
let content = "$x$ text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1);
assert_eq!(math_spans[0].byte_offset, 0, "Math should start at byte 0");
}
#[test]
fn test_math_spans_at_document_end() {
let content = "text $x$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert_eq!(math_spans.len(), 1);
assert_eq!(math_spans[0].byte_end, content.len(), "Math should end at document end");
}
#[test]
fn test_math_spans_consecutive() {
let content = "$a$$b$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert!(!math_spans.is_empty(), "Should detect at least one math span");
for i in 0..content.len() {
assert!(ctx.is_in_math_span(i), "Position {i} should be in a math span");
}
}
#[test]
fn test_math_spans_currency_not_math() {
let content = "Price is $100";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
assert!(
math_spans.is_empty() || !math_spans.iter().any(|s| s.content.contains("100")),
"Unbalanced $ should not create math span containing 100"
);
}
#[test]
fn test_reference_lookup_o1_basic() {
let content = r#"[ref1]: /url1
[REF2]: /url2 "Title"
[Ref3]: /url3
Use [link][ref1] and [link][REF2]."#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 3);
assert_eq!(ctx.get_reference_url("ref1"), Some("/url1"));
assert_eq!(ctx.get_reference_url("REF1"), Some("/url1")); assert_eq!(ctx.get_reference_url("Ref1"), Some("/url1")); assert_eq!(ctx.get_reference_url("ref2"), Some("/url2"));
assert_eq!(ctx.get_reference_url("REF2"), Some("/url2"));
assert_eq!(ctx.get_reference_url("ref3"), Some("/url3"));
assert_eq!(ctx.get_reference_url("nonexistent"), None);
}
#[test]
fn test_reference_lookup_o1_empty_content() {
let content = "No references here.";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.reference_definitions().is_empty());
assert_eq!(ctx.get_reference_url("anything"), None);
}
#[test]
fn test_reference_lookup_o1_special_characters_in_id() {
let content = r#"[ref-with-dash]: /url1
[ref_with_underscore]: /url2
[ref.with.dots]: /url3
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.get_reference_url("ref-with-dash"), Some("/url1"));
assert_eq!(ctx.get_reference_url("ref_with_underscore"), Some("/url2"));
assert_eq!(ctx.get_reference_url("ref.with.dots"), Some("/url3"));
}
#[test]
fn test_reference_lookup_o1_unicode_id() {
let content = r#"[日本語]: /japanese
[émoji]: /emoji
"#;
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.get_reference_url("日本語"), Some("/japanese"));
assert_eq!(ctx.get_reference_url("émoji"), Some("/emoji"));
assert_eq!(ctx.get_reference_url("ÉMOJI"), Some("/emoji")); }
#[test]
fn test_is_in_link_title_multiple_ranges_binary_search() {
let content = "[a]: /url1 \"Title A\"\n[b]: /url2 \"Title B\"\n[c]: /url3 \"Title C\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 3, "Should have 3 reference defs");
if let (Some(start), Some(end)) = (
ctx.reference_definitions()[0].title_byte_start,
ctx.reference_definitions()[0].title_byte_end,
) {
assert!(ctx.is_in_link_title(start + 1), "Inside first title should return true");
assert!(!ctx.is_in_link_title(end), "At exclusive end should return false");
}
if let (Some(end_a), Some(start_b)) = (
ctx.reference_definitions()[0].title_byte_end,
ctx.reference_definitions()[1].title_byte_start,
) && end_a + 1 < start_b
{
assert!(!ctx.is_in_link_title(end_a + 1), "Between titles should return false");
}
if let Some(start) = ctx.reference_definitions()[2].title_byte_start {
assert!(ctx.is_in_link_title(start + 1), "Inside third title should return true");
}
}
#[test]
fn test_is_in_math_span_between_two_spans() {
let content = "$a$ text $b$";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let math_spans = ctx.math_spans();
if math_spans.len() >= 2 {
let between = math_spans[0].byte_end + 1;
assert!(
!ctx.is_in_math_span(between),
"Position between math spans should return false"
);
}
}
#[test]
fn test_code_span_at_line_start() {
let content = "Line one\n`code` end\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let spans = ctx.code_spans();
let line2_spans: Vec<_> = spans.iter().filter(|s| s.line == 2).collect();
assert!(!line2_spans.is_empty(), "Should detect code span on line 2");
assert_eq!(line2_spans[0].start_col, 0, "Code span should start at column 0");
}
#[test]
fn test_html_tag_at_byte_zero() {
let content = "<br/> text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert!(!tags.is_empty(), "Should detect HTML tag at byte 0");
assert_eq!(tags[0].line, 1, "Tag at byte 0 should be on line 1");
}
#[test]
fn test_html_block_pre_with_blank_line_marks_all_inner_lines() {
let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(3), "line 3 (`<pre>`) should be in html block");
assert!(
ctx.is_in_html_block(4),
"line 4 (blank inside pre) should be in html block"
);
assert!(
ctx.is_in_html_block(5),
"line 5 (`hello world`) should be in html block"
);
assert!(ctx.is_in_html_block(6), "line 6 (`</pre>`) should be in html block");
}
#[test]
fn test_html_block_textarea_with_blank_line_marks_all_inner_lines() {
let content = "<textarea>\n\ninner content\n</textarea>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(1), "line 1 (`<textarea>`) should be in html block");
assert!(ctx.is_in_html_block(2), "line 2 (blank) should be in html block");
assert!(
ctx.is_in_html_block(3),
"line 3 (inner content) should be in html block"
);
assert!(
ctx.is_in_html_block(4),
"line 4 (`</textarea>`) should be in html block"
);
}
#[test]
fn test_html_block_long_pre_exceeds_arbitrary_line_cap() {
let mut content = String::from("<pre>\n");
for i in 0..120 {
content.push_str(&format!("inner line {i}\n"));
}
content.push_str("</pre>\n");
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
for line_num in 1..=122 {
assert!(
ctx.is_in_html_block(line_num),
"line {line_num} of a 122-line <pre> block should be marked in_html_block",
);
}
}
#[test]
fn test_html_block_div_still_terminates_on_blank_line() {
let content = "<div>\ninner\n\nafter blank\n</div>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(1), "line 1 (`<div>`) should be in html block");
assert!(ctx.is_in_html_block(2), "line 2 (inner) should be in html block");
assert!(
!ctx.is_in_html_block(4),
"line 4 (`after blank`) must NOT be in html block"
);
}
#[test]
fn test_html_block_gt_line_is_html_text_outside_a_blockquote() {
let content = "<table>\n <td>\n <a\n >a</a\n >\n text after\n </td>\n</table>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for line in 1..=8 {
let info = ctx.line_info(line).unwrap();
assert!(info.in_html_block, "line {line} should be in the html block");
assert!(info.blockquote.is_none(), "line {line} must not be a blockquote");
assert!(!info.is_blank, "line {line} must not be blank");
}
}
#[test]
fn test_html_block_gt_line_below_the_opener_column_is_still_a_blockquote() {
let content = "- item\n\n <div>\n inner\n> quote\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(4), "line 4 should be in the html block");
assert!(ctx.line_info(5).unwrap().blockquote.is_some(), "line 5 is a blockquote");
}
#[test]
fn test_html_block_closed_on_its_opening_line_runs_to_a_blank_line() {
let content = "<div>a</div>\ntext\n\nAfter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(1));
assert!(ctx.is_in_html_block(2), "the line under the opener is HTML text");
assert!(!ctx.is_in_html_block(4), "the blank line ends the block");
let ctx = LintContext::new("<pre>a</pre>\ntext\n", MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(1));
assert!(!ctx.is_in_html_block(2));
}
#[test]
fn test_html_block_opened_by_a_closing_tag_runs_to_a_blank_line() {
let content = "Intro\n</div>\n# text\n\nAfter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_html_block(1));
assert!(ctx.is_in_html_block(2));
assert!(ctx.is_in_html_block(3), "the line under the closing tag is HTML text");
assert!(!ctx.is_in_html_block(5), "the blank line ends the block");
let ctx = LintContext::new("</pre>\ntext\n\nAfter\n", MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_block(2));
assert!(!ctx.is_in_html_block(4), "the blank line ends the block");
}
#[test]
fn test_html_block_complete_tag_alone_on_a_line_opens_a_block() {
let content = "Intro\n\n<math display=\"block\">\n <mi>x</mi>\n</math>\n\nAfter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for line in 3..=5 {
assert!(ctx.is_in_html_block(line), "line {line} should be in the html block");
}
assert!(!ctx.is_in_html_block(7), "the blank line ends the block");
let ctx = LintContext::new("Intro\n<span>\ntext\n", MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_html_block(2));
let ctx = LintContext::new("---\nx: 1\n\n<span>\n---\ntext\n", MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_html_block(4));
assert!(!ctx.is_in_html_block(6));
let ctx = LintContext::new("- <span>\n text\n", MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_html_block(1));
assert!(ctx.line_info(1).unwrap().list_item.is_some());
let ctx = LintContext::new("<Card>\ntext\n", MarkdownFlavor::MDX, None);
assert!(!ctx.is_in_html_block(2));
}
#[test]
fn test_html_block_unclosed_pre_extends_to_eof() {
let content = "<pre>\nline a\n\nline b\nline c\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for line_num in 1..=5 {
assert!(
ctx.is_in_html_block(line_num),
"line {line_num} of an unclosed <pre> should extend to EOF",
);
}
}
#[test]
fn test_html_block_nested_pre_with_split_closing_tag_ends_with_outer_block() {
let content = "<table>\n<tr><td>\n<pre>\nx</pre\n>\n</td></tr>\n</table>\n\nafter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.is_in_html_block(3),
"line 3 (`<pre>`) is content of the <table> block"
);
assert!(ctx.is_in_html_block(7), "line 7 (`</table>`) is still in the block");
assert!(
!ctx.is_in_html_block(9),
"line 9 (`after`) must be ordinary markdown: the <table> block ended at the blank line"
);
}
#[test]
fn test_html_block_top_level_pre_with_split_closing_tag_still_extends_to_eof() {
let content = "<pre>\nx</pre\n>\n\nafter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
for line_num in 1..=5 {
assert!(
ctx.is_in_html_block(line_num),
"line {line_num} of a top-level <pre> with a split end tag should reach EOF",
);
}
}
#[test]
fn test_html_block_nested_pre_does_not_outlive_a_type_6_parent() {
let content = "<div>\n<pre>\nx\n</div>\n\nafter\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.is_in_html_block(2),
"line 2 (`<pre>`) is content of the <div> block"
);
assert!(
!ctx.is_in_html_block(6),
"line 6 (`after`) must be ordinary markdown: the <div> block ended at the blank line"
);
}
#[test]
fn test_link_no_title_yields_none() {
let ctx = LintContext::new("[t](https://x.com)\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert!(ctx.links()[0].title.is_none(), "no title delimiter must be None");
}
#[test]
fn test_link_explicit_empty_double_quote_title_yields_some_empty() {
let ctx = LintContext::new(r#"[t](https://x.com "")"#, MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert_eq!(
ctx.links()[0].title.as_deref(),
Some(""),
"`\"\"` must be preserved as Some(\"\"), not collapsed to None"
);
}
#[test]
fn test_link_explicit_empty_single_quote_title_yields_some_empty() {
let ctx = LintContext::new("[t](https://x.com '')\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert_eq!(ctx.links()[0].title.as_deref(), Some(""));
}
#[test]
fn test_link_explicit_empty_paren_title_yields_some_empty() {
let ctx = LintContext::new("[t](https://x.com ())\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert_eq!(ctx.links()[0].title.as_deref(), Some(""));
}
#[test]
fn test_image_explicit_empty_title_yields_some_empty() {
let ctx = LintContext::new(r#""#, MarkdownFlavor::Standard, None);
assert_eq!(ctx.images().len(), 1);
assert_eq!(ctx.images()[0].title.as_deref(), Some(""));
}
#[test]
fn test_link_non_empty_title_is_unaffected() {
let ctx = LintContext::new(r#"[t](https://x.com "real")"#, MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert_eq!(ctx.links()[0].title.as_deref(), Some("real"));
}
#[test]
fn test_link_title_with_trailing_whitespace_inside_parens() {
let ctx = LintContext::new(r#"[t](https://x.com "" )"#, MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1);
assert_eq!(ctx.links()[0].title.as_deref(), Some(""));
}
#[test]
fn test_reference_link_empty_title_in_definition() {
let content = "[t][r]\n\n[r]: https://x.com \"\"\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.reference_definitions().len(), 1);
assert_eq!(ctx.reference_definitions()[0].title.as_deref(), Some(""));
}
#[test]
fn test_reference_link_in_an_html_block_is_not_parsed() {
let content = "<details>\n<summary>[link][ref]</summary>\n</details>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.links().is_empty(),
"a reference link inside an HTML block renders literally, so it is not a link: {:?}",
ctx.links()
);
}
#[test]
fn test_inline_link_in_an_html_block_is_not_parsed() {
let content = "<details>\n<summary>[link](https://example.com)</summary>\n</details>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.links().is_empty(), "unexpected links: {:?}", ctx.links());
}
#[test]
fn test_reference_image_in_an_html_block_is_not_parsed() {
let content = "<details>\n<summary>![alt][img]</summary>\n</details>\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.images().is_empty(),
"a reference image inside an HTML block renders literally: {:?}",
ctx.images()
);
}
#[test]
fn test_reference_link_in_a_markdown_attribute_block_is_still_parsed() {
let content = "<div class=\"note\" markdown=\"1\">\n[link][ref]\n</div>\n\n[ref]: https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.links().len(),
1,
"the body of a markdown=\"1\" block holds real markdown: {:?}",
ctx.links()
);
assert_eq!(ctx.links()[0].line, 2);
assert!(ctx.links()[0].is_reference);
}
#[test]
fn test_reference_image_in_a_markdown_attribute_block_is_still_parsed() {
let content = "<div class=\"note\" markdown=\"1\">\n![alt][img]\n</div>\n\n[img]: https://example.com/i.png\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
ctx.images().len(),
1,
"the body of a markdown=\"1\" block holds real markdown: {:?}",
ctx.images()
);
assert_eq!(ctx.images()[0].line, 2);
}
#[test]
fn test_reference_link_after_an_html_block_is_still_parsed() {
let content = "<details>\n<summary>[dead][ref]</summary>\n</details>\n\n[live][ref]\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.links().len(), 1, "unexpected links: {:?}", ctx.links());
assert_eq!(ctx.links()[0].text, "live");
}
#[test]
fn test_pandoc_flavor_detects_div_blocks() {
let content = "::: {.callout-note}\nA note.\n:::\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
assert!(
ctx.is_in_div_block(content.find(":::").unwrap()),
"Pandoc flavor should detect div block ranges"
);
}
#[test]
fn test_pandoc_flavor_detects_citations() {
let content = "See [@smith2020] for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("[@smith2020]").unwrap() + 1;
assert!(ctx.is_in_citation(pos), "Pandoc flavor should detect citation ranges");
}
#[test]
fn test_pandoc_flavor_detects_inline_footnotes() {
let content = "Text ^[note here] more.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("^[").unwrap() + 1;
assert!(
ctx.is_in_inline_footnote(pos),
"Pandoc flavor should detect inline footnote ranges"
);
}
#[test]
fn test_standard_flavor_skips_inline_footnotes() {
let content = "Text ^[note here] more.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("^[").unwrap() + 1;
assert!(
!ctx.is_in_inline_footnote(pos),
"Standard flavor should not detect inline footnote ranges"
);
}
#[test]
fn test_pandoc_flavor_resolves_implicit_header_reference() {
let content = "# My Section\n\nSee [My Section] for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
assert!(ctx.matches_implicit_header_reference("My Section"));
assert!(!ctx.matches_implicit_header_reference("Nonexistent"));
}
#[test]
fn test_standard_flavor_does_not_resolve_implicit_header_reference() {
let content = "# My Section\n\nSee [My Section] for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.matches_implicit_header_reference("My Section"));
}
#[test]
fn test_pandoc_flavor_detects_example_list_markers() {
use crate::config::MarkdownFlavor;
let content = "(@) First item.\n(@good) Second item.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("(@)").unwrap();
assert!(ctx.is_in_example_list_marker(pos));
let pos2 = content.find("(@good)").unwrap();
assert!(ctx.is_in_example_list_marker(pos2));
}
#[test]
fn test_pandoc_flavor_detects_example_references() {
use crate::config::MarkdownFlavor;
let content = "(@good) First.\n\nAs shown in (@good), it works.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let ref_pos = content.rfind("(@good)").unwrap();
assert!(ctx.is_in_example_reference(ref_pos));
let marker_pos = content.find("(@good)").unwrap();
assert!(!ctx.is_in_example_reference(marker_pos));
}
#[test]
fn test_standard_flavor_skips_example_lists() {
use crate::config::MarkdownFlavor;
let content = "(@) First.\nAs shown in (@good), it works.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("(@)").unwrap();
assert!(!ctx.is_in_example_list_marker(pos));
let ref_pos = content.find("(@good)").unwrap();
assert!(!ctx.is_in_example_reference(ref_pos));
}
#[test]
fn test_pandoc_flavor_detects_subscript() {
use crate::config::MarkdownFlavor;
let content = "H~2~O is water.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("~2~").unwrap() + 1;
assert!(ctx.is_in_subscript_or_superscript(pos));
}
#[test]
fn test_pandoc_flavor_detects_superscript() {
use crate::config::MarkdownFlavor;
let content = "2^10^ is 1024.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("^10^").unwrap() + 1;
assert!(ctx.is_in_subscript_or_superscript(pos));
}
#[test]
fn test_pandoc_flavor_does_not_match_strikethrough() {
use crate::config::MarkdownFlavor;
let content = "This is ~~struck~~.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("~~struck~~").unwrap() + 2;
assert!(!ctx.is_in_subscript_or_superscript(pos));
}
#[test]
fn test_standard_flavor_skips_sub_super() {
use crate::config::MarkdownFlavor;
let content = "H~2~O and 2^10^.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("~2~").unwrap() + 1;
assert!(!ctx.is_in_subscript_or_superscript(pos));
}
#[test]
fn test_pandoc_flavor_detects_inline_code_attribute() {
use crate::config::MarkdownFlavor;
let content = "Use `print()`{.python} for output.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("{.python}").unwrap() + 1;
assert!(ctx.is_in_inline_code_attr(pos));
}
#[test]
fn test_pandoc_flavor_skips_bare_brace_block() {
use crate::config::MarkdownFlavor;
let content = "Use {.example} for the class.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("{.example}").unwrap() + 1;
assert!(!ctx.is_in_inline_code_attr(pos));
}
#[test]
fn test_standard_flavor_skips_inline_code_attribute() {
use crate::config::MarkdownFlavor;
let content = "Use `print()`{.python} for output.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("{.python}").unwrap() + 1;
assert!(!ctx.is_in_inline_code_attr(pos));
}
#[test]
fn test_pandoc_flavor_detects_bracketed_span() {
use crate::config::MarkdownFlavor;
let content = "This is [some text]{.smallcaps} here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("[some text]").unwrap();
assert!(ctx.is_in_bracketed_span(pos));
}
#[test]
fn test_pandoc_flavor_skips_link() {
use crate::config::MarkdownFlavor;
let content = "A [link](http://example.com) here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("[link]").unwrap();
assert!(!ctx.is_in_bracketed_span(pos));
}
#[test]
fn test_standard_flavor_skips_bracketed_span() {
use crate::config::MarkdownFlavor;
let content = "This is [some text]{.smallcaps} here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("[some text]").unwrap();
assert!(!ctx.is_in_bracketed_span(pos));
}
#[test]
fn test_pandoc_flavor_detects_line_block() {
use crate::config::MarkdownFlavor;
let content = "| The Lord of the Rings\n| by J.R.R. Tolkien\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("Lord").unwrap();
assert!(ctx.is_in_line_block(pos));
}
#[test]
fn test_pandoc_flavor_line_block_does_not_match_pipe_table() {
use crate::config::MarkdownFlavor;
let content = "| col1 | col2 |\n|------|------|\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("col1").unwrap();
assert!(!ctx.is_in_line_block(pos));
}
#[test]
fn test_standard_flavor_skips_line_block() {
use crate::config::MarkdownFlavor;
let content = "| The Lord of the Rings\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("Lord").unwrap();
assert!(!ctx.is_in_line_block(pos));
}
#[test]
fn test_pandoc_flavor_line_block_continuation_is_in_block() {
use crate::config::MarkdownFlavor;
let content = "| First line\n continuation here\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("continuation").unwrap();
assert!(ctx.is_in_line_block(pos));
}
#[test]
fn test_pandoc_flavor_detects_pipe_table_caption_below() {
use crate::config::MarkdownFlavor;
let content = "\
| col1 | col2 |
|------|------|
| a | b |
: My caption
";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("My caption").unwrap();
assert!(ctx.is_in_pipe_table_caption(pos));
}
#[test]
fn test_pandoc_flavor_definition_term_is_not_pipe_table_caption() {
use crate::config::MarkdownFlavor;
let content = "Term\n: definition\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("definition").unwrap();
assert!(!ctx.is_in_pipe_table_caption(pos));
}
#[test]
fn test_standard_flavor_skips_pipe_table_caption() {
use crate::config::MarkdownFlavor;
let content = "\
| col1 |
|------|
| a |
: Caption
";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("Caption").unwrap();
assert!(!ctx.is_in_pipe_table_caption(pos));
}
#[test]
fn test_pandoc_flavor_detects_pipe_table_caption_above() {
use crate::config::MarkdownFlavor;
let content = "\
: Caption first
| col1 | col2 |
|------|------|
| a | b |
";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("Caption first").unwrap();
assert!(ctx.is_in_pipe_table_caption(pos));
}
#[test]
fn test_pandoc_flavor_detects_metadata_block_at_start() {
use crate::config::MarkdownFlavor;
let content = "---\ntitle: Doc\n---\n\nBody.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("title").unwrap();
assert!(ctx.is_in_pandoc_metadata(pos));
let body_pos = content.find("Body").unwrap();
assert!(!ctx.is_in_pandoc_metadata(body_pos));
}
#[test]
fn test_pandoc_flavor_detects_mid_document_metadata() {
use crate::config::MarkdownFlavor;
let content = "Intro.\n\n---\nauthor: X\n---\n\nBody.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find("author").unwrap();
assert!(ctx.is_in_pandoc_metadata(pos));
}
#[test]
fn test_standard_flavor_skips_pandoc_metadata() {
use crate::config::MarkdownFlavor;
let content = "---\ntitle: Doc\n---\n\nBody.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("title").unwrap();
assert!(!ctx.is_in_pandoc_metadata(pos));
}
#[test]
fn test_pandoc_flavor_detects_grid_table() {
use crate::config::MarkdownFlavor;
let content = "\
+---+---+
| a | b |
+---+---+
| 1 | 2 |
+---+---+
";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let pos = content.find('a').unwrap();
assert!(ctx.is_in_grid_table(pos));
}
#[test]
fn test_pandoc_flavor_grid_table_excludes_surrounding_text() {
use crate::config::MarkdownFlavor;
let content = "Before.\n\n+---+---+\n| a | b |\n+---+---+\n\nAfter.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let before_pos = content.find("Before").unwrap();
let after_pos = content.find("After").unwrap();
assert!(!ctx.is_in_grid_table(before_pos));
assert!(!ctx.is_in_grid_table(after_pos));
}
#[test]
fn test_standard_flavor_skips_grid_table() {
use crate::config::MarkdownFlavor;
let content = "+---+---+\n| a | b |\n+---+---+\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find('a').unwrap();
assert!(!ctx.is_in_grid_table(pos));
}
#[test]
fn test_pandoc_flavor_detects_multi_line_table() {
use crate::config::MarkdownFlavor;
let content = "\
-------------------------------------------------------------
Centered Default Right Left
Header Aligned Aligned Aligned
----------- ------- --------------- -------------------------
First row 12.0 Example of a row that
spans multiple lines.
Second row 5.0 Here's another one. Note
the blank line between
rows.
-------------------------------------------------------------
";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let first_pos = content.find("First").unwrap();
let second_pos = content.find("Second").unwrap();
assert!(ctx.is_in_multi_line_table(first_pos));
assert!(ctx.is_in_multi_line_table(second_pos));
assert!(ctx.is_in_multi_line_table(0));
}
#[test]
fn test_pandoc_flavor_multi_line_table_excludes_surrounding_text() {
use crate::config::MarkdownFlavor;
let content = "\
Before text.
-------------------------------------------------------------
Centered Default Right Left
Header Aligned Aligned Aligned
----------- ------- --------------- -------------------------
First row 12.0 Example.
-------------------------------------------------------------
After text.
";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let before_pos = content.find("Before").unwrap();
let after_pos = content.find("After").unwrap();
let inside_pos = content.find("First").unwrap();
assert!(!ctx.is_in_multi_line_table(before_pos));
assert!(!ctx.is_in_multi_line_table(after_pos));
assert!(ctx.is_in_multi_line_table(inside_pos));
}
#[test]
fn test_standard_flavor_skips_multi_line_table() {
use crate::config::MarkdownFlavor;
let content = "\
-------------------------------------------------------------
Centered Default Right Left
Header Aligned Aligned Aligned
----------- ------- --------------- -------------------------
First row 12.0 Example.
-------------------------------------------------------------
";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let pos = content.find("First").unwrap();
assert!(!ctx.is_in_multi_line_table(pos));
}
#[test]
fn test_front_matter_is_not_scanned_for_html_comments() {
let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n\nText --> text\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.html_comment_ranges().is_empty(),
"got: {:?}",
ctx.html_comment_ranges()
);
assert!(ctx.unterminated_html_comment().is_none());
}
#[test]
fn test_html_comment_in_the_body_is_found_after_front_matter() {
let content = "---\nauthor: \"a <!-- b\"\n---\n\n<!-- a body comment -->\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let ranges = ctx.html_comment_ranges();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, content.find("<!-- a body").unwrap());
}
#[test]
fn test_unterminated_html_comment_offset() {
let content = "# Title\n\n<!-- never closed\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let opener = content.find("<!--").unwrap();
assert_eq!(ctx.unterminated_html_comment(), Some(opener));
let ranges = ctx.html_comment_ranges();
assert_eq!(ranges.len(), 1, "got: {ranges:?}");
assert_eq!((ranges[0].start, ranges[0].end), (opener, content.len()));
}
#[test]
fn test_unterminated_inline_html_comment_has_no_range() {
let content = "# Title\n\nSome prose <!-- never closed\n\nMore text.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.unterminated_html_comment(), Some(content.find("<!--").unwrap()));
assert!(
ctx.html_comment_ranges().is_empty(),
"an inline opener hides nothing, got: {:?}",
ctx.html_comment_ranges()
);
}
#[test]
fn test_unterminated_html_comment_range_stops_at_its_container() {
let content = "> <!-- never closed\n> inside\n\nVisible text.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let ranges = ctx.html_comment_ranges();
assert_eq!(ranges.len(), 1, "got: {ranges:?}");
assert_eq!(ranges[0].start, content.find("<!--").unwrap());
assert!(
ranges[0].end < content.find("Visible").unwrap(),
"range {:?} should stop before the text after the quote",
ranges[0]
);
}
#[test]
fn test_html_opener_a_closed_obsidian_pair_hides_gets_no_range() {
use crate::config::MarkdownFlavor;
let content = "%% note\n<!-- hidden\n%%\n\nVisible text.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert_eq!(ctx.unterminated_html_comment(), None);
assert_eq!(ctx.unterminated_obsidian_comment(), None);
assert!(
ctx.html_comment_ranges().is_empty(),
"got: {:?}",
ctx.html_comment_ranges()
);
let visible = content.find("Visible").unwrap();
assert!(!ctx.is_in_html_comment(visible));
assert!(!ctx.is_in_obsidian_comment(visible));
}
#[test]
fn test_obsidian_delimiters_inside_an_unclosed_html_block_are_comment_text() {
use crate::config::MarkdownFlavor;
let content = "<!-- an aside\n\n%% a note\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert_eq!(ctx.unterminated_html_comment(), Some(0));
assert_eq!(ctx.unterminated_obsidian_comment(), None);
assert!(ctx.is_in_html_comment(content.find("%%").unwrap()));
assert!(!ctx.is_in_obsidian_comment(content.find("%%").unwrap()));
}
#[test]
fn test_the_comment_syntax_that_opens_first_hides_the_other() {
use crate::config::MarkdownFlavor;
let content = "%% note <!-- hidden %%\n\n<!-- closed -->\n\nVisible text.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert_eq!(ctx.unterminated_obsidian_comment(), None);
assert!(ctx.is_in_obsidian_comment(content.find("hidden").unwrap()));
let visible = content.find("Visible").unwrap();
assert!(!ctx.is_in_obsidian_comment(visible));
}
#[test]
fn test_obsidian_delimiter_behind_a_comment_on_the_same_line_is_comment_text() {
use crate::config::MarkdownFlavor;
let content = "text <!-- %% --> tail\n\n%% a note\n\nBelow.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let hidden = content.find("%%").unwrap();
let opener = content.rfind("%%").unwrap();
assert!(!ctx.is_in_obsidian_comment(hidden));
assert_eq!(ctx.unterminated_obsidian_comment(), Some(opener));
assert!(ctx.is_in_obsidian_comment(content.find("Below.").unwrap()));
}
#[test]
fn test_obsidian_comments_are_repaired_around_an_unclosed_html_block() {
use crate::config::MarkdownFlavor;
let content = "> <!-- an aside\n> %% hidden\n\n%% a note\n\nBelow.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
let opener = content.rfind("%%").unwrap();
assert_eq!(ctx.unterminated_html_comment(), Some(content.find("<!--").unwrap()));
assert_eq!(ctx.unterminated_obsidian_comment(), Some(opener));
assert!(ctx.is_in_html_comment(content.find("%% hidden").unwrap()));
assert!(ctx.is_in_obsidian_comment(content.find("Below.").unwrap()));
}
#[test]
fn test_unterminated_obsidian_comment_offset() {
let content = "# Title\n\n%% never closed\n";
let obsidian = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert_eq!(
obsidian.unterminated_obsidian_comment(),
Some(content.find("%%").unwrap())
);
let standard = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
standard.unterminated_obsidian_comment(),
None,
"%% is ordinary text outside the Obsidian flavor"
);
}
#[test]
fn test_front_matter_is_not_scanned_for_obsidian_comments() {
let content = "---\ntitle: \"50%% off\"\n---\n\n# Title\n\nText\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert_eq!(ctx.unterminated_obsidian_comment(), None);
assert!(!ctx.is_in_obsidian_comment(content.find("# Title").unwrap()));
assert!(!ctx.is_in_obsidian_comment(content.find("Text").unwrap()));
}
#[test]
fn test_obsidian_comment_in_the_body_is_found_after_front_matter() {
let content = "---\ntitle: \"50%% off\"\n---\n\nText %% a note %% more\n";
let ctx = LintContext::new(content, MarkdownFlavor::Obsidian, None);
assert!(ctx.is_in_obsidian_comment(content.find("a note").unwrap()));
assert!(!ctx.is_in_obsidian_comment(content.find("more").unwrap()));
}
#[test]
fn test_delimiters_in_an_indented_code_block_are_not_comment_delimiters() {
let content = "Intro text.\n\n <!-- open\n\nMiddle text.\n\n --> close\n\nEnd.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.html_comment_ranges().is_empty(),
"got: {:?}",
ctx.html_comment_ranges()
);
assert!(!ctx.is_in_html_comment(content.find("Middle").unwrap()));
assert_eq!(ctx.unterminated_html_comment(), None);
}
#[test]
fn test_unclosed_comment_in_an_admonition_hides_the_rest_of_its_body() {
let content = "!!! note\n <!-- hidden\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert_eq!(ctx.unterminated_html_comment(), Some(content.find("<!--").unwrap()));
assert!(ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
#[test]
fn test_unclosed_comment_in_an_admonition_stops_at_the_next_admonition() {
let content = "!!! note\n <!-- hidden\n hidden text\n\n!!! warning\n visible text\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(ctx.is_in_html_comment(content.find("hidden text").unwrap()));
assert!(!ctx.is_in_html_comment(content.find("visible text").unwrap()));
}
#[test]
fn test_unclosed_comment_in_a_markdown_html_block_hides_the_rest_of_it() {
let content = "<div markdown>\n\n <!-- hidden\n visit https://example.com\n\n</div>\n\nAfter.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
assert!(!ctx.is_in_html_comment(content.find("After.").unwrap()));
}
#[test]
fn test_an_admonition_comment_is_read_across_a_blank_line_after_the_marker() {
let content = "!!! note\n\n <!-- hidden\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
#[test]
fn test_a_comment_indented_inside_an_admonition_is_still_a_comment() {
let content = "!!! note\n\n <!-- a note -->\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(ctx.is_in_html_comment(content.find("a note").unwrap()));
assert!(!ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
#[test]
fn test_an_opener_partway_through_an_admonition_line_opens_nothing() {
let content = "!!! note\n Some text <!-- never closed\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert_eq!(ctx.unterminated_html_comment(), Some(content.find("<!--").unwrap()));
assert!(!ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
#[test]
fn test_delimiters_in_a_fence_inside_an_admonition_are_not_comment_delimiters() {
let content = "!!! note\n\n ```\n <!-- a sample opener\n ```\n\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert_eq!(ctx.unterminated_html_comment(), None);
assert!(!ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
#[test]
fn test_an_unclosed_comment_in_an_admonition_hides_a_fence_below_it() {
let content = "!!! note\n <!-- hidden\n ```\n code\n ```\n visit https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
assert!(ctx.is_in_html_comment(content.find("code").unwrap()));
assert!(ctx.is_in_html_comment(content.find("https://example.com").unwrap()));
}
fn horizontal_rule_line(ctx: &LintContext) -> Option<usize> {
let marked: Vec<usize> = ctx
.lines
.iter()
.enumerate()
.filter(|(_, line)| line.is_horizontal_rule)
.map(|(i, _)| i + 1)
.collect();
assert!(marked.len() <= 1, "expected at most one marked line, got {marked:?}");
marked.first().copied()
}
#[test]
fn test_a_break_in_a_block_that_hides_its_content_is_not_a_horizontal_rule() {
for (content, flavor) in [
("Text.\n\n<!--\n***\n-->\n\nMore.\n", MarkdownFlavor::Standard),
("Text.\n\n<div>\n***\n</div>\n\nMore.\n", MarkdownFlavor::Standard),
("Text.\n\n$$\n***\n$$\n\nMore.\n", MarkdownFlavor::Standard),
("Text.\n\n::: mermaid\n***\n:::\n\nMore.\n", MarkdownFlavor::AzureDevOps),
("Text.\n\n{/*\n***\n*/}\n\nMore.\n", MarkdownFlavor::MDX),
("Text.\n\n%%\n***\n%%\n\nMore.\n", MarkdownFlavor::Obsidian),
] {
let ctx = LintContext::new(content, flavor, None);
assert_eq!(
horizontal_rule_line(&ctx),
None,
"line 4 was still a horizontal rule in {content:?} ({flavor:?})"
);
}
}
#[test]
fn test_a_break_in_a_container_whose_body_is_markdown_stays_a_horizontal_rule() {
for (content, flavor) in [
("Text.\n\n***\n\nMore.\n", MarkdownFlavor::Standard),
("::: note\nText.\n***\nMore.\n:::\n", MarkdownFlavor::Pandoc),
("<Tabs>\nText.\n***\nMore.\n</Tabs>\n", MarkdownFlavor::MDX),
("/// note\nText.\n***\nMore.\n///\n", MarkdownFlavor::MkDocs),
("> Text.\n> ***\n> More.\n", MarkdownFlavor::Standard),
(
"<div markdown>\n\nText.\n***\nMore.\n\n</div>\n",
MarkdownFlavor::Standard,
),
(
"<div markdown>\n\nText.\n***\nMore.\n\n</div>\n",
MarkdownFlavor::MkDocs,
),
] {
let ctx = LintContext::new(content, flavor, None);
assert!(
horizontal_rule_line(&ctx).is_some(),
"the break stopped being a horizontal rule in {content:?} ({flavor:?})"
);
}
}
#[test]
fn test_a_break_written_flush_against_an_html_tag_is_raw_html() {
for (content, flavor) in [
("<div markdown>\nText.\n***\nMore.\n</div>\n", MarkdownFlavor::Standard),
(
"<div markdown=\"1\">\nText.\n***\nMore.\n</div>\n",
MarkdownFlavor::MkDocs,
),
] {
let ctx = LintContext::new(content, flavor, None);
assert_eq!(
horizontal_rule_line(&ctx),
None,
"the raw HTML was still a horizontal rule in {content:?} ({flavor:?})"
);
}
}
#[test]
fn heading_slug_text_keeps_the_whitespace_an_anchor_element_leaves() {
let content = "# Alpha <a id=\"x\"></a>\n\nBeta <a id=\"y\"></a>\n---\n\n> ## Foo <a id=\"z\"></a> Bar\n\n#### Plain {#custom}\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx
.headings()
.map(|parsed| {
(
parsed.heading.text.as_str(),
parsed.heading.slug_text.as_str(),
parsed.heading.custom_id.as_deref(),
)
})
.collect();
assert_eq!(
headings,
vec![
("Alpha", "Alpha ", None),
("Beta", "Beta ", None),
("Foo Bar", "Foo Bar", None),
("Plain", "Plain", Some("custom")),
]
);
}
fn definition_list_lines(ctx: &LintContext) -> Vec<bool> {
(1..=ctx.lines.len())
.map(|line| ctx.is_in_definition_list(line))
.collect()
}
#[test]
fn a_definition_list_ends_with_its_last_definition() {
let content = "Term\n: Aliquam metus.\n\n Duis mollis.\n\nAfter the list.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(definition_list_lines(&ctx), [true, true, true, true, false, false]);
assert_eq!(
(1..=6).map(|line| ctx.is_definition_term(line)).collect::<Vec<_>>(),
[true, false, false, false, false, false]
);
assert_eq!(
ctx.definition_text_at(2),
Some(&DefinitionText {
start_line: 2,
end_line: 2,
marker_prefix_len: Some(4),
})
);
assert_eq!(ctx.definition_text_at(3), None);
assert_eq!(
ctx.definition_text_at(4),
Some(&DefinitionText {
start_line: 4,
end_line: 4,
marker_prefix_len: None,
})
);
}
#[test]
fn a_colon_touching_its_text_opens_no_definition() {
let content = "T\n:warning: x\n\n::: {.note}\nBody\n:::\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(definition_list_lines(&ctx), [false; 6]);
assert!((1..=6).all(|line| !ctx.is_definition_term(line) && ctx.definition_text_at(line).is_none()));
let mixed = "A\n:x\n\nB\n: y\n";
let ctx = LintContext::new(mixed, MarkdownFlavor::Standard, None);
assert_eq!(definition_list_lines(&ctx), [false, false, false, true, true]);
assert!(!ctx.is_definition_term(1));
assert!(ctx.is_definition_term(4));
assert!(ctx.definition_text_at(2).is_none());
assert_eq!(
ctx.definition_text_at(5).map(|text| text.marker_prefix_len),
Some(Some(4))
);
}
#[test]
fn definition_texts_are_found_in_containers_and_after_a_tab() {
let content =
"> Term\n> :\tQuoted.\n\n- Item\n\n Term\n\n : Listed,\n lazily continued.\n\nTerm\n:\n Deferred.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_definition_term(1));
assert_eq!(
ctx.definition_text_at(2),
Some(&DefinitionText {
start_line: 2,
end_line: 2,
marker_prefix_len: Some(4),
})
);
assert!(ctx.is_definition_term(6));
assert_eq!(
ctx.definition_text_at(9),
Some(&DefinitionText {
start_line: 8,
end_line: 9,
marker_prefix_len: Some(6),
})
);
assert!(ctx.is_definition_term(11));
assert!(ctx.is_in_definition_list(12));
assert_eq!(ctx.definition_text_at(12), None);
assert_eq!(
ctx.definition_text_at(13),
Some(&DefinitionText {
start_line: 13,
end_line: 13,
marker_prefix_len: None,
})
);
}
#[test]
fn a_list_nested_before_the_first_kept_term_still_counts() {
let content = "A\n:x\n\n B\n : nested\n\nC\n: kept\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
definition_list_lines(&ctx),
[false, false, false, true, true, false, true, true]
);
assert_eq!(
ctx.definition_text_at(5).map(|text| text.marker_prefix_len),
Some(Some(8))
);
}
#[test]
fn a_term_whose_colon_touches_its_text_is_free_between_definition_items() {
let content = "A\n: valid\n\nB is prose.\n\n:x\n\nC\n: valid\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(
definition_list_lines(&ctx),
[true, true, false, false, false, false, false, true, true]
);
assert!(!ctx.is_definition_term(4));
}
#[test]
fn a_definition_continues_after_a_list_nested_in_it() {
let content = "Term\n: Outer.\n\n Inner\n : nested\n\n Back in the outer definition.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(definition_list_lines(&ctx), [true; 7]);
}
#[test]
fn a_code_span_only_a_reader_without_math_sees_is_in_a_code_span_under_either_reading() {
let content = "A $x `y$ and z` end.\n\nThen `w` code.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let only_without_math = content.find("and").unwrap();
let inside_rumdl_span = content.find('w').unwrap();
let outside = content.find("end").unwrap();
assert!(!ctx.is_in_code_span_byte(only_without_math));
assert!(ctx.is_in_code_span_byte_with_or_without_math(only_without_math));
assert!(ctx.is_in_code_span_byte_with_or_without_math(inside_rumdl_span));
assert!(!ctx.is_in_code_span_byte_with_or_without_math(outside));
}
#[test]
fn is_code_span_text_byte_covers_every_reading_of_the_spans() {
let content = "pre `a\tb` post\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.is_code_span_text_byte(content.find('\t').unwrap()));
assert!(!ctx.is_code_span_text_byte(content.find("pre").unwrap()));
assert!(!ctx.is_code_span_text_byte(content.find("post").unwrap()));
let content = "A $x `y$\tand z` end.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.is_in_code_span_byte(content.find('\t').unwrap()));
assert!(ctx.is_code_span_text_byte(content.find('\t').unwrap()));
assert!(!ctx.is_code_span_text_byte(content.find("end").unwrap()));
let content = "!!! note\n\n `a\tb`\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let tab = content.find('\t').unwrap();
assert!(!ctx.is_in_code_span_byte(tab));
assert!(ctx.is_byte_offset_in_code_span(tab));
assert!(ctx.is_code_span_text_byte(tab));
}
#[test]
fn mdx_inline_comments_do_not_hide_visible_heading_metadata() {
for (source, raw, depth) in [
("# hello {/* c */} world\n", "hello {/* c */} world", 0),
(" # hello {/* c */} world\n", "hello {/* c */} world", 0),
("> # hello {/* c */} world\n", "hello {/* c */} world", 1),
("hello {/* c */} world\n===\n", "hello {/* c */} world", 0),
("{/* c */} hello world\n===\n", "{/* c */} hello world", 0),
("hello world {/* c */}\n===\n", "hello world {/* c */}", 0),
("> hello {/* c */} world\n> ===\n", "hello {/* c */} world", 1),
] {
let ctx = LintContext::new(source, MarkdownFlavor::MDX, None);
let headings: Vec<_> = ctx
.headings()
.map(|h| (h.line_num, h.heading.raw_text.as_str(), h.blockquote_depth))
.collect();
assert_eq!(headings, vec![(1, raw, depth)], "visible heading: {source}");
assert!(ctx.lines[0].in_mdx_comment);
assert!(ctx.is_in_mdx_comment(source.find("{/*").unwrap()));
}
}
#[test]
fn mdx_comment_bodies_and_flow_comments_keep_headings_hidden() {
for source in [
"{/*\n# literal\nvisible\n===\n*/}\n\n# real\n",
"{/* c */}\n===\n\n# real\n",
"> {/*\n> # literal\n> visible\n> ===\n> */}\n\n# real\n",
] {
let ctx = LintContext::new(source, MarkdownFlavor::MDX, None);
let headings: Vec<_> = ctx.headings().map(|h| h.heading.raw_text.as_str()).collect();
assert_eq!(headings, vec!["real"], "comment contents stay literal: {source}");
}
}
#[test]
fn shortcode_ranges_preserve_quoted_closing_markers() {
for (open, close) in [("{{<", ">}}"), ("{{%", "%}}")] {
for quote in ["`", "\""] {
let tag = format!("{open} note title={quote}literal {close} left\tvalue{quote} {close}");
let content = format!("Before {tag} after");
let ctx = LintContext::new(&content, MarkdownFlavor::Hugo, None);
assert!(ctx.is_in_shortcode(content.find("left").unwrap()), "{content}");
assert!(!ctx.is_in_shortcode(content.find("after").unwrap()), "{content}");
}
}
}
#[test]
fn shortcode_ranges_keep_apostrophes_and_recover_malformed_quotes() {
for tag in [
"{{< note title=don't >}}",
"{{< note title='word' >}}",
"{{< note title=\"unterminated >}}",
] {
let content = format!("{tag} after {{{{< note title=second >}}}}");
let ctx = LintContext::new(&content, MarkdownFlavor::Hugo, None);
assert!(ctx.is_in_shortcode(content.find("note").unwrap()), "{content}");
assert!(!ctx.is_in_shortcode(content.find("after").unwrap()), "{content}");
assert!(ctx.is_in_shortcode(content.find("second").unwrap()), "{content}");
}
let content = "{{< note title='literal >}} rest' >}} after";
let ctx = LintContext::new(content, MarkdownFlavor::Hugo, None);
assert!(!ctx.is_in_shortcode(content.find("rest").unwrap()));
}
#[test]
fn shortcode_ranges_keep_quotes_inside_bare_argument_values() {
for value in ["foo\"bar", "foo=\"bar", "foo`bar", "foo=`bar"] {
let content = format!("{{{{< note title={value} >}}}} after \" ` {{{{< note title=second >}}}}");
let ctx = LintContext::new(&content, MarkdownFlavor::Hugo, None);
assert!(!ctx.is_in_shortcode(content.find("after").unwrap()), "{content}");
assert!(ctx.is_in_shortcode(content.find("second").unwrap()), "{content}");
}
}
#[test]
fn shortcode_ranges_preserve_escaped_double_quote_arguments() {
for tag in [
r#"{{< note title=\"literal >}} left\" >}}"#,
r#"{{< note title="literal \" >}} left" >}}"#,
] {
let content = format!("{tag} after");
let ctx = LintContext::new(&content, MarkdownFlavor::Hugo, None);
assert!(ctx.is_in_shortcode(content.find("left").unwrap()), "{content}");
assert!(!ctx.is_in_shortcode(content.find("after").unwrap()), "{content}");
}
}
#[test]
fn html_tag_ranges_include_quoted_greater_than_signs() {
for quote in ["\"", "'"] {
for (name, flavor) in [
("span", MarkdownFlavor::Standard),
("span", MarkdownFlavor::MDX),
("Card", MarkdownFlavor::MDX),
] {
let tag = format!("<{name} title={quote}literal > www.example.org\nvalue{quote} />");
let content = format!("Before {tag} after");
let ctx = LintContext::new(&content, flavor, None);
let tags = if name == "Card" {
ctx.jsx_component_tags()
} else {
ctx.html_tags()
};
assert_eq!(tags.len(), 1, "{content}");
assert_eq!(tags[0].byte_end, content.find(" after").unwrap(), "{content}");
assert!(tags[0].is_self_closing, "{content}");
}
}
}
#[test]
fn quoted_html_attributes_keep_literal_text_out_of_autofixes() {
use crate::rule::Rule;
use crate::rules::{
MD009TrailingSpaces, MD010NoHardTabs, MD011NoReversedLinks, MD034NoBareUrls, MD037NoSpaceInEmphasis,
};
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(MD009TrailingSpaces::new(0, true)),
Box::new(MD010NoHardTabs::new(4)),
Box::new(MD011NoReversedLinks),
Box::new(MD034NoBareUrls),
Box::new(MD037NoSpaceInEmphasis),
];
for name in ["span", "Card"] {
let content = format!(
"Before <{name} title=\"literal > www.example.org left\tvalue \nright * hi * (url)[text]\">text</{name}> after www.example.org left\tvalue * hi * (url)[text] \n"
);
let ctx = LintContext::new(&content, MarkdownFlavor::MDX, None);
for rule in &rules {
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{}: {content:?}: {warnings:?}", rule.name());
assert_eq!(warnings[0].line, 2, "{}: {warnings:?}", rule.name());
}
}
}
#[test]
fn quoted_html_tag_ranges_can_span_long_unicode_attributes() {
let tag = format!("<span title=\"literal > {}\">", "内容".repeat(1000));
let content = format!("Before {tag} visible</span> after");
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
let tags = ctx.html_tags();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].byte_end, "Before ".len() + tag.len());
assert!(!ctx.is_in_html_tag(content.find("visible").unwrap()));
}
#[test]
fn mdx_tag_ranges_preserve_expression_attributes_and_member_names() {
for name in ["span", "Card", "ui.Card", "svg:path"] {
for expression in [
"() => null",
"() => <span title='inside > value' />",
"`literal > ${1}`",
] {
let open = format!("<{name} onClick={{{expression}}} title=\"literal > value\">");
let content = format!("内容 Before {open}child</{name}> after");
let ctx = LintContext::new(&content, MarkdownFlavor::MDX, None);
let mut tags: Vec<_> = ctx
.html_tags()
.iter()
.cloned()
.chain(ctx.jsx_component_tags().iter().cloned())
.collect();
tags.sort_by_key(|tag| tag.byte_offset);
assert_eq!(tags.len(), 2, "{content}");
assert_eq!(tags[0].byte_offset, "内容 Before ".len(), "{content}");
assert_eq!(tags[0].start_col, "内容 Before ".chars().count(), "{content}");
assert_eq!(tags[0].byte_end, "内容 Before ".len() + open.len(), "{content}");
assert!(tags[1].is_closing, "{content}");
let child = content.find("child").unwrap();
assert!(
!ctx.is_in_html_tag(child) && !ctx.is_in_jsx_component_tag(child),
"{content}"
);
}
}
}
#[test]
fn mdx_member_attributes_keep_literal_text_out_of_autofixes() {
use crate::rule::Rule;
use crate::rules::{
MD009TrailingSpaces, MD010NoHardTabs, MD011NoReversedLinks, MD034NoBareUrls, MD037NoSpaceInEmphasis,
};
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(MD009TrailingSpaces::new(0, true)),
Box::new(MD010NoHardTabs::new(4)),
Box::new(MD011NoReversedLinks),
Box::new(MD034NoBareUrls),
Box::new(MD037NoSpaceInEmphasis),
];
let content = "Before <ui.Card onClick={() => null} title=\"www.example.org left\tvalue \nright * hi * (url)[text]\">text</ui.Card> after www.example.org left\tvalue * hi * (url)[text] \n";
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
for rule in &rules {
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "{}: {warnings:?}", rule.name());
assert_eq!(warnings[0].line, 2, "{}: {warnings:?}", rule.name());
}
}
#[test]
fn mdx_self_closing_tags_leave_following_prose_outside_their_range() {
let content = "Before <ui.Card onClick={() => null} title='value > text' /> after";
let ctx = LintContext::new(content, MarkdownFlavor::MDX, None);
let tags = ctx.jsx_component_tags();
assert_eq!(tags.len(), 1);
assert!(tags[0].is_self_closing);
assert_eq!(tags[0].byte_end, content.find(" after").unwrap());
assert!(!ctx.is_in_jsx_component_tag(content.find("after").unwrap()));
}
#[test]
fn link_target_policy_declared_targets_join_files_and_directories_separately() {
let base = std::env::current_dir().expect("test process should have a working directory");
let root = base.join("workspace");
let policy = LinkTargetPolicy::build(
["a.md"],
[PathBuf::from("files/report.pdf")],
[PathBuf::from("empty/inner"), PathBuf::from("guide")],
false,
[root.as_path()],
);
assert!(policy.contains(Path::new("files/report.pdf")));
assert_eq!(
policy.resolve_supplied(Path::new("files")),
Some(PathBuf::from("files")),
"a declared file implies its directory"
);
for dir in ["empty/inner", "empty", "guide"] {
assert_eq!(
policy.resolve_supplied(Path::new(dir)),
Some(PathBuf::from(dir)),
"{dir} is a declared or implied directory"
);
}
assert!(
!policy.contains(Path::new("empty/inner")) && !policy.contains(Path::new("guide")),
"a declared directory is not a file"
);
assert_eq!(
policy.resolve_supplied(&root.join("empty")),
Some(root.join("empty")),
"the root-joined spelling resolves too"
);
assert_eq!(policy.resolve_supplied(Path::new("nope")), None);
}
#[test]
fn bare_list_marker_opens_an_empty_item() {
for content in [
"-\n-\n",
"-\n-\n-\n",
"-\n--\n",
"-\n---\n",
"-\n===\n",
"+\n-\n",
"1.\n-\n",
"1.\n---\n",
"1.\n=\n",
"1)\n-\n",
"Intro\n\n-\n-\n",
"> -\n> -\n",
"> 1.\n> -\n",
"# T\n\nList three outcomes:\n\n-\n-\n-\n",
"# T\n\n-\n foo\n ===\n",
"- \n foo\n ===\n",
] {
assert!(
setext_headings(content, MarkdownFlavor::Standard).is_empty(),
"{content:?}: {:?}",
setext_headings(content, MarkdownFlavor::Standard)
);
}
}
#[test]
fn bare_list_marker_controls_stay_headings() {
for (content, line, text, level, depth) in [
("Title\n-\n", 1, "Title", 2, 0),
("Title\n---\n", 1, "Title", 2, 0),
("Title\n===\n", 1, "Title", 1, 0),
("Title\n -\n", 1, "Title", 2, 0),
("foo\nbar\n-\n", 2, "foo bar", 2, 0),
("> Title\n> -\n", 1, "Title", 2, 1),
("- a\n-\n\nTitle\n-\n", 4, "Title", 2, 0),
] {
let found = setext_headings(content, MarkdownFlavor::Standard);
assert_eq!(found.len(), 1, "{content:?}: {found:?}");
assert_eq!((found[0].0, found[0].1.as_str()), (line, text), "{content:?}");
assert_eq!((found[0].2, found[0].3), (level, depth), "{content:?}");
}
}
fn parser_setext_headings(content: &str) -> Vec<(u8, String)> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut found = Vec::new();
let mut current: Option<(u8, String)> = None;
for (event, range) in Parser::new_ext(content, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::Heading { level, .. }) => current = Some((level as u8, String::new())),
Event::Text(text) | Event::Code(text) => {
if let Some(heading) = current.as_mut() {
heading.1.push_str(&text);
}
}
Event::SoftBreak => {
if let Some(heading) = current.as_mut() {
heading.1.push(' ');
}
}
Event::End(TagEnd::Heading(_)) => {
let heading = current.take().expect("heading start precedes its end");
if !content[range].starts_with('#') {
found.push(heading);
}
}
_ => {}
}
}
found
}
#[test]
fn setext_headings_match_the_parser_around_empty_list_items() {
for content in [
"-\n-\n",
"-\n-\n-\n",
"-\n--\n",
"-\n---\n",
"-\n===\n",
"+\n-\n",
"*\n-\n",
"1.\n-\n",
"1.\n---\n",
"1)\n-\n",
"- \n-\n",
"-\n- \n",
"- \n- \n",
"# T\n\n-\n foo\n ===\n",
"-\n foo\n ---\n",
"-\n foo\n ---\n",
"-\n foo\n ===\n",
"Title\n-\n",
"Title\n---\n",
"Title\n===\n",
"foo\nbar\n-\n",
"Title\n -\n",
"Title\n1.\n",
"Title\n2.\n===\n",
"- a\n-\n\nTitle\n-\n",
"> -\n> -\n",
"> Title\n> -\n",
"> -\n> foo\n> ---\n",
"- a\n -\n -\n",
] {
let mut ours: Vec<(u8, String)> = setext_headings(content, MarkdownFlavor::Standard)
.into_iter()
.map(|(_, text, level, _)| (level, text))
.collect();
let mut theirs = parser_setext_headings(content);
ours.sort();
theirs.sort();
assert_eq!(ours, theirs, "{content:?}");
}
}
fn parser_heading_containers(content: &str) -> Vec<(usize, HeadingContainer)> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let line_of = |offset: usize| content[..offset].matches('\n').count();
let item_content_col = |offset: usize| {
let line_start = content[..offset].rfind('\n').map_or(0, |at| at + 1);
let line = content[line_start..].split('\n').next().unwrap_or("");
let start = offset - line_start;
let marker_len = line[start..]
.find(|c: char| !c.is_ascii_digit())
.map_or(1, |digits| digits + 1);
let marker_end = start + marker_len;
let marker_end_col = column_at(line, marker_end);
let text = line[marker_end..].trim_start_matches([' ', '\t']);
if text.is_empty() {
return marker_end_col + 1;
}
let text_col = column_at(line, line.len() - text.len());
if text_col - marker_end_col > 4 {
marker_end_col + 1
} else {
text_col
}
};
let mut stack: Vec<Option<usize>> = Vec::new();
let mut found = Vec::new();
for (event, range) in Parser::new_ext(content, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::Item) => stack.push(Some(item_content_col(range.start))),
Event::Start(Tag::BlockQuote(_) | Tag::FootnoteDefinition(_)) => stack.push(None),
Event::End(TagEnd::Item | TagEnd::BlockQuote(_) | TagEnd::FootnoteDefinition) => {
stack.pop();
}
Event::Start(Tag::Heading { .. }) => {
let container = if stack.iter().any(Option::is_none) {
HeadingContainer::Unknown
} else {
match stack.last() {
Some(Some(content_col)) => HeadingContainer::Item {
content_col: *content_col,
},
_ => HeadingContainer::Outside,
}
};
found.push((line_of(range.start), container));
}
_ => {}
}
}
found
}
fn recorded_heading_containers(ctx: &LintContext) -> Vec<(usize, usize, HeadingContainer)> {
ctx.lines
.iter()
.enumerate()
.filter_map(|(last, line)| {
let heading = line.heading.as_deref()?;
let first = last + 1 - heading.text_lines;
let spans_underline = usize::from(heading.style != HeadingStyle::ATX);
let container = ctx.heading_container(first + 1);
for covered in first..=last + spans_underline {
assert_eq!(
ctx.heading_container(covered + 1),
container,
"line {covered} of the heading on line {first} records another container"
);
}
Some((first, last, container))
})
.collect()
}
const CONTAINER_ROWS_THE_PASS_MAY_DECLINE: &[&str] =
&["> - x\n>\n> ## a\n", "- x\n\n > ## a\n", "[^a]: note\n\n ## a\n"];
const CONTAINER_ROWS: &[&str] = &[
"- -\n ## a\n",
"- -\n a\n ---\n",
"text\n ---\n - z\n===\n",
"- x\n\n text\n ---\n - z\n ===\n",
"1. -\n ## a\n",
"- - x\n\n ## a\n",
"- x\n - y\n ## a\n",
"- x\n > y\n ## a\n",
"- x\n 1. y\n ## a\n",
"> x\n > y\n ## a\n",
"> x\n ## a\n",
"> > x\n ## a\n",
"> - x\n ## a\n",
"- > x\n ## a\n",
"- > x\n ## a\n",
"para\n ## a\n",
"> x\n ## a\n",
"> x\n a\n ===\n",
"- x\n a\n ===\n",
"> - x\n> y\n ## a\n",
"- x\n ## A\n",
"- x\n\n ## a\n",
"-\n ## a\n",
"1. x\n\n ## a\n",
"- x\n\n a\nlazy\n ---\n",
"-\n -\n\n ## a\n",
"- x\n - y\n\n paragraph\n\n ## a\n",
"- x\n text\n ## a\n",
"- x\nlazy\n ## a\n",
"- x\n - y\n\n ## a\n",
"-\tx\n\n ## a\n",
"- code\n\n ## a\n",
"- x\n\n\t## a\n\n\ttext\n",
"1. x\n\n ## a\n",
"-\n ## a\n text\n",
"- x\n\n ## a\n\n text\n",
"- x\n ## a\n text\n",
"1. x\n\n ## a\n\n text\n",
"- x\n - y\n\n ## a\n\n text\n",
"- x\n\n ## a ##\n",
"- x\n\n ## a\n",
"# T\n\n- x\n\n ### a\n",
"- x\n\n ---\n\n ## a\n",
"-\n a\n ---\n text\n",
"- x\n\n a\n ===\n",
"1. x\n\n a\n ---\n",
"- x\n - y\n\n a\n ---\n\n text\n",
"-\n a\n b\n ---\n text\n",
"10. x\n\n ## a\n\n text\n",
"-\n\n ## a\n",
"-\n ## a\n",
"-\n -\n ## a\n",
"-\n -\n -\n\n ## a\n",
"1.\n ## a\n",
"- \n ## a\n",
"- x\n\n ## a\n",
"- x\n - y\n\n ## a\n",
"- x\n - y\n\n## a\n",
"- x\n- y\n\n ## a\n",
"- x\n\nplain\n\n ## a\n",
"* a\n\n b\n\n ## c\n\n* d\n\n ## e\n",
"- a\n - b\n - c\n\n ## d\n",
"- x\n - y\n\n a\nlazy\n ---\n",
"1. a\n 1. b\n\n x\n ---\n",
"- x\n\n a\n\n b\n ---\n",
"- x\n \n ## a\n",
"-\n \n ## a\n",
"## a\n",
" ## a\n",
"a\n---\n",
"- x\n\n## a\n",
"- x\n\n## a\n\n text\n",
"1. x\n\n## a\n",
];
fn assert_heading_containers_match_the_parser(content: &str, may_decline: bool) {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let ours = recorded_heading_containers(&ctx);
let theirs = parser_heading_containers(content);
if may_decline {
for (first, _, ours) in &ours {
let expected = theirs
.iter()
.find(|(line, _)| line == first)
.map(|(_, container)| container);
assert!(
expected == Some(ours) || *ours == HeadingContainer::Unknown,
"{content:?}: heading on line {first}: {ours:?}, the parser says {expected:?}"
);
}
for (first, _) in &theirs {
assert_eq!(
ctx.heading_container(first + 1),
HeadingContainer::Unknown,
"{content:?}"
);
}
return;
}
assert_eq!(
ours.iter().map(|(first, ..)| *first).collect::<Vec<_>>(),
theirs.iter().map(|(first, _)| *first).collect::<Vec<_>>(),
"{content:?}: the headings found differ from the parser's"
);
for ((first, _, ours), (_, theirs)) in ours.iter().zip(&theirs) {
assert_eq!(ours, theirs, "{content:?}: heading on line {first}");
}
}
#[test]
fn heading_containers_match_the_parser() {
for content in CONTAINER_ROWS {
assert_heading_containers_match_the_parser(content, false);
}
}
#[test]
fn heading_containers_decline_what_the_pass_does_not_model() {
for content in CONTAINER_ROWS_THE_PASS_MAY_DECLINE {
assert_heading_containers_match_the_parser(content, true);
}
}
#[test]
fn heading_container_covers_every_line_of_the_heading() {
let item = HeadingContainer::Item { content_col: 2 };
let ctx = LintContext::new("- x\n\n a\nlazy\n ---\n\n text\n", MarkdownFlavor::Standard, None);
for line_num in 3..=5 {
assert_eq!(ctx.heading_container(line_num), item, "line {line_num}");
}
assert_eq!(ctx.heading_container(1), HeadingContainer::Unknown);
assert_eq!(ctx.heading_container(7), HeadingContainer::Unknown);
assert_eq!(ctx.heading_container(0), HeadingContainer::Unknown);
assert_eq!(ctx.heading_container(99), HeadingContainer::Unknown);
let ctx = LintContext::new("- x\n\n ## a\n\n## b\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.heading_container(3), item);
assert_eq!(ctx.heading_container(5), HeadingContainer::Outside);
}
fn parser_item_columns(content: &str) -> Vec<(usize, usize, usize, bool)> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut quote_depth = 0;
let mut found = Vec::new();
for (event, range) in Parser::new_ext(content, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::BlockQuote(_)) => quote_depth += 1,
Event::End(TagEnd::BlockQuote(_)) => quote_depth -= 1,
Event::Start(Tag::Item) if quote_depth == 0 => {
let line_start = content[..range.start].rfind('\n').map_or(0, |at| at + 1);
let line = content[line_start..].split('\n').next().unwrap_or("");
let start = range.start - line_start;
let start = start + (line[start..].len() - line[start..].trim_start_matches([' ', '\t']).len());
let marker_len = match line[start..].chars().next() {
Some(c) if c.is_ascii_digit() => line[start..].find(|c: char| !c.is_ascii_digit()).unwrap_or(1) + 1,
_ => 1,
};
let marker_end = start + marker_len;
let after = &line[marker_end..];
let text = after.trim_start_matches([' ', '\t']);
let marker_end_col = column_at(line, marker_end);
let text_col = column_at(line, line.len() - text.len());
let empty = text.is_empty();
let content_col = if empty || text_col - marker_end_col > 4 {
marker_end_col + 1
} else {
text_col
};
found.push((
content[..range.start].matches('\n').count(),
column_at(line, start),
content_col,
empty,
));
}
_ => {}
}
}
found.sort_unstable();
found
}
fn recorded_item_columns(ctx: &LintContext) -> Vec<(usize, usize, usize, bool)> {
let mut found = Vec::new();
for (index, line) in ctx.lines.iter().enumerate() {
if let Some(item) = line.list_item.as_deref() {
for chain in &item.commonmark_items {
found.push((index, chain.marker_column, chain.content_column, chain.is_empty));
}
}
}
found.sort_unstable();
found
}
const ITEM_COLUMN_ROWS: &[&str] = &[
"- a\n",
"* a\n",
"+ a\n",
"1. a\n",
"10. a\n",
"999999999. a\n",
"1) a\n",
"-\n a\n",
"1.\n a\n",
"10.\n a\n",
"- \n a\n",
"-\tx\n",
"1.\tx\n",
"- \tx\n",
"- \tx\n",
"- t\n",
"1. t\n",
"- t\n",
"- 1. x\n",
"1. - x\n",
"- - a\n",
"- -\n",
"1. -\n",
"- 1. -\n",
"-\t1. x\n",
" - a\n",
" - a\n",
"- a\n - b\n - c\n",
"- a\n\n - b\n",
"1. a\n 1. b\n",
"- a\n\n code\n",
"-\n -\n a\n",
"- a\n- b\n",
"text\n- a\n",
"text\n2. a\n",
"- ```\n x\n ```\n",
"- - ```\n x\n ```\n",
"- \u{661}\u{662}. x\n",
"-\u{a0}x\n",
"- \u{a0}\n a\n",
"- \u{a0}\n a\n",
"1234567890. x\n",
];
#[test]
fn recorded_item_columns_match_the_parser() {
for content in ITEM_COLUMN_ROWS {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(recorded_item_columns(&ctx), parser_item_columns(content), "{content:?}");
}
}
#[test]
fn item_columns_are_not_recorded_inside_a_blockquote() {
for content in ["> - a\n", "> - a\n> b\n"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(recorded_item_columns(&ctx).is_empty(), "{content:?}");
}
}
#[test]
fn the_item_column_comparison_can_fail() {
let ctx = LintContext::new("-\n a\n", MarkdownFlavor::Standard, None);
assert_eq!(recorded_item_columns(&ctx), vec![(0, 0, 2, true)]);
assert_ne!(recorded_item_columns(&ctx), vec![(0, 0, 1, true)]);
assert_eq!(
parser_item_columns("- 1. x\n"),
vec![(0, 0, 2, false), (0, 2, 5, false)]
);
assert_eq!(parser_item_columns("text\n"), vec![]);
}
fn parser_headings(content: &str) -> Vec<(usize, u8, String)> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut found = Vec::new();
let mut current: Option<(usize, u8, String)> = None;
for (event, range) in Parser::new_ext(content, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::Heading { level, .. }) => {
current = Some((content[..range.start].matches('\n').count(), level as u8, String::new()));
}
Event::Text(text) | Event::Code(text) => {
if let Some(heading) = current.as_mut() {
heading.2.push_str(&text);
}
}
Event::SoftBreak => {
if let Some(heading) = current.as_mut() {
heading.2.push(' ');
}
}
Event::End(TagEnd::Heading(_)) => found.push(current.take().expect("heading start precedes its end")),
_ => {}
}
}
found
}
const MARKER_LINE_ROWS: &[&str] = &[
"- a\n ---\n",
"- a\n ===\n",
"* a\n ---\n",
"+ a\n ===\n",
"1. a\n ---\n",
"1) a\n ===\n",
"10. a\n ---\n",
"- - a\n ---\n",
"- - a\n ===\n",
"- a\n b\n ---\n",
"- a\n b\n c\n ===\n",
"- > a\n > ---\n",
"> - a\n> ---\n",
"> - a\n> ===\n",
"> > - a\n> > ---\n",
"> - > a\n> > ---\n",
"> - > a\n> > b\n> > ===\n",
"> > - > a\n> > > ---\n",
"- a\n ---\n- b\n ---\n",
"- a\n ---\n\n text\n",
"text\n\n- a\n ---\n",
"- ## a\n",
"1. # a\n",
"1) ### a\n",
"- - ## a\n",
"* ## a\n",
"+ # a\n",
"- > ## a\n",
"> - ## a\n",
"> 1. # a\n",
"- ## a ##\n",
"- ## a\n",
"- ## a\n text\n",
"# T\n\n- ## a\n\n - ### b\n",
"- a\n ---\n - b\n ===\n",
"- a\n ---\n",
"- ##\ta\n",
"- #\ta\n",
"- a\n ---\n",
"- a\n---\n",
"- a\n===\n",
"- ##a\n",
"- ## a\n",
"- x\n - ## a\n",
"1. ## a\n",
"- - ## a\n",
"> - ## a\n",
"- a\n\n ---\n",
"1. a\n ---\n",
"- a\n ---\n b\n",
"-\n ## a\n",
"-\n a\n ---\n",
"> - a\n > ---\n",
"> - a\n > ---\n",
"> 1. a\n > ---\n",
"> - a\n > ===\n",
"> - a\n > ---\n",
"> - a\n > ---\n",
"> - a\n> ---\n",
"# T\n\n\u{661}. a\n ---\n",
"# T\n\n\u{661}. a\n ===\n",
];
#[test]
fn marker_line_headings_match_the_parser() {
for content in MARKER_LINE_ROWS {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let mut ours: Vec<(usize, u8, String)> = ctx
.headings()
.map(|h| (h.first_line_num() - 1, h.heading.level, h.heading.text.clone()))
.collect();
let mut theirs = parser_headings(content);
ours.sort();
theirs.sort();
assert_eq!(ours, theirs, "{content:?}");
}
}
#[test]
fn marker_line_headings_report_where_their_syntax_starts() {
for (content, text, prefix) in [
("- a\n ---\n", "a", 2),
("- a\n ---\n", "a", 2),
("1. a\n ===\n", "a", 3),
("> - > a\n> > ---\n", "a", 6),
("- - a\n ---\n", "a", 4),
("- a\n b\n ---\n", "a b", 2),
("- > a\n > ---\n", "a", 4),
("> - a\n> ---\n", "a", 4),
("- ## a\n", "a", 2),
("1. # a\n", "a", 3),
("- - ## a\n", "a", 4),
("> - ## a\n", "a", 4),
("- \u{3000}a\n ---\n", "a", 2),
("- \u{a0}a\n ===\n", "a", 2),
("- \u{3000}Title\n ---\n", "Title", 2),
("a\n---\n", "a", 0),
("## a\n", "a", 0),
(" ## a\n", "a", 2),
("> ## a\n", "a", 2),
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1, "{content:?}");
assert_eq!(headings[0].heading.text, text, "{content:?}");
assert_eq!(headings[0].heading.first_line_prefix_len(), prefix, "{content:?}");
}
}
#[test]
fn marker_line_heading_prefix_excludes_unicode_whitespace() {
let content = "# T\n\n- \u{3000}Title\n ---\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = &ctx.headings().last().unwrap().heading;
assert_eq!(heading.first_line_prefix_len(), 2);
assert_eq!(heading.raw_text, "\u{3000}Title");
}
#[test]
fn marker_line_setext_underline_column_follows_every_container_marker() {
let content = "> - > a\n> > ---\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(1).expect("a heading");
assert_eq!(heading.heading.marker_column, 6, "the underline starts after `> > `");
}
#[test]
fn marker_line_atx_headings_are_recorded_by_quote_depth() {
let ctx = LintContext::new("- ## a\n", MarkdownFlavor::Standard, None);
let heading = ctx.lines[0].heading.as_ref().expect("a heading on the marker line");
assert_eq!((heading.level, heading.marker_column), (2, 2));
for content in ["> - ## a\n", "- > ## a\n"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.lines[0].heading.is_none(), "{content:?}");
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1, "{content:?}");
assert_eq!(headings[0].blockquote_depth, 1, "{content:?}");
}
}
#[test]
fn marker_line_setext_headings_locate_their_text() {
let content = "- a\n b\n ---\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(2).expect("recorded on the last text line");
assert_eq!((heading.first_line_num(), heading.heading.text_lines), (1, 2));
assert_eq!(heading.heading.content_column, 2);
assert_eq!(heading.heading.text, "a b");
assert_eq!(&content[heading.text_byte_range(content)], "a\n b");
let content = "> - a\n> ---\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.heading_on_line(1).expect("a quoted marker-line heading");
assert_eq!((heading.blockquote_depth, heading.heading.content_column), (1, 4));
assert_eq!(&content[heading.text_byte_range(content)], "a");
}
fn pulldown_heading_text(content: &str) -> Option<String> {
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
let mut text: Option<String> = None;
for event in Parser::new(content) {
match event {
Event::Start(Tag::Heading { .. }) => text = Some(String::new()),
Event::Text(t) | Event::Code(t) => {
if let Some(text) = text.as_mut() {
text.push_str(&t);
}
}
Event::End(TagEnd::Heading(_)) => return text,
_ => {}
}
}
None
}
#[test]
fn atx_heading_text_is_stripped_of_spaces_and_tabs_only() {
for (content, raw, closing) in [
("# a\u{2003}\n", "a\u{2003}", ""),
("# \u{2003}a\n", "\u{2003}a", ""),
("# \u{2003}\n", "\u{2003}", ""),
("# a\u{a0}\n", "a\u{a0}", ""),
("# a\u{3000}b\u{3000}\n", "a\u{3000}b\u{3000}", ""),
("# a #\u{2003}\n", "a #\u{2003}", ""),
("# a\u{2003}#\n", "a\u{2003}#", ""),
("# a \u{2003}#\n", "a \u{2003}#", ""),
("# a\u{a0}##\n", "a\u{a0}##", ""),
("# a\u{2003} #\n", "a\u{2003}", "#"),
("# a\u{2003}\t##\t\n", "a\u{2003}", "##"),
("# a\t#\n", "a", "#"),
("# a #\t\n", "a", "#"),
("# a ####### \n", "a", "#######"),
("#\ta\n", "a", ""),
("# a \n", "a", ""),
("> # a\u{2003}\n", "a\u{2003}", ""),
("> # a\u{2003}#\n", "a\u{2003}#", ""),
("> # a\u{2003} #\n", "a\u{2003}", "#"),
("> # a #\u{2003}\n", "a #\u{2003}", ""),
("> # \u{2003}\n", "\u{2003}", ""),
("- # a\u{2003}\n", "a\u{2003}", ""),
("- # a\u{2003}#\n", "a\u{2003}#", ""),
("- # a\u{2003} #\n", "a\u{2003}", "#"),
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let headings: Vec<_> = ctx.headings().collect();
assert_eq!(headings.len(), 1, "{content:?}");
let heading = headings[0].heading;
assert_eq!(heading.raw_text, raw, "{content:?}: raw_text");
assert_eq!(heading.closing_sequence, closing, "{content:?}: closing sequence");
assert_eq!(heading.has_closing_sequence, !closing.is_empty(), "{content:?}");
if closing.is_empty() || !content.contains('\t') {
assert_eq!(
pulldown_heading_text(content).as_deref(),
Some(heading.raw_text.as_str()),
"{content:?}: the reference parser's heading text"
);
}
}
}
#[test]
fn test_indented_lazy_line_in_a_blockquote_is_not_a_horizontal_rule() {
for content in [
"> o\n> ---\n",
"> o\n> ***\n",
"> o\n> ___\n",
"> o\n> - - -\n",
">o\n> ***\n",
"> o\n>\t\t***\n",
"> > o\n> > ***\n",
"> - a\n> ***\n",
"- > o\n > ***\n",
"> o\n> |a|b|\n> |-|-|\n> ---\n",
"> |a|b|\n> |-|-|\n> ---\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(horizontal_rule_line(&ctx), None, "{content:?}");
}
}
#[test]
fn test_horizontal_rule_in_a_blockquote_is_still_found() {
for (content, line) in [
("> ---\n", 1),
("> o\n>\n> ---\n", 3),
("> o\n>\n> ---\n", 3),
("> > o\n> >\n> > ***\n", 3),
("> o\n>\n> ***\n> b\n", 3),
("> - a\n>\n> ***\n", 3),
("> o\n> ***\n", 2),
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert_eq!(horizontal_rule_line(&ctx), Some(line), "{content:?}");
}
let ctx = LintContext::new("> o\n>\n> ---\n", MarkdownFlavor::Standard, None);
assert_eq!(horizontal_rule_line(&ctx), None);
}
fn table_header_lines(content: &str) -> Vec<usize> {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
ctx.table_blocks.iter().map(|b| b.header_line + 1).collect()
}
#[test]
fn test_indented_lazy_rows_in_a_blockquote_are_not_a_table() {
for content in [
"> o\n> |a|b|\n> |-|-|\n",
"> o\n> |a|b|\n> |-|-|\n> |1|2|\n",
">o\n> |a|b|\n> |-|-|\n",
"> o\n>\t\t|a|b|\n>\t\t|-|-|\n",
"> > o\n> > |a|b|\n> > |-|-|\n",
"> - a\n> |a|b|\n> |-|-|\n",
"- > o\n > |a|b|\n > |-|-|\n",
"> o\n> |a|b|\n> |-|-|\n",
] {
assert!(table_header_lines(content).is_empty(), "{content:?}");
}
}
#[test]
fn test_tables_in_a_blockquote_are_still_found() {
assert_eq!(table_header_lines("> |a|b|\n> |-|-|\n"), vec![1]);
assert_eq!(table_header_lines("> intro\n> |a|b|\n> |-|-|\n> |1|2|\n"), vec![2]);
assert_eq!(table_header_lines("> > |a|b|\n> > |-|-|\n"), vec![1]);
assert_eq!(table_header_lines("> - |a|b|\n> |-|-|\n"), vec![1]);
assert_eq!(
table_header_lines("> o\n> |a|b|\n> |-|-|\n> |c|d|\n> |-|-|\n"),
vec![4]
);
assert_eq!(table_header_lines("> o\n>\t|a|b|\n>\t|-|-|\n"), vec![2]);
}
#[test]
fn test_indented_header_row_after_a_link_definition_is_still_a_table() {
assert_eq!(
table_header_lines("> [a]: /url\n> |a|b|\n> |-|-|\n> |x|y|\n"),
vec![2]
);
assert_eq!(table_header_lines("[a]: /url\n |a|b|\n|-|-|\n|x|y|\n"), vec![2]);
assert_eq!(
table_header_lines("> [a]: /url\n> [b]: /url\n> |a|b|\n> |-|-|\n"),
vec![3]
);
assert!(table_header_lines("> [a]: /url\n> o\n> |a|b|\n> |-|-|\n").is_empty());
}
#[test]
fn test_indented_text_after_a_link_definition_is_still_text() {
for content in ["[a]: /url\n # foo\ntext\n", "> [a]: /url\n> # foo\n> text\n"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(ctx.lines[1].heading.is_none(), "{content:?}");
}
for content in ["[a]: /url\n ---\n", "> [a]: /url\n> ---\n"] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(!ctx.lines[1].is_horizontal_rule, "{content:?}");
}
}
#[test]
fn test_indented_row_after_pipe_definitions_is_still_a_table() {
assert_eq!(
table_header_lines("> [a]: /url\n> [b]: /u|rl\n> |a|b|\n> |-|-|\n"),
vec![3]
);
}
#[test]
fn test_indented_row_after_many_pipe_definitions_is_still_a_table() {
let content = format!("> [a]: /url\n{}> |a|b|\n> |-|-|\n", "> [b]: /u|rl\n".repeat(40));
assert_eq!(table_header_lines(&content), vec![42]);
}
#[test]
fn test_indented_row_after_a_definition_title_line_is_still_a_table() {
for title in ["\"title|x\"", "'title|x'", "(title|x)"] {
for (quote, indent) in [("> ", " "), ("", " ")] {
let content =
format!("{quote}[a]: /url\n{quote}{indent}{title}\n{quote}{indent}|a|b|\n{quote}|-|-|\n{quote}|x|y|\n");
assert_eq!(table_header_lines(&content), vec![3], "{content:?}");
}
}
}
#[test]
fn test_mdx_indented_delimiter_row_still_opens_a_table() {
let ctx = LintContext::new("# Heading\n\n|a|b|\n |-|-|\n===\n", MarkdownFlavor::MDX, None);
assert!(ctx.lines[2..].iter().all(|line| line.heading.is_none()));
}
#[test]
fn test_indented_row_after_a_multiline_definition_title_is_still_a_table() {
for content in [
"> [a]: /url\n> \"title|x\n> end|x\"\n> |a|b|\n> |-|-|\n> |x|y|\n",
"[a]: /url\n 'title|x\n end|x'\n |a|b|\n|-|-|\n|x|y|\n",
] {
assert_eq!(table_header_lines(content), vec![4], "{content:?}");
}
}
#[test]
fn test_mdx_indented_lines_open_the_blocks_they_read_as() {
let ctx = LintContext::new("> o\n> ***\n> after\n", MarkdownFlavor::MDX, None);
assert!(ctx.lines[1].is_horizontal_rule);
let ctx = LintContext::new("> o\n> |a|b|\n> |-|-|\n", MarkdownFlavor::MDX, None);
assert_eq!(ctx.table_blocks.len(), 1);
}
#[test]
fn test_mdg_pipe_rows_keep_their_gherkin_indentation() {
let source = "Feature: Tables\n Scenario: Values\n Given rows\n | A | B |\n | --- | --- |\n";
let ctx = LintContext::new(source, MarkdownFlavor::MDG, None);
assert_eq!(
ctx.table_blocks.iter().map(|b| b.header_line + 1).collect::<Vec<_>>(),
vec![4]
);
let ctx = LintContext::new("> o\n> |a|b|\n> |-|-|\n", MarkdownFlavor::MDG, None);
assert!(ctx.table_blocks.is_empty());
}
fn container_variants(lines: &[String]) -> Vec<(&'static str, String)> {
let top = lines.join("\n") + "\n";
let mut quote = String::new();
let mut item = String::new();
for (i, line) in lines.iter().enumerate() {
quote.push_str(&format!("> {line}\n"));
item.push_str(&format!("{}{line}\n", if i == 0 { "- " } else { " " }));
}
vec![("top", top), ("quote", quote), ("item", item)]
}
fn definition_then_table(definition: Vec<String>) -> Vec<String> {
let header = definition.len() + 1;
let mut lines = definition;
lines.extend([" |a|b|", "|-|-|", "|x|y|"].map(String::from));
assert_eq!(lines.len(), header + 2);
lines
}
#[test]
fn test_definition_with_a_very_long_title_does_not_hide_the_table_below_it() {
for (open, close) in [("\"", "\""), ("'", "'"), ("(", ")")] {
let mut definition = vec!["[a]: /url".to_string(), format!(" {open}t|0")];
definition.extend((0..200).map(|i| format!(" x|{i}")));
definition.push(format!(" end{close}"));
let header = definition.len() + 1;
for (place, content) in container_variants(&definition_then_table(definition)) {
assert_eq!(
table_header_lines(&content),
vec![header],
"{place} {open}: a title of 200 lines"
);
}
}
}
#[test]
fn atx_display_text_ignores_the_unicode_whitespace_raw_text_keeps() {
for content in [
"# a\u{2003}\n",
"# \u{2003}a\n",
"> # a\u{2003}\n",
"- # \u{2003}a\u{2003}\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let heading = ctx.headings().next().unwrap().heading;
assert_eq!(heading.text, "a", "{content:?}");
assert_eq!(heading.slug_text, "a", "{content:?}");
}
let ctx = LintContext::new("# a\u{a0}b\n", MarkdownFlavor::Standard, None);
assert_eq!(ctx.headings().next().unwrap().heading.text, "a\u{a0}b");
}
#[test]
fn test_definition_title_on_its_own_line_does_not_hide_the_table_below_it() {
for (open, close) in [("\"", "\""), ("'", "'"), ("(", ")")] {
let definition = vec!["[a]: /url".to_string(), format!(" {open}title|x{close}")];
let header = definition.len() + 1;
for (place, content) in container_variants(&definition_then_table(definition)) {
assert_eq!(
table_header_lines(&content),
vec![header],
"{place} {open}: {content:?}"
);
}
}
}
#[test]
fn test_indented_rows_after_a_definition_and_text_are_not_a_table() {
let definition = ["[a]: /url", " \"title|x\"", "foo", " |a|b|", " |-|-|"].map(String::from);
for (place, content) in container_variants(&definition) {
assert!(table_header_lines(&content).is_empty(), "{place}: {content:?}");
}
for (place, content) in container_variants(&["[a]: /url".to_string(), "|a|b|".into(), "|-|-|".into()]) {
assert_eq!(table_header_lines(&content), vec![2], "{place}: {content:?}");
}
}
#[test]
fn test_mdx_definition_extents_decide_indented_rows() {
let mut definition = vec!["[a]: /url".to_string(), " \"t|0".to_string()];
definition.extend((0..200).map(|i| format!(" x|{i}")));
definition.push(" end\"".to_string());
let header = definition.len() + 1;
for (place, content) in container_variants(&definition_then_table(definition)) {
let ctx = LintContext::new(&content, MarkdownFlavor::MDX, None);
let headers: Vec<usize> = ctx.table_blocks.iter().map(|b| b.header_line + 1).collect();
assert_eq!(headers, vec![header], "{place}: {content:?}");
}
for (place, content) in container_variants(&["|a|b|".to_string(), " |-|-|".into(), "|x|y|".into()]) {
let ctx = LintContext::new(&content, MarkdownFlavor::MDX, None);
assert_eq!(ctx.table_blocks.len(), 1, "{place}: {content:?}");
}
}
#[test]
fn test_definition_extents_linear_complexity() {
fn document(definitions: usize) -> String {
let mut content = String::new();
for i in 0..definitions {
content.push_str(&format!("[d{i}]: /u\n \"t|a\n b|c\n d|e\"\n"));
}
content + " |a|b|\n|-|-|\n|x|y|\n"
}
let time = |definitions: usize| {
let content = document(definitions);
let start = std::time::Instant::now();
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
assert_eq!(ctx.table_blocks.len(), 1);
start.elapsed().as_secs_f64()
};
let small = (0..3).map(|_| time(1000)).fold(f64::MAX, f64::min);
let large = (0..3).map(|_| time(4000)).fold(f64::MAX, f64::min);
assert!(large < small * 10.0 + 0.05, "1000: {small:.3}s, 4000: {large:.3}s");
}
#[test]
fn test_definitions_sharing_a_label_all_count_as_definitions() {
let mut definition = vec![
"[a]: /first".to_string(),
"[a]: /url".to_string(),
" \"t|0".to_string(),
];
definition.extend((0..100).map(|i| format!(" x|{i}")));
definition.push(" end\"".to_string());
let header = definition.len() + 1;
for (place, content) in container_variants(&definition_then_table(definition)) {
assert_eq!(table_header_lines(&content), vec![header], "{place}: a repeated label");
}
}
#[test]
fn rendered_link_range_index_matches_exhaustive_containment() {
let mut ctx = LintContext::new("[link](target.md)", MarkdownFlavor::Standard, None);
let template = ctx.links()[0].clone();
ctx.links = [(0, 40, false), (5, 20, false), (15, 50, true), (40, 60, false)]
.into_iter()
.map(|(start, end, undefined)| {
let mut link = template.clone();
link.byte_offset = start;
link.byte_end = end;
link.is_reference = undefined;
if undefined {
link.url = "".into();
}
link
})
.collect();
for start in 0..=65 {
for end in start..=65 {
let expected = ctx.links().iter().any(|link| {
!(link.is_reference && link.url.is_empty()) && start >= link.byte_offset && end <= link.byte_end
});
assert_eq!(ctx.is_range_in_rendered_link(&(start..end)), expected, "{start}..{end}");
}
}
let ctx = LintContext::new("[undefined][reference]", MarkdownFlavor::Standard, None);
assert!(!ctx.is_range_in_rendered_link(&(0..0)));
assert!(!ctx.is_range_in_rendered_link(&(0..5)));
}
#[test]
fn setext_underline_content_trims_only_ascii_space_and_tab() {
for ok in [
"---", "--- ", "---\t", " ---", " ---", "=== ", "=", "-", "---\r", "\t===",
] {
assert!(is_setext_underline_content(ok), "{ok:?} is an underline");
}
for bad in [
"---\u{2003}",
"\u{2003}---",
"===\u{a0}",
"\u{a0}===",
"---\u{3000}",
"---\u{2009}",
"---\u{c}",
"---\u{85}",
"\r---",
"--- -",
"= = =",
"-=",
"",
" ",
] {
assert!(!is_setext_underline_content(bad), "{bad:?} is not an underline");
}
}
#[test]
fn unicode_whitespace_around_an_underline_makes_no_heading() {
for ws in ['\u{2003}', '\u{a0}', '\u{3000}', '\u{2009}'] {
for marker in ["---", "==="] {
for underline in [format!("{marker}{ws}"), format!("{ws}{marker}")] {
for prefix in ["", "- ", "> "] {
let pad = if prefix == "- " { " " } else { prefix };
let content = format!("{prefix}Title\n{pad}{underline}\n");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(
ctx.lines
.iter()
.all(|l| l.heading.is_none() && !l.is_setext_heading_text),
"{content:?} must not contain a heading"
);
}
}
}
}
for underline in ["--- ", "---\t", "=== ", " ==="] {
let content = format!("Title\n{underline}\n");
let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert!(ctx.lines[0].is_setext_heading_text, "{content:?} is a heading");
}
}
#[test]
fn horizontal_rule_line_trims_only_ascii_space_tab_and_cr() {
assert!(is_horizontal_rule_line("---"));
assert!(is_horizontal_rule_line("--- \t\r"));
assert!(is_horizontal_rule_line(" ---"));
assert!(!is_horizontal_rule_line("---\u{2003}"));
assert!(!is_horizontal_rule_line("\u{a0}---"));
assert!(!is_horizontal_rule_line("***\u{3000}"));
}
fn rule_line_of(content: &str) -> Option<usize> {
horizontal_rule_line(&LintContext::new(content, MarkdownFlavor::Standard, None))
}
#[test]
fn test_tag_alone_on_a_line_does_not_end_the_paragraph_above_it() {
for content in [
"> o\n> <audio>\n> ***\n",
"> o\n> <video src=\"x\">\n> ***\n",
"> o\n> </audio>\n> ***\n",
"> o\n> <audio/>\n> ***\n",
"> o\n> </pre>\n> ***\n",
"> o\n> </script>\n> ***\n",
"> o\n> </style>\n> ***\n",
"> o\n> </textarea>\n> ***\n",
"> o\n> <span>\n> ***\n",
"> > o\n> > <audio>\n> > ***\n",
"> - o\n> <audio>\n> ***\n",
"- > o\n > <audio>\n > ***\n",
"- o\n <audio>\n ***\n",
"o\n<audio>\n ***\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
}
#[test]
fn test_tag_alone_on_a_line_opens_a_block_where_no_paragraph_runs_into_it() {
for content in [
"> <audio>\n> ***\n",
"> <audio>\n> ***\n",
"> <span>\n> ***\n",
"- <audio>\n ***\n",
"> <audio>\n>\n> o\n> <audio>\n> ***\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
assert_eq!(rule_line_of("> <audio>\n>\n> ***\n"), Some(3));
}
#[test]
fn test_break_below_a_block_level_tag_in_a_container_is_raw_html() {
for content in [
"> <div>\n> ***\n",
"> <div>\n> ***\n",
"> o\n> <div>\n> ***\n",
"> o\n> <div>\n> ***\n",
"- <div>\n ***\n",
"1. <div>\n x\n ***\n",
"> - <div>\n> ***\n",
"> > <div>\n> > ***\n",
"> <pre>\n> ***\n> </pre>\n",
"> <pre>\n>\n> ***\n> </pre>\n",
"> <script>\n> ***\n",
"- o\n <div>\n ***\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
assert_eq!(rule_line_of("> <div>\n>\n> ***\n"), Some(3));
assert_eq!(rule_line_of("- <div>\n\n ***\n"), Some(3));
assert_eq!(rule_line_of("> <div>\n> x\n>\n> ***\n"), Some(4));
assert_eq!(rule_line_of("> o\n> <audio>\n> ***\n"), Some(3));
assert_eq!(rule_line_of("> o\n> <audio>\n> ***\n"), Some(3));
}
#[test]
fn test_indented_run_in_a_quoted_footnote_continues_its_paragraph() {
for content in [
"> [^a]: o\n> ***\n\nref[^a]\n",
"> [^a]: o\n> ***\n\nref[^a]\n",
"> > [^a]: o\n> > ***\n\nref[^a]\n",
"> [^a]: o\n> ---\n\nref[^a]\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
for content in [
"> [^a]: o\n> - x\n> ***\n> y\n\nref[^a]\n",
"> > [^a]: o\n> > - x\n> > ***\n\nref[^a]\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
assert_eq!(rule_line_of("> [^a]: - o\n> ***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> [^a]: - o\n> ***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> [^a]: 1. o\n> ***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> [^a]: - o\n> ***\n\nref[^a]\n"), Some(2));
assert_eq!(rule_line_of("> [^a]: 1. o\n> ***\n\nref[^a]\n"), Some(2));
assert_eq!(rule_line_of("> > [^a]: o\n> ***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> [^a]: > o\n> > ***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> [^a]: > o\n> > ***\n\nref[^a]\n"), Some(2));
assert_eq!(
rule_line_of("> [^abcd]: -\to\n> ***\n> tail\n\nref[^abcd]\n"),
None
);
assert_eq!(rule_line_of("> [^a]: 1.\to\n> ***\n\nref[^a]\n"), None);
assert_eq!(
rule_line_of("> [^a\\]b]: o\n> ***\n> tail\n\nref[^a\\]b]\n"),
None
);
assert_eq!(rule_line_of("> [^a\\\\]: o\n> ***\n\nref[^a\\\\]\n"), None);
assert_eq!(rule_line_of("> [^a]: o\n \t***\n\nref[^a]\n"), None);
assert_eq!(rule_line_of("> o\n \t***\n"), None);
assert_eq!(rule_line_of("> o\n\n ***\n"), Some(3));
assert_eq!(rule_line_of("> [^a]: o\n> - x\n> ***\n\nref[^a]\n"), Some(3));
assert_eq!(rule_line_of("> [^a]: o\n> ***\n\nref[^a]\n"), Some(2));
assert_eq!(rule_line_of("> > [^a]: o\n> > ***\n\nref[^a]\n"), Some(2));
assert_eq!(rule_line_of("> [^a]: o\n>\n> ***\n\nref[^a]\n"), Some(3));
assert_eq!(rule_line_of("> [^a]: o\n> ***\n\nref[^a]\n"), Some(2));
}
#[test]
fn test_indented_code_in_a_quoted_footnote_holds_no_rule() {
for content in [
"> [^a]: o\n>\n> ***\n> y\n\nref[^a]\n",
"> [^a]: o\n>\n> ***\n\nref[^a]\n",
"> > [^a]: o\n> >\n> > ---\n> > y\n\nref[^a]\n",
"> [^a]: o\n>\n> * * *\n\nref[^a]\n",
] {
assert_eq!(rule_line_of(content), None, "{content:?}");
}
assert_eq!(rule_line_of("> [^a]: o\n>\n> ***\n\nref[^a]\n"), Some(3));
}
#[test]
fn indented_code_in_a_quote_opened_on_a_list_item_line_is_code() {
for content in [
"- > ## Code\n",
"- > code\n",
"- > code\n",
"- - > code\n",
"* > code\n",
"+ > code\n",
"> - > code\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let last = ctx.lines.len() - 1;
assert!(ctx.lines[last].in_code_block, "{content:?}: not a code block line");
assert_eq!(
ctx.code_blocks.len(),
1,
"{content:?}: code blocks {:?}",
ctx.code_blocks
);
assert_eq!(ctx.headings().count(), 0, "{content:?}: read as a heading");
}
}
#[test]
fn text_in_a_quote_opened_on_a_list_item_line_is_not_code() {
for content in [
"- > text\n",
"- > para\n > more\n",
"- > para\n > ## notcode\n",
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
assert!(
ctx.code_blocks.is_empty(),
"{content:?}: code blocks {:?}",
ctx.code_blocks
);
}
}
#[test]
fn source_inline_facts_defer_owned_compatibility_views() {
let source = "é `code` **bold** and _italic_\n\n`two\nlines`\n";
let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
assert!(ctx.code_spans_cache.get().is_none());
assert!(ctx.emphasis_spans_cache.get().is_none());
assert!(ctx.is_byte_offset_in_code_span(source.find("code").unwrap()));
assert!(!ctx.is_byte_offset_in_code_span(source.find("bold").unwrap()));
assert_eq!(ctx.source_emphasis_spans().len(), 2);
assert!(ctx.code_spans_cache.get().is_none());
assert!(ctx.emphasis_spans_cache.get().is_none());
let code = ctx.code_spans();
assert_eq!(code[0].content, "code");
assert_eq!((code[0].line, code[0].start_col, code[0].end_col), (1, 2, 8));
assert_eq!(code[1].content, "two\nlines");
assert_eq!((code[1].line, code[1].end_line), (3, 4));
let emphasis = ctx.emphasis_spans();
assert_eq!(emphasis[0].content, "bold");
assert_eq!(emphasis[1].content, "italic");
assert!(std::sync::Arc::ptr_eq(&code, &ctx.code_spans()));
assert!(std::sync::Arc::ptr_eq(&emphasis, &ctx.emphasis_spans()));
for pos in 0..=source.len() {
assert_eq!(
ctx.is_byte_offset_in_code_span(pos),
code.iter().any(|span| span.byte_offset <= pos && pos < span.byte_end)
);
}
}
#[test]
fn builtin_inline_rules_keep_owned_views_unmaterialized() {
let source = "# A ` heading ` with *emphasis*\n\n中文`code` _other_ and https://example.com\n";
let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
let names = [
"MD013", "MD034", "MD038", "MD044", "MD049", "MD052", "MD053", "MD064", "MD081", "MD087", "MD089", "MD093",
];
let rules: Vec<_> = crate::rules::all_rules(&crate::config::Config::default())
.into_iter()
.filter(|rule| names.contains(&rule.name()))
.collect();
assert_eq!(rules.len(), names.len());
for rule in rules {
rule.check(&ctx).unwrap();
assert!(ctx.code_spans_cache.get().is_none(), "{} copied code text", rule.name());
assert!(
ctx.emphasis_spans_cache.get().is_none(),
"{} copied emphasis text",
rule.name()
);
}
}
#[test]
fn source_code_ranges_preserve_continuation_and_supplement_metadata() {
for source in [
"`first\nlast`\n",
"`first\n`tail\n",
"é `first\nlast`\n",
"!!! note\n `first\n last`\n",
"=== \"Tab\"\n `first\n last`\n",
"<Panel>\n`first\nlast`\n</Panel>\n",
"{::comment}\n*hidden*\n{:/comment}\n*shown*\n",
] {
for ending in ["\n", "\r\n", "\r"] {
let source = source.replace('\n', ending);
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::MDX,
MarkdownFlavor::Kramdown,
] {
let ctx = LintContext::new(&source, flavor, None);
let spans = ctx.code_spans();
let ranges: Vec<_> = spans.iter().map(|span| (span.byte_offset, span.byte_end)).collect();
assert_eq!(ctx.inline_code_ranges(), ranges);
for pos in 0..=source.len() + 1 {
assert_eq!(
ctx.is_byte_offset_in_code_span(pos),
spans.iter().any(|span| span.byte_offset <= pos && pos < span.byte_end)
);
}
for (index, line) in ctx.lines.iter().enumerate() {
let expected = spans.iter().any(|span| span.line < index + 1 && index < span.end_line);
assert_eq!(
line.in_code_span_continuation, expected,
"{flavor:?}: {source:?}, line {index}"
);
}
for span in spans.iter() {
assert_eq!(span.content, source[span.byte_offset..span.byte_end].trim_matches('`'));
}
}
}
}
}
#[test]
fn untracked_heading_punctuation_keeps_has_char_fallback() {
let source = "A heading with .,;:!?。!?—§ characters\n";
let ctx = LintContext::new(source, MarkdownFlavor::Standard, None);
for ch in ".,;:!?。!?—§x".chars() {
assert_eq!(ctx.has_char(ch), source.contains(ch));
}
}
#[test]
fn rendered_link_membership_preserves_unresolved_reference_and_image_boundaries() {
let source =
"[ * spaced * ](url) [ * spaced * ][known] [ * spaced * ][unknown] \n\n[known]: /url\n";
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::MkDocs,
MarkdownFlavor::MDX,
MarkdownFlavor::Kramdown,
] {
let ctx = LintContext::new(source, flavor, None);
for pos in 0..=source.len() {
let expected = ctx.links.iter().any(|link| {
!(link.is_reference && link.url.is_empty()) && link.byte_offset <= pos && pos < link.byte_end
});
assert_eq!(
ctx.is_range_in_rendered_link(&(pos..pos + 1)),
expected,
"{flavor:?}: {pos}"
);
}
}
}