use rumdl_lib::config::{Config, MarkdownFlavor};
use rumdl_lib::lint;
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::*;
fn create_mkdocs_config() -> Config {
let mut config = Config::default();
config.global.flavor = MarkdownFlavor::MkDocs;
config
}
fn lint_mkdocs(content: &str) -> Vec<rumdl_lib::rule::LintWarning> {
let config = create_mkdocs_config();
let rules = filter_rules(&all_rules(&config), &config.global);
lint(content, &rules, false, MarkdownFlavor::MkDocs, None, None).unwrap()
}
fn lint_standard(content: &str) -> Vec<rumdl_lib::rule::LintWarning> {
let config = Config::default();
let rules = filter_rules(&all_rules(&config), &config.global);
lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap()
}
mod basic_recognition {
use super::*;
#[test]
fn test_admonitions_all_types() {
let content = r#"# Admonitions
!!! note
Note content.
!!! abstract
Abstract content.
!!! info
Info content.
!!! tip
Tip content.
!!! success
Success content.
!!! question
Question content.
!!! warning
Warning content.
!!! failure
Failure content.
!!! danger
Danger content.
!!! bug
Bug content.
!!! example
Example content.
!!! quote
Quote content.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"All admonition types should be recognized: {warnings:?}"
);
}
#[test]
fn test_collapsible_admonitions() {
let content = r#"# Collapsible
??? note "Collapsed"
Hidden by default.
???+ warning "Expanded"
Visible by default.
??? abstract
No title, collapsed.
???+ tip
No title, expanded.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Collapsible admonitions should work: {warnings:?}");
}
#[test]
fn test_content_tabs_variations() {
let content = r#"# Tabs
=== "Tab 1"
Content 1.
=== "Tab 2"
Content 2.
=== "Tab with 'quotes'"
Quoted title.
=== "Tab with \"double quotes\""
Double quoted.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Content tabs should work: {warnings:?}");
}
#[test]
fn test_mkdocstrings_variations() {
let content = r#"# API Docs
::: module
handler: python
::: package.module.Class
options:
show_source: true
heading_level: 2
::: function
:::module.without.space
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "mkdocstrings blocks should work: {warnings:?}");
}
#[test]
fn test_pymdown_inline_extensions() {
let content = r#"# Inline Extensions
Keys: ++ctrl+c++ and ++ctrl+alt+del++
Caret: ^superscript^ and ^^insert^^
Tilde: ~subscript~ and ~~strikethrough~~
Mark: ==highlighted==
Critic: {++added++} {--deleted--} {~~old~>new~~} {==marked==} {>>comment<<}
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"PyMdown inline extensions should work: {warnings:?}"
);
}
#[test]
fn test_snippets_syntax() {
let content = r#"# Snippets
--8<-- "file.md"
--8<-- "path/to/file.py"
--8<-- "file.md:10:20"
;--8<--
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Snippets should work: {warnings:?}");
}
#[test]
fn test_abbreviations() {
let content = r#"# Abbreviations
The HTML and CSS specifications.
*[HTML]: Hypertext Markup Language
*[CSS]: Cascading Style Sheets
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Abbreviations should work: {warnings:?}");
}
#[test]
fn test_definition_lists() {
let content = r#"# Definitions
Term 1
: Definition 1
Term 2
: Definition 2a
: Definition 2b
Term 3
: Multi-line
definition.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Definition lists should work: {warnings:?}");
}
#[test]
fn test_footnotes() {
let content = r#"# Footnotes
Text with footnote.[^1] Another.[^named]
[^1]: Simple footnote.
[^named]:
Multi-paragraph
footnote content.
Second paragraph.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Footnotes should work: {warnings:?}");
}
#[test]
fn test_attribute_lists() {
let content = r#"# Attributes
## Heading {#custom-id}
## Another {.class-name}
## Multiple {#id .class data-value="test"}
Paragraph.
{.centered}
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Attribute lists should work: {warnings:?}");
}
#[test]
fn test_superfences_with_attributes() {
let content = r#"# Code Blocks
```python title="example.py"
print("hello")
```
```javascript linenums="1" hl_lines="2-3"
const a = 1;
const b = 2;
const c = 3;
```
```mermaid
graph TD
A --> B
```
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Superfences should work: {warnings:?}");
}
#[test]
fn test_math_blocks() {
let content = r#"# Math
Inline: $E = mc^2$ and $\int_0^1 x dx$
Block:
$$
\frac{n!}{k!(n-k)!} = \binom{n}{k}
$$
$$
\sum_{i=1}^{n} i = \frac{n(n+1)}{2}
$$
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Math blocks should work: {warnings:?}");
}
#[test]
fn test_emoji_shortcodes() {
let content = r#"# Emoji
Material: :material-check: :material-close: :material-github:
FontAwesome: :fontawesome-brands-github: :fontawesome-solid-heart:
Octicons: :octicons-mark-github-16: :octicons-alert-24:
Twemoji: :smile: :heart: :rocket:
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Emoji shortcodes should work: {warnings:?}");
}
#[test]
fn test_inline_code_highlighting() {
let content = r#"# InlineHilite
Use `#!python print("hello")` for Python.
Or `#!javascript console.log("hi")` for JS.
Generic: `#!bash echo $PATH`
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "InlineHilite should work: {warnings:?}");
}
#[test]
fn test_md_in_html() {
let content = r#"# MD in HTML
<div markdown="1">
**Bold** and *italic* work here.
- List item
- Another
</div>
<div markdown="block">
More content.
</div>
"#;
let warnings = lint_mkdocs(content);
let md033 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD033"))
.count();
assert_eq!(md033, 0, "md_in_html should not trigger MD033");
}
#[test]
fn test_toc_marker() {
let content = r#"# Document
[TOC]
## Section 1
## Section 2
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "TOC marker should work: {warnings:?}");
}
#[test]
fn test_tasklists() {
let content = r#"# Tasks
- [x] Completed
- [ ] Incomplete
- [X] Also completed
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Tasklists should work: {warnings:?}");
}
#[test]
fn test_smartsymbols() {
let content = r#"# Symbols
Copyright (c) and trademark (tm) and registered (r).
Arrows: --> <-- <-->
Fractions: 1/4 1/2 3/4
Dashes: -- and ---
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "SmartSymbols should work: {warnings:?}");
}
}
mod edge_cases {
use super::*;
#[test]
fn test_admonition_without_content() {
let content = r#"# Empty Admonitions
!!! note
!!! warning "Title Only"
Text after.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Empty admonitions should be handled: {warnings:?}");
}
#[test]
fn test_admonition_single_line() {
let content = r#"# Single Line
!!! note "Title" Content on same line is not standard but should not crash.
Regular text.
"#;
let _ = lint_mkdocs(content);
}
#[test]
fn test_deeply_nested_admonitions() {
let content = r#"# Deep Nesting
!!! note "Level 1"
Content level 1.
!!! warning "Level 2"
Content level 2.
!!! danger "Level 3"
Content level 3.
!!! tip "Level 4"
Maximum reasonable nesting.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Deeply nested admonitions should work: {warnings:?}"
);
}
#[test]
fn test_tabs_with_code_blocks() {
let content = r#"# Tabs with Code
=== "Python"
```python
def hello():
print("Hello")
```
=== "Rust"
```rust
fn main() {
println!("Hello");
}
```
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Tabs with code should work: {warnings:?}");
}
#[test]
fn test_tabs_inside_admonition() {
let content = r#"# Nested Tabs
!!! example "Code Examples"
=== "Python"
```python
print("hello")
```
=== "JavaScript"
```javascript
console.log("hello");
```
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Tabs inside admonitions should work: {warnings:?}");
}
#[test]
fn test_mkdocstrings_with_complex_paths() {
let content = r#"# Complex Paths
::: package.subpackage.module.Class.method
::: _private_module._PrivateClass
::: module.Class.__init__
::: package.module.CONSTANT
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Complex mkdocstrings paths should work: {warnings:?}"
);
}
#[test]
fn test_keys_with_special_characters() {
let content = r#"# Special Keys
Simple: ++enter++ ++escape++ ++space++
Modifiers: ++ctrl+shift+alt+del++
Function keys: ++f1++ ++f12++
Arrows: ++arrow-up++ ++arrow-down++ ++arrow-left++ ++arrow-right++
Numpad: ++num0++ ++num-lock++
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Special keys should work: {warnings:?}");
}
#[test]
fn test_inline_extensions_adjacent() {
let content = r#"# Adjacent
Text ==highlight==^super^~sub~**bold** end.
Keys ++ctrl+c++++ctrl+v++ adjacent.
Mixed {++add++}{--del--} together.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Adjacent inline extensions should work: {warnings:?}"
);
}
#[test]
fn test_inline_extensions_in_emphasis() {
let content = r#"# In Emphasis
This is **bold with ==highlight== inside** text.
This is *italic with ^super^ inside* text.
This is ***bold italic with ~sub~ inside*** text.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Extensions in emphasis should work: {warnings:?}");
}
#[test]
fn test_math_with_special_characters() {
let content = r#"# Complex Math
Inline: $\alpha + \beta = \gamma$ and $x_{i,j}^{2}$
Block with alignment:
$$
\begin{aligned}
a &= b + c \\
d &= e + f
\end{aligned}
$$
Matrices:
$$
\begin{pmatrix}
a & b \\
c & d
\end{pmatrix}
$$
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Complex math should work: {warnings:?}");
}
#[test]
fn test_footnotes_complex_content() {
let content = r#"# Complex Footnotes
Text[^complex] here.
[^complex]:
This footnote has:
- A list
- With items
```python
# And code
print("hello")
```
And more text.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Complex footnotes should work: {warnings:?}");
}
#[test]
fn test_abbreviation_with_special_chars() {
let content = r#"# Special Abbreviations
Using HTML5 and CSS3 and ES6+ features.
*[HTML5]: Hypertext Markup Language version 5
*[CSS3]: Cascading Style Sheets version 3
*[ES6+]: ECMAScript 6 and later
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Abbreviations with special chars should work: {warnings:?}"
);
}
#[test]
fn test_snippet_paths_with_special_chars() {
let content = r#"# Special Paths
--8<-- "path/to/file-name.md"
--8<-- "path/to/file_name.py"
--8<-- "../relative/path.txt"
--8<-- "./same-dir/file.md"
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Snippet paths should work: {warnings:?}");
}
#[test]
fn test_attribute_list_complex() {
let content = r#"# Complex Attributes
## Heading {#my-id .class1 .class2 data-foo="bar" data-baz='qux'}
Paragraph with many attributes.
{#para-id .styled .centered style="color: red" data-toggle="tooltip"}
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Complex attributes should work: {warnings:?}");
}
#[test]
fn test_definition_list_with_markdown() {
let content = r#"# Rich Definitions
Term with **bold**
: Definition with *italic* and `code`.
Second paragraph with [definition list documentation](https://example.com).
Another Term
: - List item 1
- List item 2
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Rich definitions should work: {warnings:?}");
}
#[test]
fn test_critic_markup_edge_cases() {
let content = r#"# Critic Edge Cases
Empty: {++++} {----}
Nested braces: {++text with {braces}++}
Multi-word: {++multiple words added here++}
With punctuation: {--removed, with punctuation!--}
Complex substitution: {~~old text with *emphasis*~>new text with **bold**~~}
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Critic edge cases should work: {warnings:?}");
}
#[test]
fn test_unclosed_inline_extensions() {
let content = r#"# Unclosed
Unclosed key ++ctrl
Unclosed mark ==highlight
Unclosed caret ^super
Regular text after.
"#;
let _ = lint_mkdocs(content);
}
#[test]
fn test_empty_content() {
let content = "";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Empty content should work: {warnings:?}");
}
#[test]
fn test_only_extension_markers() {
let content = r#"!!! note
Only admonition.
"#;
let warnings = lint_mkdocs(content);
let non_md041 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() != Some("MD041"))
.count();
assert_eq!(
non_md041, 0,
"Only extension markers should work except MD041: {warnings:?}"
);
}
}
mod negative_tests {
use super::*;
#[test]
fn test_violations_in_admonition_content() {
let content = "# Test\n\n!!! note\n Content here.\n\n\n More content.\n";
let warnings = lint_mkdocs(content);
let md012 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD012"))
.count();
assert!(
md012 > 0,
"MD012 should detect multiple blanks in admonition: {warnings:?}"
);
}
#[test]
fn test_trailing_spaces_in_admonition() {
let content = "# Test\n\n!!! note\n Line with trailing spaces \n";
let warnings = lint_mkdocs(content);
let md009 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD009"))
.count();
assert!(
md009 > 0,
"MD009 should detect trailing spaces in admonition: {warnings:?}"
);
}
#[test]
fn test_long_lines_detected() {
let content = "# Test\n\nThis is a very long line that exceeds the default 80 character limit and should definitely trigger MD013 in the linter.\n";
let warnings = lint_mkdocs(content);
let md013 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD013"))
.count();
assert!(md013 > 0, "MD013 should detect long lines: {warnings:?}");
}
#[test]
fn test_multiple_blank_lines_detected() {
let content = "# Test\n\nParagraph 1.\n\n\n\nParagraph 2.\n";
let warnings = lint_mkdocs(content);
let md012 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD012"))
.count();
assert!(md012 > 0, "MD012 should detect multiple blank lines: {warnings:?}");
}
#[test]
fn test_heading_increment_detected() {
let content = "# H1\n\n### H3 skipping H2\n";
let warnings = lint_mkdocs(content);
let md001 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD001"))
.count();
assert!(md001 > 0, "MD001 should detect heading increment: {warnings:?}");
}
#[test]
fn test_bare_url_detected() {
let content = "# Test\n\nVisit https://example.com for more.\n";
let warnings = lint_mkdocs(content);
let md034 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.count();
assert!(md034 > 0, "MD034 should detect bare URLs: {warnings:?}");
}
#[test]
fn test_hard_tabs_detected() {
let content = "# Test\n\n\tIndented with tab.\n";
let warnings = lint_mkdocs(content);
let md010 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD010"))
.count();
assert!(md010 > 0, "MD010 should detect hard tabs: {warnings:?}");
}
#[test]
fn test_emphasis_used_as_heading_detected() {
let content = "# Test\n\n**This looks like a heading**\n\nBut it's just bold text.\n";
let warnings = lint_mkdocs(content);
let md036 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD036"))
.count();
assert!(md036 > 0, "MD036 should detect emphasis as heading: {warnings:?}");
}
#[test]
fn test_standard_flavor_flags_mkdocs_syntax() {
let content = r#"# Test
!!! note
This is not an admonition in standard markdown.
"#;
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len()
|| mkdocs_warnings.iter().map(|w| &w.rule_name).collect::<Vec<_>>()
!= standard_warnings.iter().map(|w| &w.rule_name).collect::<Vec<_>>(),
"Standard and MkDocs should handle admonitions differently"
);
}
#[test]
fn test_unreferenced_footnote_detected() {
let content = "# Test\n\n[^orphan]: This footnote is never used.\n";
let warnings = lint_mkdocs(content);
let md066 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD066"))
.count();
assert!(md066 > 0, "MD066 should detect unreferenced footnotes: {warnings:?}");
}
#[test]
fn test_undefined_reference_detected() {
let content = "# Test\n\nSee [undefined reference][nowhere] here.\n";
let warnings = lint_mkdocs(content);
let md052 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD052"))
.count();
assert!(md052 > 0, "MD052 should detect undefined references: {warnings:?}");
}
}
mod rule_interactions {
use super::*;
#[test]
fn test_md031_blanks_around_admonitions() {
let content = r#"# Test
Text before.
!!! note
Admonition content.
Text after.
"#;
let warnings = lint_mkdocs(content);
let md031 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD031"))
.count();
assert_eq!(
md031, 0,
"MD031 should not flag properly spaced admonitions: {warnings:?}"
);
}
#[test]
fn test_md038_with_inlinehilite() {
let content = "# Test\n\nUse `#!python print()` here.\n";
let warnings = lint_mkdocs(content);
let md038 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.count();
assert_eq!(md038, 0, "MD038 should not flag InlineHilite: {warnings:?}");
}
#[test]
fn test_md040_with_superfences() {
let content = "# Test\n\n```mermaid\ngraph TD\n A --> B\n```\n";
let warnings = lint_mkdocs(content);
let md040 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD040"))
.count();
assert_eq!(md040, 0, "MD040 should recognize mermaid as language: {warnings:?}");
}
#[test]
fn test_md042_with_auto_references() {
let content = "# Test\n\nSee [module.Class][] for details.\n";
let warnings = lint_mkdocs(content);
let md042 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD042"))
.count();
assert_eq!(md042, 0, "MD042 should allow auto-references: {warnings:?}");
}
#[test]
fn test_md046_with_tabs_and_admonitions() {
let content = r#"# Test
=== "Tab 1"
```python
code()
```
=== "Tab 2"
```python
more_code()
```
"#;
let warnings = lint_mkdocs(content);
let md046 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD046"))
.count();
assert_eq!(md046, 0, "MD046 should handle code in tabs: {warnings:?}");
}
#[test]
fn test_md033_with_md_in_html() {
let content = "# Test\n\n<div markdown=\"1\">\nContent.\n</div>\n";
let warnings = lint_mkdocs(content);
let md033 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD033"))
.count();
assert_eq!(md033, 0, "MD033 should allow markdown attribute: {warnings:?}");
}
#[test]
fn test_md049_md050_with_pymdown() {
let content = "# Test\n\nThis is ==marked== and *italic* and **bold**.\n";
let warnings = lint_mkdocs(content);
let md049 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD049"))
.count();
let md050 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD050"))
.count();
assert_eq!(
md049 + md050,
0,
"MD049/MD050 should not flag mark syntax: {warnings:?}"
);
}
#[test]
fn test_md032_with_lists_in_admonitions() {
let content = r#"# Test
!!! note
Text before list.
- Item 1
- Item 2
Text after list.
"#;
let warnings = lint_mkdocs(content);
let md032 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD032"))
.count();
assert_eq!(md032, 0, "MD032 should handle lists in admonitions: {warnings:?}");
}
#[test]
fn test_md022_md023_with_attr_list() {
let content = "# Test\n\n## Heading {#custom-id}\n\nText.\n";
let warnings = lint_mkdocs(content);
let md022 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD022"))
.count();
let md023 = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD023"))
.count();
assert_eq!(md022 + md023, 0, "MD022/MD023 should handle attr_list: {warnings:?}");
}
#[test]
fn test_md024_with_tabs() {
let content = r#"# Guide
=== "Python"
## Installation
Install with pip.
=== "JavaScript"
## Installation
Install with npm.
"#;
let warnings = lint_mkdocs(content);
let _ = warnings; }
}
mod fix_preservation {
use super::*;
fn assert_fix_preserves(content: &str, rule: &dyn Rule, rule_name: &str) {
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
if content.contains("!!!") {
assert!(fixed.contains("!!!"), "{rule_name} should preserve admonitions");
}
if content.contains("===") {
assert!(fixed.contains("==="), "{rule_name} should preserve tabs");
}
if content.contains(":::") {
assert!(fixed.contains(":::"), "{rule_name} should preserve mkdocstrings");
}
if content.contains("++") && content.contains("++") {
assert!(fixed.matches("++").count() >= 2, "{rule_name} should preserve keys");
}
if content.contains("--8<--") {
assert!(fixed.contains("--8<--"), "{rule_name} should preserve snippets");
}
}
#[test]
fn test_md009_preserves_extensions() {
let content = "# Test\n\n!!! note\n Content here. \n\n=== \"Tab\"\n\n Tab content.\n";
let rule = MD009TrailingSpaces::default();
assert_fix_preserves(content, &rule, "MD009");
}
#[test]
fn test_md010_preserves_extensions() {
let content = "# Test\n\n!!! note\n\tTabbed content.\n";
let rule = MD010NoHardTabs::default();
assert_fix_preserves(content, &rule, "MD010");
}
#[test]
fn test_md012_preserves_extensions() {
let content = "# Test\n\n\n!!! note\n Content.\n\n\n=== \"Tab\"\n\n More.\n";
let rule = MD012NoMultipleBlanks::default();
assert_fix_preserves(content, &rule, "MD012");
}
#[test]
fn test_md013_preserves_extensions() {
let content = r#"# Test
!!! note "A very long admonition title that might exceed line length limits"
Content inside the admonition that is also quite long and might be wrapped.
=== "Tab with a somewhat long title"
Tab content here.
::: module.path.to.a.deeply.nested.Class
options:
show_source: true
"#;
let config = create_mkdocs_config();
let rule = MD013LineLength::from_config(&config);
assert_fix_preserves(content, rule.as_ref(), "MD013");
}
#[test]
fn test_md022_preserves_extensions() {
let content = "# Test\n## Heading {#custom-id}\nText.\n";
let config = create_mkdocs_config();
let rule = MD022BlanksAroundHeadings::from_config(&config);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("{#custom-id}"), "MD022 should preserve attribute lists");
}
#[test]
fn test_md023_preserves_extensions() {
let content = "# Test\n\n ## Indented {.class}\n\nText.\n";
let config = create_mkdocs_config();
let rule = MD023HeadingStartLeft::from_config(&config);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("{.class}"), "MD023 should preserve attribute lists");
}
#[test]
fn test_md031_preserves_extensions() {
let content = "# Test\n!!! note\n Content.\nText after.\n";
let rule = MD031BlanksAroundFences::default();
assert_fix_preserves(content, &rule, "MD031");
}
#[test]
fn test_md032_preserves_extensions() {
let content = "# Test\n!!! note\n - Item 1\n - Item 2\nText.\n";
let rule = MD032BlanksAroundLists::default();
assert_fix_preserves(content, &rule, "MD032");
}
#[test]
fn test_md047_preserves_extensions() {
let content = "# Test\n\n!!! note\n Content.\n";
let rule = MD047SingleTrailingNewline;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("!!!"), "MD047 should preserve admonitions");
}
#[test]
fn test_multiple_fixes_preserve_extensions() {
let content = r#"# Test
!!! note
Content with trailing spaces.
=== "Tab"
Tab content.
Extra blank lines above.
::: module.Class
options:
show: true
"#;
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let md009 = MD009TrailingSpaces::default();
let fixed1 = md009.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&fixed1, MarkdownFlavor::MkDocs, None);
let md012 = MD012NoMultipleBlanks::default();
let fixed2 = md012.fix(&ctx2).unwrap();
assert!(fixed2.contains("!!!"), "Multiple fixes should preserve admonitions");
assert!(fixed2.contains("==="), "Multiple fixes should preserve tabs");
assert!(fixed2.contains(":::"), "Multiple fixes should preserve mkdocstrings");
}
}
mod stress_tests {
use super::*;
#[test]
fn test_comprehensive_document() {
let content = r#"# Comprehensive MkDocs Document
[TOC]
## Introduction
This document tests all MkDocs extensions. The HTML[^1] works.
*[HTML]: Hypertext Markup Language
*[CSS]: Cascading Style Sheets
## Admonitions with Everything
!!! note "Complex Admonition"
This admonition contains:
- Task lists:
- [x] Completed
- [ ] Pending
- Keys: Press ++ctrl+c++ to copy.
- Code:
```python title="example.py"
print("Hello")
```
- Math: $E = mc^2$
- Formatting: ==highlighted== and ^^inserted^^
??? tip "Collapsible with Tabs"
=== "Python"
```python
def greet():
print("Hello")
```
=== "Rust"
```rust
fn greet() {
println!("Hello");
}
```
## API Reference
::: mypackage.core.MainClass
handler: python
options:
show_source: true
heading_level: 3
members:
- __init__
- process
- cleanup
See [mypackage.core.MainClass][] for details.
## Definition Lists
API
: Application Programming Interface.
Used for:
- Communication
- Integration
SDK
: Software Development Kit.
## Complex Math
Inline: $\sum_{i=1}^{n} x_i$ and $\int_0^\infty e^{-x} dx$
Block:
$$
\mathbf{V}_1 \times \mathbf{V}_2 =
\begin{vmatrix}
\mathbf{i} & \mathbf{j} & \mathbf{k} \\
\frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
\frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0
\end{vmatrix}
$$
## Critic Markup
Has {++additions++}, {--deletions--}, and {~~old~>new~~}.
It also has {==highlights==} and {>>author comments<<}.
## Snippets
--8<-- "examples/header.md"
## Formatting Summary
| Feature | Syntax | Example |
|---------|--------|---------|
| Keys | `++key++` | ++enter++ |
| Mark | `==text==` | ==marked== |
| Super | `^text^` | ^super^ |
| Sub | `~text~` | ~sub~ |
---
[^1]: Footnote with complex content:
Including code:
```python
print("footnote code")
```
And lists:
- Item 1
- Item 2
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Comprehensive document should have no warnings: {warnings:?}"
);
}
#[test]
fn test_maximum_nesting_depth() {
let content = r#"# Maximum Nesting
!!! note "Level 1"
!!! warning "Level 2"
!!! danger "Level 3"
=== "Tab A"
```python title="nested.py"
def deeply_nested():
"""
Docstring with math: $x^2$
"""
pass
```
=== "Tab B"
::: module.Class
options:
show: true
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Maximum nesting should work: {warnings:?}");
}
#[test]
fn test_all_inline_extensions_together() {
let content = r#"# All Inline Extensions
This has ==highlights==, ^super^, ~sub~, ~~strike~~, ^^insert^^.
Also ++keys++ and `#!python code()`.
Math inline: $\alpha + \beta = \gamma$
Emoji: :material-check: :fontawesome-solid-heart:
Critic: {++add++} {--del--} {~~old~>new~~} {==mark==} {>>note<<}
Combined: ==mark== ^sup^ ~sub~ ^^ins^^ ++key++ `#!py x` $x$ :smile:
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "All inline extensions should work: {warnings:?}");
}
#[test]
fn test_rapid_context_switching() {
let content = r#"# Rapid Switching
!!! note
Note.
=== "Tab"
Tab.
::: mod
opt: val
!!! warning
Warning.
=== "Another"
Another.
::: other
more: opts
Regular paragraph.
!!! tip
Final.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Rapid context switching should work: {warnings:?}");
}
#[test]
fn test_large_document_performance() {
let mut content = String::from("# Large Document\n\n");
for i in 0..50 {
content.push_str(&format!("## Section {i}\n\n"));
content.push_str(&format!("!!! note \"Note {i}\"\n Content for note {i}.\n\n"));
content.push_str(&format!("=== \"Tab A{i}\"\n\n Tab A content.\n\n"));
content.push_str(&format!("=== \"Tab B{i}\"\n\n Tab B content.\n\n"));
content.push_str(&format!("::: module{i}.Class\n\n"));
content.push_str(&format!("Text with ==hi{i}== and ++key{i}++.\n\n"));
}
let content = content.trim_end().to_string() + "\n";
let start = std::time::Instant::now();
let warnings = lint_mkdocs(&content);
let duration = start.elapsed();
assert!(
warnings.is_empty(),
"Large document should have no warnings: {warnings:?}"
);
assert!(
duration.as_secs() < 10,
"Large document should lint in reasonable time: {duration:?}"
);
}
#[test]
fn test_unicode_in_extensions() {
let content = r#"# Unicode Extensions
!!! note "日本語タイトル"
Japanese content: こんにちは
=== "Français"
Contenu français avec accents: é, è, ê, ë
=== "中文"
中文内容
::: module.Ελληνικά
Text with ==强调== and ^^挿入^^ and ~下付き~.
*[API]: アプリケーションプログラミングインターフェース
Press ++ctrl+日++ for Japanese.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Unicode in extensions should work: {warnings:?}");
}
#[test]
fn test_special_characters_in_extensions() {
let content = r#"# Special Characters
!!! note "Title with <angle> & 'quotes' \"doubles\""
Content with special chars: < > & ' " ` ~
=== "Tab with `backticks`"
Content.
=== "Tab with *asterisks*"
More content.
::: module.Class_with_underscores
*[C++]: C Plus Plus
*[C#]: C Sharp
Keys: ++ctrl+<++ and ++>+shift++
"#;
let warnings = lint_mkdocs(content);
let _ = warnings;
}
#[test]
fn test_empty_extension_blocks() {
let content = r#"# Empty Blocks
!!! note
!!! warning ""
=== ""
=== "Empty Tab"
:::
::: module
Text after empty blocks.
"#;
let _ = lint_mkdocs(content);
}
#[test]
fn test_extensions_at_document_boundaries() {
let content1 = "!!! note\n Start with admonition.\n";
let _ = lint_mkdocs(content1);
let content2 = "# Title\n\n!!! note\n End with admonition.";
let _ = lint_mkdocs(content2);
let content3 = "!!! note\n Only.\n\n=== \"Tab\"\n Tab.\n\n::: mod\n";
let _ = lint_mkdocs(content3);
}
}
mod table_extensions {
use super::*;
#[test]
fn test_basic_table_in_mkdocs() {
let content = r#"# Tables
| Header 1 | Header 2 | Header 3 |
|----------|----------|----------|
| Cell 1 | Cell 2 | Cell 3 |
| Cell 4 | Cell 5 | Cell 6 |
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Basic table should work: {warnings:?}");
}
#[test]
fn test_table_in_admonition() {
let content = r#"# Table in Admonition
!!! note "Table Example"
| Header | Value |
|--------|-------|
| Key | Val |
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Table in admonition should work: {warnings:?}");
}
#[test]
fn test_table_in_content_tab() {
let content = r#"# Table in Tab
=== "Data Table"
| ID | Name | Status |
|----|---------|--------|
| 1 | Alice | Active |
| 2 | Bob | Active |
=== "Summary Table"
| Metric | Value |
|--------|-------|
| Total | 100 |
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Table in content tab should work: {warnings:?}");
}
#[test]
fn test_table_with_inline_extensions() {
let content = r#"# Tables with Extensions
| Feature | Syntax | Result |
|---------|--------|--------|
| Keys | `++ctrl++` | ++ctrl++ |
| Mark | `==text==` | ==text== |
| Math | `$x^2$` | $x^2$ |
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Table with inline extensions should work: {warnings:?}"
);
}
#[test]
fn test_table_alignment_variations() {
let content = r#"# Aligned Tables
| Left | Center | Right |
|:-----|:------:|------:|
| L | C | R |
| L | C | R |
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Table alignment should work: {warnings:?}");
}
#[test]
fn test_table_after_admonition() {
let content = r#"# Table After Admonition
!!! info
Some info here.
| After | Admonition |
|-------|------------|
| Data | Here |
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Table after admonition should work: {warnings:?}");
}
#[test]
fn test_multiple_tables_with_extensions() {
let content = r#"# Multiple Tables
!!! note "First Table"
| A | B |
|---|---|
| 1 | 2 |
!!! warning "Second Table"
| C | D |
|---|---|
| 3 | 4 |
Regular table:
| E | F |
|---|---|
| 5 | 6 |
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Multiple tables with extensions should work: {warnings:?}"
);
}
}
mod frontmatter_tests {
use super::*;
#[test]
fn test_yaml_frontmatter_basic() {
let content = r#"---
title: Test Document
description: A test document for MkDocs
---
## Overview
Content here.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "YAML frontmatter should work: {warnings:?}");
}
#[test]
fn test_frontmatter_with_extensions() {
let content = r#"---
title: Extensions Test
tags:
- mkdocs
- testing
---
## Extensions
!!! note
Content after frontmatter.
=== "Tab"
Tab content.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Frontmatter with extensions should work: {warnings:?}"
);
}
#[test]
fn test_frontmatter_with_special_yaml() {
let content = r#"---
title: "Title with: colon"
description: |
Multi-line
description
list:
- item1
- item2
---
## Content
Text here.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Complex YAML frontmatter should work: {warnings:?}"
);
}
#[test]
fn test_frontmatter_not_confused_with_hr() {
let content = r#"---
title: Test
---
## Heading
Content.
---
More content after horizontal rule.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "HR after frontmatter should work: {warnings:?}");
}
#[test]
fn test_frontmatter_with_mkdocstrings_config() {
let content = r#"---
title: API Reference
plugins:
- mkdocstrings:
handlers:
python:
options:
show_source: true
---
## API Reference
::: mymodule.MyClass
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Frontmatter with mkdocstrings config should work: {warnings:?}"
);
}
#[test]
fn test_toml_frontmatter() {
let content = r#"+++
title = "TOML Frontmatter"
date = 2024-01-01
+++
# TOML Test
Content.
"#;
let _ = lint_mkdocs(content);
}
}
mod extension_interactions {
use super::*;
#[test]
fn test_tabs_inside_admonition() {
let content = r#"# Nested Extensions
!!! example "Code Examples"
=== "Python"
```python
def hello():
print("Hello")
```
=== "Rust"
```rust
fn hello() {
println!("Hello");
}
```
=== "Go"
```go
func hello() {
fmt.Println("Hello")
}
```
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Tabs inside admonition should work: {warnings:?}");
}
#[test]
fn test_admonition_inside_tabs() {
let content = r#"# Admonitions in Tabs
=== "Notes"
!!! note
A note inside a tab.
!!! warning
A warning inside the same tab.
=== "Tips"
!!! tip
A tip in another tab.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Admonitions inside tabs should work: {warnings:?}");
}
#[test]
fn test_code_blocks_inside_nested_extensions() {
let content = r#"# Deep Code Nesting
!!! example
=== "With Highlighting"
```python title="example.py" hl_lines="2 3"
def process():
data = load()
result = transform(data)
return result
```
=== "With Line Numbers"
```python linenums="1"
def other():
pass
```
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Code blocks in nested extensions should work: {warnings:?}"
);
}
#[test]
fn test_mkdocstrings_inside_admonition() {
let content = r#"# API in Admonition
!!! info "Quick Reference"
::: mymodule.quick_function
options:
show_source: false
!!! example "Full Reference"
::: mymodule.detailed_function
options:
show_source: true
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"mkdocstrings inside admonition should work: {warnings:?}"
);
}
#[test]
fn test_definition_list_inside_admonition() {
let content = r#"# Definitions in Admonition
!!! note "Terminology"
Term 1
: Definition of term 1.
Term 2
: Definition of term 2.
With additional detail.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Definition list in admonition should work: {warnings:?}"
);
}
#[test]
fn test_footnotes_with_extensions() {
let content = r#"# Footnotes and Extensions
Text with footnote[^1] and ==highlighting==.
!!! note
Content with another footnote[^2].
[^1]: Regular footnote.
[^2]: Footnote from admonition.
With code:
```python
print("footnote code")
```
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Footnotes with extensions should work: {warnings:?}"
);
}
#[test]
fn test_math_inside_all_contexts() {
let content = r#"# Math Everywhere
Inline: $E = mc^2$
!!! note "Math Note"
Block math in admonition:
$$
\int_0^\infty e^{-x} dx = 1
$$
=== "Equations"
More math: $\sum_{i=1}^n x_i$
$$
\frac{d}{dx} \sin(x) = \cos(x)
$$
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Math inside all contexts should work: {warnings:?}"
);
}
#[test]
fn test_inline_extensions_inside_all_block_extensions() {
let content = r#"# Inline in Blocks
!!! note
Keys: ++ctrl+s++ to save.
Mark: ==important== text.
Super: E=mc^2^
=== "Inline Tab"
Subscript: H~2~O
Emoji: :material-check:
Critic: {++added++}
::: module.Class
options:
show_root_heading: true
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Inline extensions in block extensions should work: {warnings:?}"
);
}
#[test]
fn test_collapsible_with_everything() {
let content = r#"# Collapsible Complex
??? example "Click to expand"
=== "Overview"
!!! tip
Nested tip.
Regular paragraph with ==marks==.
=== "Details"
```python title="code.py"
print("nested code")
```
Math: $x^2 + y^2 = z^2$
???+ warning "Open by default"
- List with ++keys++
- And ^superscript^
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Collapsible with everything should work: {warnings:?}"
);
}
#[test]
fn test_triple_nesting_depth() {
let content = r#"# Triple Nesting
!!! note "Level 1"
=== "Level 2A"
!!! warning "Level 3"
Deep content with ==mark== and ++key++.
=== "Level 2B"
!!! tip "Level 3 Alt"
Alternative deep content.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Triple nesting should work: {warnings:?}");
}
}
mod fix_validation {
use super::*;
fn fix_and_validate(content: &str, rule: &dyn Rule, rule_name: &str) {
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
let post_fix_warnings = lint_mkdocs(&fixed);
let extension_breaks: Vec<_> = post_fix_warnings
.iter()
.filter(|w| {
let msg = &w.message;
msg.contains("!!!") || msg.contains("===") || msg.contains(":::")
})
.collect();
assert!(
extension_breaks.is_empty(),
"{rule_name} fix should not break extension syntax. \
Original:\n{content}\nFixed:\n{fixed}\nBroken: {extension_breaks:?}"
);
}
#[test]
fn test_md009_fix_validates() {
let content = "# Test\n\n!!! note\n Content here. \n\n=== \"Tab\" \n Tab. \n";
let rule = MD009TrailingSpaces::default();
fix_and_validate(content, &rule, "MD009");
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(!fixed.contains(" \n"), "MD009 should remove trailing spaces");
}
#[test]
fn test_md010_fix_validates() {
let content = "# Test\n\n!!! note\n\tTabbed content.\n";
let rule = MD010NoHardTabs::default();
fix_and_validate(content, &rule, "MD010");
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(!fixed.contains('\t'), "MD010 should remove hard tabs");
}
#[test]
fn test_md012_fix_validates() {
let content = "# Test\n\n\n\n!!! note\n Content.\n\n\n\n=== \"Tab\"\n More.\n";
let rule = MD012NoMultipleBlanks::default();
fix_and_validate(content, &rule, "MD012");
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(
fixed.contains("Content.\n\n=== \"Tab\""),
"MD012 should reduce non-heading-adjacent blanks"
);
}
#[test]
fn test_md047_fix_validates() {
let content = "# Test\n\n!!! note\n Content.\n\n=== \"Tab\"\n Tab.";
let rule = MD047SingleTrailingNewline;
fix_and_validate(content, &rule, "MD047");
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.ends_with('\n'), "MD047 should add trailing newline");
assert!(!fixed.ends_with("\n\n"), "MD047 should not add multiple newlines");
}
#[test]
fn test_fix_preserves_extension_markers_precisely() {
let content = "# Test\n\n!!! note \"Title\" \n Content. \n\n???+ tip\n Tip. \n";
let rule = MD009TrailingSpaces::default();
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("!!! note \"Title\""), "Admonition marker preserved");
assert!(fixed.contains("???+ tip"), "Collapsible marker preserved");
}
#[test]
fn test_fix_chain_validates() {
let content = "# Test \n\n\n!!! note\n Content. \n\n\n=== \"Tab\"\n Tab. ";
let mut current = content.to_string();
let ctx = LintContext::new(¤t, MarkdownFlavor::MkDocs, None);
current = MD009TrailingSpaces::default().fix(&ctx).unwrap();
let ctx = LintContext::new(¤t, MarkdownFlavor::MkDocs, None);
current = MD012NoMultipleBlanks::default().fix(&ctx).unwrap();
let ctx = LintContext::new(¤t, MarkdownFlavor::MkDocs, None);
current = MD047SingleTrailingNewline.fix(&ctx).unwrap();
let final_warnings = lint_mkdocs(¤t);
let critical: Vec<_> = final_warnings
.iter()
.filter(|w| matches!(w.rule_name.as_deref(), Some("MD009") | Some("MD012") | Some("MD047")))
.collect();
assert!(
critical.is_empty(),
"Fix chain should resolve all targeted issues: {critical:?}"
);
assert!(current.contains("!!! note"), "Admonition preserved");
assert!(current.contains("=== \"Tab\""), "Tab preserved");
}
#[test]
fn test_fix_with_deeply_nested_content() {
let content = r#"# Deep Nesting
!!! note "Outer"
=== "Tab A"
!!! warning "Inner"
Content.
=== "Tab B"
More content.
"#;
let rule = MD009TrailingSpaces::default();
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fixed = rule.fix(&ctx).unwrap();
assert!(fixed.contains("!!! note \"Outer\""), "Outer admonition");
assert!(fixed.contains("=== \"Tab A\""), "Tab A");
assert!(fixed.contains("!!! warning \"Inner\""), "Inner admonition");
assert!(fixed.contains("=== \"Tab B\""), "Tab B");
for (i, line) in fixed.lines().enumerate() {
assert!(
!line.ends_with(' '),
"Line {} should not have trailing space: {:?}",
i + 1,
line
);
}
}
}
mod boundary_tests {
use super::*;
#[test]
fn test_extension_marker_variations() {
let markers = [
"!!! note",
"!!! note \"Title\"",
"!!! note \"\"",
"??? note",
"??? note \"Title\"",
"???+ note",
"???+ note \"Title\"",
];
for marker in markers {
let content = format!("# Test\n\n{marker}\n Content.\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Marker '{marker}' should work: {warnings:?}");
}
}
#[test]
fn test_tab_marker_variations() {
let markers = [
"=== \"Tab\"",
"=== \"\"",
"=== \"Tab with spaces\"",
"=== \"Tab's apostrophe\"",
];
for marker in markers {
let content = format!("# Test\n\n{marker}\n Content.\n");
let warnings = lint_mkdocs(&content);
let _ = warnings;
}
}
#[test]
fn test_mkdocstrings_marker_variations() {
let markers = [
"::: module",
"::: module.submodule",
"::: module.submodule.Class",
"::: module.Class.method",
"::: package.module:function",
];
for marker in markers {
let content = format!("# Test\n\n{marker}\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "mkdocstrings '{marker}' should work: {warnings:?}");
}
}
#[test]
fn test_inline_extension_boundary_positions() {
let cases = [
"==start== of line",
"at ==middle== of line",
"at end ==here==",
"==only==",
"a==tight==b",
"==one== and ==two==",
];
for case in cases {
let content = format!("# Test\n\n{case}\n");
let warnings = lint_mkdocs(&content);
assert!(
warnings.is_empty(),
"Inline extension '{case}' should work: {warnings:?}"
);
}
}
#[test]
fn test_keys_extension_variations() {
let keys = [
"++ctrl++",
"++ctrl+c++",
"++ctrl+alt+del++",
"++ctrl+shift+alt+f12++",
"++enter++",
"++backspace++",
"++arrow-up++",
"++arrow-down++",
"++arrow-left++",
"++arrow-right++",
];
for key in keys {
let content = format!("# Test\n\nPress {key} to continue.\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Key '{key}' should work: {warnings:?}");
}
}
#[test]
fn test_snippet_syntax_variations() {
let snippets = [
"--8<-- \"file.md\"",
"--8<-- \"path/to/file.md\"",
"--8<-- \"../relative/file.md\"",
";--8<--",
];
for snippet in snippets {
let content = format!("# Test\n\n{snippet}\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Snippet '{snippet}' should work: {warnings:?}");
}
}
#[test]
fn test_math_boundary_positions() {
let cases = [
"$x$ at start",
"at end $x$",
"in $middle$ here",
"$a$ and $b$ and $c$",
"tight$x$bound",
];
for case in cases {
let content = format!("# Test\n\n{case}\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Math '{case}' should work: {warnings:?}");
}
}
#[test]
fn test_critic_markup_variations() {
let critics = [
"{++addition++}",
"{--deletion--}",
"{~~old~>new~~}",
"{==highlight==}",
"{>>comment<<}",
];
for critic in critics {
let content = format!("# Test\n\nText with {critic} here.\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Critic '{critic}' should work: {warnings:?}");
}
}
#[test]
fn test_abbreviation_variations() {
let abbrs = [
"*[HTML]: Hypertext Markup Language",
"*[CSS]: Cascading Style Sheets",
"*[API]: Application Programming Interface",
"*[URL]: Uniform Resource Locator",
];
for abbr in abbrs {
let content = format!("# Test\n\nThe HTML spec.\n\n{abbr}\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Abbreviation '{abbr}' should work: {warnings:?}");
}
}
#[test]
fn test_attribute_list_variations() {
let attrs = [
"{ #id }",
"{ .class }",
"{ #id .class }",
"{ .class1 .class2 }",
"{ data-attr=value }",
"{ #id .class data-x=y }",
];
for attr in attrs {
let content = format!("# Heading {attr}\n\nText.\n");
let warnings = lint_mkdocs(&content);
assert!(warnings.is_empty(), "Attribute '{attr}' should work: {warnings:?}");
}
}
#[test]
fn test_empty_and_whitespace_only_content() {
let cases = ["", "\n", "\n\n", " ", " \n", "\t", "\t\n"];
for case in cases {
let _ = lint_mkdocs(case);
}
}
#[test]
fn test_maximum_line_lengths_with_extensions() {
let short = format!(
"# Test\n\nPress {} to continue.\n",
"++ctrl+alt+shift+".to_string() + &"x".repeat(40) + "++"
);
let _ = lint_mkdocs(&short);
}
}
mod regression_tests {
use super::*;
#[test]
fn test_admonition_not_confused_with_emphasis() {
let content = "# Test\n\n!!! note\n Content.\n";
let warnings = lint_mkdocs(content);
let emphasis_warnings: Vec<_> = warnings
.iter()
.filter(|w| {
matches!(
w.rule_name.as_deref(),
Some("MD036") | Some("MD037") | Some("MD049") | Some("MD050")
)
})
.collect();
assert!(
emphasis_warnings.is_empty(),
"Admonition should not trigger emphasis rules: {emphasis_warnings:?}"
);
}
#[test]
fn test_tabs_not_confused_with_code_fence() {
let content = "# Test\n\n=== \"Tab\"\n Content.\n";
let warnings = lint_mkdocs(content);
let fence_warnings: Vec<_> = warnings
.iter()
.filter(|w| matches!(w.rule_name.as_deref(), Some("MD031") | Some("MD040") | Some("MD046")))
.collect();
assert!(
fence_warnings.is_empty(),
"Tab should not trigger fence rules: {fence_warnings:?}"
);
}
#[test]
fn test_mkdocstrings_not_confused_with_definition_list() {
let content = "# Test\n\n::: module.Class\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"mkdocstrings should not trigger warnings: {warnings:?}"
);
}
#[test]
fn test_keys_not_confused_with_code_span() {
let content = "# Test\n\nPress ++ctrl+c++ to copy.\n";
let warnings = lint_mkdocs(content);
let code_span_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.collect();
assert!(
code_span_warnings.is_empty(),
"Keys should not trigger MD038: {code_span_warnings:?}"
);
}
#[test]
fn test_mark_not_confused_with_emphasis() {
let content = "# Test\n\nThis is ==highlighted== text.\n";
let warnings = lint_mkdocs(content);
let emphasis_warnings: Vec<_> = warnings
.iter()
.filter(|w| matches!(w.rule_name.as_deref(), Some("MD049") | Some("MD050")))
.collect();
assert!(
emphasis_warnings.is_empty(),
"Mark should not trigger emphasis rules: {emphasis_warnings:?}"
);
}
#[test]
fn test_snippet_not_confused_with_html_comment() {
let content = "# Test\n\n--8<-- \"file.md\"\n";
let warnings = lint_mkdocs(content);
let html_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD033"))
.collect();
assert!(
html_warnings.is_empty(),
"Snippet should not trigger MD033: {html_warnings:?}"
);
}
#[test]
fn test_indented_content_in_extensions_not_code_block() {
let content = r#"# Test
!!! note
This is indented but not code.
Still not code.
"#;
let warnings = lint_mkdocs(content);
let code_warnings: Vec<_> = warnings
.iter()
.filter(|w| matches!(w.rule_name.as_deref(), Some("MD040") | Some("MD046")))
.collect();
assert!(
code_warnings.is_empty(),
"Admonition content should not be code: {code_warnings:?}"
);
}
#[test]
fn test_auto_reference_handling() {
let content = "# Test\n\nSee [MyClass][] for details.\n";
let warnings = lint_mkdocs(content);
let _ = warnings;
}
#[test]
fn test_smartsymbols_not_flagged() {
let content = "# Test\n\nCopyright (c) 2024. Trademark (tm). Arrow -->.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Smart symbols should work: {warnings:?}");
}
#[test]
fn test_emoji_shortcodes_not_flagged() {
let content = "# Test\n\nCheck :material-check: and :fontawesome-solid-star:.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Emoji shortcodes should work: {warnings:?}");
}
#[test]
fn test_md038_indented_fenced_code_in_admonition() {
let content = concat!(
"# Test\n\n",
"!!! example\n",
" ```yaml title=\"mkdocs.yml\"\n",
" plugins:\n",
" - mkdocstrings:\n",
" enabled: true\n",
" ```\n",
);
let warnings = lint_mkdocs(content);
let md038: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.collect();
assert!(
md038.is_empty(),
"Indented fenced code in admonition should not trigger MD038: {md038:?}"
);
}
#[test]
fn test_md038_indented_fenced_code_in_tabs() {
let content = concat!(
"# Test\n\n",
"=== \"Markdown\"\n",
" ```md\n",
" See [installer.records][] to learn about records.\n",
" ```\n\n",
"=== \"Result (HTML)\"\n",
" ```html\n",
" <p>See <a href=\"url\">installer.records</a></p>\n",
" ```\n",
);
let warnings = lint_mkdocs(content);
let md038: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.collect();
assert!(
md038.is_empty(),
"Indented fenced code in tabs should not trigger MD038: {md038:?}"
);
}
#[test]
fn test_md038_real_issue_still_caught_in_admonition() {
let content = "# Test\n\n!!! note\n Use ` code ` in your config.\n";
let warnings = lint_mkdocs(content);
let md038: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.collect();
assert!(
!md038.is_empty(),
"Real MD038 violations in admonitions should still be caught"
);
}
#[test]
fn test_md051_blockquote_headings_generate_anchors() {
let content = concat!(
"# Main\n\n",
"> #### locale\n",
">\n",
"> A code representing the language.\n\n",
"[link](#locale)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
let md051: Vec<_> = warnings.iter().filter(|w| w.message.contains("locale")).collect();
assert!(
md051.is_empty(),
"Blockquote heading anchor '#locale' should be recognized: {md051:?}"
);
}
#[test]
fn test_md051_blockquote_heading_with_custom_id() {
let content = concat!(
"# Main\n\n",
"> ## Settings {#my-settings}\n\n",
"[link](#my-settings)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Blockquote heading custom anchor should be recognized: {warnings:?}"
);
}
#[test]
fn test_md051_nested_blockquote_heading() {
let content = concat!("# Main\n\n", ">> ### deep-heading\n\n", "[link](#deep-heading)\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Nested blockquote heading anchor should be recognized: {warnings:?}"
);
}
#[test]
fn test_md051_mkdocs_duplicate_heading_underscore_dedup() {
let content = concat!(
"# Main\n\n",
"## Templates\n\n",
"First section.\n\n",
"## Templates\n\n",
"Second section.\n\n",
"[first](#templates)\n",
"[second-github](#templates-1)\n",
"[second-mkdocs](#templates_1)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD051LinkFragments::with_anchor_style(
rumdl_lib::utils::anchor_styles::AnchorStyle::PythonMarkdown,
);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MkDocs _1 dedup suffix should be accepted: {warnings:?}"
);
}
#[test]
fn test_md051_standard_flavor_no_underscore_dedup() {
let content = concat!(
"# Main\n\n",
"## Templates\n\n",
"First section.\n\n",
"## Templates\n\n",
"Second section.\n\n",
"[second-mkdocs](#templates_1)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
!warnings.is_empty(),
"Standard flavor should NOT accept _1 dedup suffix"
);
}
#[test]
fn test_md051_mkdocs_triple_duplicate_heading() {
let content = concat!(
"# Main\n\n",
"## API\n\n",
"First.\n\n",
"## API\n\n",
"Second.\n\n",
"## API\n\n",
"Third.\n\n",
"[first](#api)\n",
"[second](#api_1)\n",
"[third](#api_2)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD051LinkFragments::with_anchor_style(
rumdl_lib::utils::anchor_styles::AnchorStyle::PythonMarkdown,
);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MkDocs triple duplicate dedup should work: {warnings:?}"
);
}
#[test]
fn test_md051_blockquote_heading_with_closing_hashes() {
let content = concat!("# Main\n\n", "> ## Settings ##\n\n", "[link](#settings)\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Blockquote heading with closing hashes should generate correct anchor: {warnings:?}"
);
}
#[test]
fn test_md051_blockquote_heading_closing_hashes_different_count() {
let content = concat!("# Main\n\n", "> ### Info ###########\n\n", "[link](#info)\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Closing hashes with different count should still generate correct anchor: {warnings:?}"
);
}
#[test]
fn test_md051_blockquote_heading_hash_in_text_not_stripped() {
let content = concat!("# Main\n\n", "> ## C# Language\n\n", "[link](#c-language)\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Hash in heading text (C#) should not be treated as closing sequence: {warnings:?}"
);
}
#[test]
fn test_md051_blockquote_heading_only_closing_hashes() {
let content = concat!("# Main\n\n", "> ## ##\n\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let _warnings = rule.check(&ctx).unwrap();
}
#[test]
fn test_md038_indented_fenced_code_in_pymdown_block() {
let content = concat!(
"# Test\n\n",
"/// details | Summary\n",
" ```python\n",
" def foo():\n",
" pass\n",
" ```\n",
"///\n",
);
let warnings = lint_mkdocs(content);
let md038: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD038"))
.collect();
assert!(
md038.is_empty(),
"Indented fenced code in PyMdown block should not trigger MD038: {md038:?}"
);
}
#[test]
fn test_md051_mkdocs_slash_in_heading_collapses_separators() {
let content = concat!(
"# Main\n\n",
"### Cross-references to other projects / inventories\n\n",
"[link](#cross-references-to-other-projects-inventories)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD051LinkFragments::with_anchor_style(
rumdl_lib::utils::anchor_styles::AnchorStyle::PythonMarkdown,
);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MkDocs slash-in-heading should collapse separators: {warnings:?}"
);
}
#[test]
fn test_md051_mkdocs_via_lint_mkdocs_auto_anchor_style() {
let content = concat!(
"# Main\n\n",
"### Cross-references to other projects / inventories\n\n",
"[link](#cross-references-to-other-projects-inventories)\n",
);
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"lint_mkdocs should auto-use PythonMarkdown anchor style: {md051:?}"
);
}
#[test]
fn test_md051_mkdocs_cjk_heading_generates_underscore_anchor() {
let content = concat!(
"# Main\n\n",
"## 你好世界\n\n",
"## こんにちは\n\n",
"## 안녕하세요\n\n",
"[first](#_1)\n",
"[second](#_2)\n",
"[third](#_3)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD051LinkFragments::with_anchor_style(
rumdl_lib::utils::anchor_styles::AnchorStyle::PythonMarkdown,
);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"MkDocs CJK headings should generate _1, _2, _3 anchors: {warnings:?}"
);
}
#[test]
fn test_md051_standard_cjk_heading_preserves_unicode() {
let content = concat!("# Main\n\n", "## 你好世界\n\n", "[link](#你好世界)\n",);
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = rumdl_lib::MD051LinkFragments::new();
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"GitHub style should preserve CJK anchors: {warnings:?}"
);
}
#[test]
fn test_md051_blockquote_empty_heading_text() {
let content = concat!(
"# Main\n\n",
"> ## \n\n",
"> ## Real Heading\n\n",
"[link](#real-heading)\n",
);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD051LinkFragments::with_anchor_style(
rumdl_lib::utils::anchor_styles::AnchorStyle::PythonMarkdown,
);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Empty blockquote heading should not break subsequent anchor generation: {warnings:?}"
);
}
}
mod cross_flavor_tests {
use super::*;
#[test]
fn test_admonition_standard_vs_mkdocs() {
let content = "# Test\n\n!!! note\n Content.\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len(),
"MkDocs should be more lenient with admonitions.\n\
MkDocs: {mkdocs_warnings:?}\nStandard: {standard_warnings:?}"
);
}
#[test]
fn test_content_tabs_standard_vs_mkdocs() {
let content = "# Test\n\n=== \"Tab\"\n Content.\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len(),
"MkDocs should be more lenient with content tabs.\n\
MkDocs: {mkdocs_warnings:?}\nStandard: {standard_warnings:?}"
);
}
#[test]
fn test_mkdocstrings_standard_vs_mkdocs() {
let content = "# Test\n\n::: module.Class\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len(),
"MkDocs should be more lenient with mkdocstrings.\n\
MkDocs: {mkdocs_warnings:?}\nStandard: {standard_warnings:?}"
);
}
#[test]
fn test_keys_extension_standard_vs_mkdocs() {
let content = "# Test\n\nPress ++ctrl+c++ to copy.\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
let _ = (mkdocs_warnings, standard_warnings);
}
#[test]
fn test_mark_extension_standard_vs_mkdocs() {
let content = "# Test\n\nThis is ==highlighted== text.\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
let _ = (mkdocs_warnings, standard_warnings);
}
#[test]
fn test_math_standard_vs_mkdocs() {
let content = "# Test\n\nInline $x^2$ and block:\n\n$$\ny = mx + b\n$$\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
let _ = (mkdocs_warnings, standard_warnings);
}
#[test]
fn test_snippet_standard_vs_mkdocs() {
let content = "# Test\n\n--8<-- \"file.md\"\n";
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len(),
"MkDocs should be more lenient with snippets.\n\
MkDocs: {mkdocs_warnings:?}\nStandard: {standard_warnings:?}"
);
}
#[test]
fn test_complex_document_both_flavors() {
let content = r#"# Complex Document
!!! note "Admonition"
Content here.
=== "Tab 1"
Tab content.
::: module.Class
Regular paragraph.
"#;
let mkdocs_warnings = lint_mkdocs(content);
let standard_warnings = lint_standard(content);
assert!(
mkdocs_warnings.len() <= standard_warnings.len(),
"MkDocs should handle extensions better.\n\
MkDocs ({} warnings): {mkdocs_warnings:?}\n\
Standard ({} warnings): {standard_warnings:?}",
mkdocs_warnings.len(),
standard_warnings.len()
);
}
}
mod line_ending_tests {
use super::*;
#[test]
fn test_crlf_admonitions() {
let content = "# Test\r\n\r\n!!! note\r\n Content.\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF admonitions should work: {warnings:?}");
}
#[test]
fn test_crlf_content_tabs() {
let content = "# Test\r\n\r\n=== \"Tab\"\r\n\r\n Content.\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF content tabs should work: {warnings:?}");
}
#[test]
fn test_crlf_mkdocstrings() {
let content = "# Test\r\n\r\n::: module.Class\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF mkdocstrings should work: {warnings:?}");
}
#[test]
fn test_crlf_inline_extensions() {
let content = "# Test\r\n\r\nText with ==mark== and ++key++.\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF inline extensions should work: {warnings:?}");
}
#[test]
fn test_crlf_nested_extensions() {
let content = "# Test\r\n\r\n!!! note\r\n\r\n === \"Tab\"\r\n\r\n Content.\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF nested extensions should work: {warnings:?}");
}
#[test]
fn test_mixed_line_endings() {
let content = "# Test\n\n!!! note\r\n Content.\n\n=== \"Tab\"\r\n Tab.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_cr_only_line_endings() {
let content = "# Test\r\r!!! note\r Content.\r";
let _ = lint_mkdocs(content);
}
#[test]
fn test_crlf_complex_document() {
let content = "# Document\r\n\r\n!!! note \"Title\"\r\n Content with ==mark==.\r\n\r\n\
=== \"Tab A\"\r\n\r\n Tab content.\r\n\r\n\
=== \"Tab B\"\r\n\r\n More content.\r\n\r\n\
::: module.Class\r\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "CRLF complex document should work: {warnings:?}");
}
}
mod mixed_indentation_tests {
use super::*;
#[test]
fn test_admonition_with_tab_indent() {
let content = "# Test\n\n!!! note\n\tContent with tab.\n";
let warnings = lint_mkdocs(content);
let non_tab_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() != Some("MD010"))
.collect();
assert!(
non_tab_warnings.is_empty(),
"Tab-indented admonition should work (except MD010): {non_tab_warnings:?}"
);
}
#[test]
fn test_content_tab_with_tab_indent() {
let content = "# Test\n\n=== \"Tab\"\n\n\tContent with tab.\n";
let warnings = lint_mkdocs(content);
let non_tab_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() != Some("MD010"))
.collect();
assert!(
non_tab_warnings.is_empty(),
"Tab-indented content tab should work: {non_tab_warnings:?}"
);
}
#[test]
fn test_mixed_spaces_and_tabs_in_admonition() {
let content = "# Test\n\n!!! note\n \t Mixed indent.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_2_space_indent_admonition() {
let content = "# Test\n\n!!! note\n Two space indent.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_8_space_indent_admonition() {
let content = "# Test\n\n!!! note\n Eight space indent.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_nested_mixed_indentation() {
let content = "# Test\n\n!!! note\n === \"Tab\"\n\t \tMixed deep.\n";
let _ = lint_mkdocs(content);
}
}
mod blockquote_tests {
use super::*;
#[test]
fn test_inline_extensions_in_blockquote() {
let content = "# Test\n\n> Quote with ==highlighted== text.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Inline extensions in blockquote should work: {warnings:?}"
);
}
#[test]
fn test_keys_in_blockquote() {
let content = "# Test\n\n> Press ++ctrl+c++ to copy.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Keys in blockquote should work: {warnings:?}");
}
#[test]
fn test_math_in_blockquote() {
let content = "# Test\n\n> The equation $E = mc^2$ is famous.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Math in blockquote should work: {warnings:?}");
}
#[test]
fn test_critic_in_blockquote() {
let content = "# Test\n\n> Text with {++addition++} here.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Critic markup in blockquote should work: {warnings:?}"
);
}
#[test]
fn test_nested_blockquote_with_extensions() {
let content = "# Test\n\n> Level 1\n> > Level 2 with ==mark==.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Nested blockquote with extensions should work: {warnings:?}"
);
}
#[test]
fn test_multiline_blockquote_with_extensions() {
let content = r#"# Test
> This is a blockquote.
> It has ==highlighted== text.
> And ++keyboard++ keys.
> Plus $math$ expressions.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Multiline blockquote with extensions should work: {warnings:?}"
);
}
#[test]
fn test_blockquote_admonition_interaction() {
let content = "# Test\n\n> !!! note\n> This is unusual.\n";
let _ = lint_mkdocs(content);
}
}
mod escape_tests {
use super::*;
#[test]
fn test_escaped_mark_syntax() {
let content = "# Test\n\nThis is \\==not marked\\== text.\n";
let warnings = lint_mkdocs(content);
let _ = warnings;
}
#[test]
fn test_escaped_keys_syntax() {
let content = "# Test\n\nThis is \\++not a key\\++ combo.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_escaped_math_syntax() {
let content = "# Test\n\nPrice is \\$100 dollars.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Escaped dollar should work: {warnings:?}");
}
#[test]
fn test_escaped_caret_syntax() {
let content = "# Test\n\nUse \\^text\\^ for literal carets.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_escaped_tilde_syntax() {
let content = "# Test\n\nUse \\~text\\~ for literal tildes.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_backslash_in_admonition_title() {
let content = "# Test\n\n!!! note \"Path: C:\\\\Users\"\n Content.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Backslash in admonition title should work: {warnings:?}"
);
}
#[test]
fn test_backslash_in_tab_title() {
let content = "# Test\n\n=== \"C:\\\\Path\"\n\n Content.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Backslash in tab title should work: {warnings:?}");
}
#[test]
fn test_special_chars_in_code_spans() {
let content = "# Test\n\nUse `==` for mark and `++` for keys.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Special chars in code spans should work: {warnings:?}"
);
}
#[test]
fn test_html_entities_with_extensions() {
let content = "# Test\n\nText with & ==marked== <content>.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"HTML entities with extensions should work: {warnings:?}"
);
}
}
mod malformed_tests {
use super::*;
#[test]
fn test_unclosed_admonition() {
let content = "# Test\n\n!!! note\n\nNext paragraph.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_admonition_wrong_indent() {
let content = "# Test\n\n!!! note\nNot indented.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_tab_missing_quotes() {
let content = "# Test\n\n=== Tab\n Content.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_tab_unclosed_quote() {
let content = "# Test\n\n=== \"Tab\n Content.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_mkdocstrings_no_path() {
let content = "# Test\n\n:::\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_unclosed_mark() {
let content = "# Test\n\nThis is ==unclosed mark.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_unclosed_keys() {
let content = "# Test\n\nPress ++ctrl+c to copy.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_unclosed_math() {
let content = "# Test\n\nEquation $x + y without closing.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_unclosed_superscript() {
let content = "# Test\n\nText with ^unclosed super.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_unclosed_subscript() {
let content = "# Test\n\nH~2 without closing O.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_nested_unclosed_extensions() {
let content = "# Test\n\n==mark with ^super and ~sub all unclosed.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_mismatched_extension_markers() {
let content = "# Test\n\nThis ==opens but ^^ closes wrong.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_empty_extension_markers() {
let content = "# Test\n\nEmpty ==== mark and ++++ keys.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_only_markers_no_content() {
let content = "# Test\n\n== ++ ^^ ~~ ::\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_deeply_broken_nesting() {
let content = r#"# Test
!!! note
=== "Tab
!!! warning
Content without proper closing.
=== "Another
More broken.
"#;
let _ = lint_mkdocs(content);
}
#[test]
fn test_interleaved_broken_extensions() {
let content = "# Test\n\nStart ==mark then ^super then ~sub all ==cross^crossed~.\n";
let _ = lint_mkdocs(content);
}
}
mod position_tests {
use super::*;
#[test]
fn test_extension_as_first_content() {
let content = "!!! note\n First thing in document.\n";
let _ = lint_mkdocs(content);
}
#[test]
fn test_extension_at_eof_no_newline() {
let content = "# Test\n\n!!! note\n No trailing newline";
let _ = lint_mkdocs(content);
}
#[test]
fn test_inline_extension_at_line_start() {
let content = "# Test\n\n==highlighted== starts the line.\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Extension at line start should work: {warnings:?}");
}
#[test]
fn test_inline_extension_at_line_end() {
let content = "# Test\n\nLine ends with ==highlighted==\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Extension at line end should work: {warnings:?}");
}
#[test]
fn test_extension_only_line() {
let content = "# Test\n\n==only content==\n";
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Extension-only line should work: {warnings:?}");
}
#[test]
fn test_extension_after_many_blank_lines() {
let content = "# Test\n\n\n\n\n\n!!! note\n After many blanks.\n";
let warnings = lint_mkdocs(content);
let extension_issues: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() != Some("MD012"))
.collect();
assert!(
extension_issues.is_empty(),
"Extension after blanks should work: {extension_issues:?}"
);
}
#[test]
fn test_extension_between_headings() {
let content = "# Heading 1\n\n!!! note\n Between headings.\n\n## Heading 2\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Extension between headings should work: {warnings:?}"
);
}
#[test]
fn test_extension_in_list_item() {
let content = "# Test\n\n- Item with ==mark== inside.\n- Another with ++key++.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Extensions in list items should work: {warnings:?}"
);
}
#[test]
fn test_extension_after_code_block() {
let content = "# Test\n\n```python\ncode()\n```\n\n!!! note\n After code.\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Extension after code block should work: {warnings:?}"
);
}
#[test]
fn test_extension_before_code_block() {
let content = "# Test\n\n!!! note\n Before code.\n\n```python\ncode()\n```\n";
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"Extension before code block should work: {warnings:?}"
);
}
}
mod integration_tests {
use super::*;
#[test]
fn test_real_world_api_docs() {
let content = r#"---
title: API Reference
---
# API Reference
[TOC]
## Overview
This module provides the core functionality.
!!! warning "Deprecation Notice"
The old API is deprecated. Use the new one.
## Classes
::: mypackage.core.Client
options:
show_source: true
members:
- connect
- disconnect
### Usage
=== "Basic"
```python
client = Client()
client.connect()
```
=== "Advanced"
```python
client = Client(timeout=30)
client.connect(retry=True)
```
## Keyboard Shortcuts
| Action | Shortcut |
|--------|----------|
| Copy | ++ctrl+c++ |
| Paste | ++ctrl+v++ |
| Save | ++ctrl+s++ |
## Math Examples
The quadratic formula: $x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}$
## See Also
- [OtherClass][] for related functionality.
*[API]: Application Programming Interface
[^1]: Additional reference.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.len() <= 2,
"Real-world API docs should work well: {warnings:?}"
);
}
#[test]
fn test_real_world_tutorial() {
let content = r#"# Getting Started Tutorial
!!! info "Prerequisites"
- Python 3.8+
- pip installed
## Installation
=== "pip"
```bash
pip install mypackage
```
=== "poetry"
```bash
poetry add mypackage
```
=== "conda"
```bash
conda install mypackage
```
## First Steps
1. Import the module
2. Create a client
3. Connect to the server
??? example "Complete Example"
```python
from mypackage import Client
client = Client()
client.connect()
print("Connected!")
```
## Tips
!!! tip
Use ++ctrl+c++ to interrupt long-running operations.
!!! warning
Always call `disconnect()` when done.
## Next Steps
See the [API Reference](api.md) for details.
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Tutorial should have no warnings: {warnings:?}");
}
#[test]
fn test_real_world_changelog() {
let content = r#"# Changelog
## [2.0.0] - 2024-01-15
!!! danger "Breaking Changes"
- Removed deprecated `old_function()`
- Changed signature of `process()`
### Added
- New ==highlighted== feature
- Support for ^superscript^ text
### Fixed
- Bug in {~~old~>new~~} handling
## [1.5.0] - 2024-01-01
??? note "Migration Guide"
Follow these steps to upgrade:
1. Update dependencies
2. Run migrations
3. Test thoroughly
"#;
let warnings = lint_mkdocs(content);
assert!(warnings.is_empty(), "Changelog should have no warnings: {warnings:?}");
}
#[test]
fn test_stress_all_extensions_combined() {
let content = r#"---
title: Complete Test
---
# Complete Extension Test
[TOC]
*[HTML]: Hypertext Markup Language
Text with ==mark==, ^super^, ~sub~, ++key++, $math$, and :emoji:.
!!! note "Admonition"
=== "Tab 1"
```python title="code.py"
print("Hello")
```
=== "Tab 2"
::: module.Class
??? tip "Collapsible"
Content with {++critic++} markup.
> Blockquote with ==extensions==.
Term
: Definition with ++keys++.
| Header | Value |
|--------|-------|
| Key | ++v++ |
$$
E = mc^2
$$
--8<-- "snippet.md"
[Reference][] link.
[^1]: Footnote.
"#;
let warnings = lint_mkdocs(content);
assert!(
warnings.len() <= 3,
"Complete test should have minimal warnings: {warnings:?}"
);
}
}
mod per_extension_regression {
use super::*;
fn assert_check_and_fix_roundtrip(content: &str, extension_name: &str) {
let warnings = lint_mkdocs(content);
assert!(
warnings.is_empty(),
"{extension_name}: expected zero warnings but got {}: {warnings:?}",
warnings.len()
);
let config = create_mkdocs_config();
let rules = filter_rules(&all_rules(&config), &config.global);
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let unfixable_rules: &[&str] = &["MD054"];
for rule in &rules {
match rule.fix(&ctx) {
Ok(fixed) => {
assert_eq!(
fixed,
content,
"{extension_name}: rule {} modified valid content during fix",
rule.name()
);
}
Err(_) => {
assert!(
unfixable_rules.contains(&rule.name()),
"{extension_name}: unexpected Err from rule {} fix()",
rule.name()
);
}
}
}
}
#[test]
fn test_abbr_roundtrip() {
let content = "# Abbreviations\n\nThe HTML specification is maintained by the W3C.\n\n*[HTML]: Hyper Text Markup Language\n*[W3C]: World Wide Web Consortium\n";
assert_check_and_fix_roundtrip(content, "abbr");
}
#[test]
fn test_admonition_roundtrip() {
let content = "# Admonitions\n\n!!! note \"Custom Title\"\n This is a note admonition with a custom title.\n\n!!! warning\n This is a warning.\n";
assert_check_and_fix_roundtrip(content, "admonition");
}
#[test]
fn test_attr_list_roundtrip() {
let content = "# Attributes { #custom-id .special }\n\nA paragraph with attributes.\n{ .highlight }\n\nAnother paragraph.\n{ #other-id data-value=\"test\" }\n";
assert_check_and_fix_roundtrip(content, "attr_list");
}
#[test]
fn test_def_list_roundtrip() {
let content = "# Definitions\n\nTerm 1\n: Definition for term 1.\n\nTerm 2\n: Definition for term 2.\n";
assert_check_and_fix_roundtrip(content, "def_list");
}
#[test]
fn test_footnotes_roundtrip() {
let content = "# Footnotes\n\nText with a footnote reference.[^1]\n\nAnother reference.[^note]\n\n[^1]: First footnote definition.\n\n[^note]: Named footnote definition.\n";
assert_check_and_fix_roundtrip(content, "footnotes");
}
#[test]
fn test_md_in_html_roundtrip() {
let content = "# HTML with Markdown\n\n<div markdown>\n\nThis is **markdown** inside HTML.\n\n- List item 1\n- List item 2\n\n</div>\n";
assert_check_and_fix_roundtrip(content, "md_in_html");
}
#[test]
fn test_toc_roundtrip() {
let content =
"# Table of Contents\n\n[TOC]\n\n## Section One\n\nContent here.\n\n## Section Two\n\nMore content.\n";
assert_check_and_fix_roundtrip(content, "toc");
}
#[test]
fn test_tables_roundtrip() {
let content = "# Tables\n\n| Header 1 | Header 2 |\n| -------- | -------- |\n| Cell 1 | Cell 2 |\n| Cell 3 | Cell 4 |\n";
assert_check_and_fix_roundtrip(content, "tables");
}
#[test]
fn test_meta_roundtrip() {
let content = "---\nauthor: Test Author\ntags:\n - test\n - mkdocs\n---\n\n# Meta Extension\n\nContent after frontmatter.\n";
assert_check_and_fix_roundtrip(content, "meta");
}
#[test]
fn test_fenced_code_roundtrip() {
let content =
"# Fenced Code\n\n```python\nprint(\"hello\")\n```\n\n```yaml title=\"config.yml\"\nkey: value\n```\n";
assert_check_and_fix_roundtrip(content, "fenced_code");
}
#[test]
fn test_arithmatex_roundtrip() {
let content =
"# Math\n\nInline math: $E = mc^2$\n\nBlock math:\n\n$$\n\\frac{n!}{k!(n-k)!} = \\binom{n}{k}\n$$\n";
assert_check_and_fix_roundtrip(content, "arithmatex");
}
#[test]
fn test_caret_roundtrip() {
let content = "# Caret\n\nThis is ^^inserted text^^ and H^2^O is water.\n";
assert_check_and_fix_roundtrip(content, "caret");
}
#[test]
fn test_mark_roundtrip() {
let content = "# Mark\n\nThis is ==marked text== for highlighting.\n";
assert_check_and_fix_roundtrip(content, "mark");
}
#[test]
fn test_tilde_roundtrip() {
let content = "# Tilde\n\nThis is ~~deleted text~~ and H~2~O is water.\n";
assert_check_and_fix_roundtrip(content, "tilde");
}
#[test]
fn test_details_roundtrip() {
let content = "# Details\n\n??? note \"Collapsible\"\n This content is hidden by default.\n\n???+ tip \"Open by Default\"\n This content is visible.\n";
assert_check_and_fix_roundtrip(content, "details");
}
#[test]
fn test_emoji_roundtrip() {
let content = "# Emoji\n\nA thumbs up :thumbsup: and a :material-check: icon.\n";
assert_check_and_fix_roundtrip(content, "emoji");
}
#[test]
fn test_inlinehilite_roundtrip() {
let content = "# Inline Highlight\n\nUse `#!python print(\"hello\")` for inline code.\n";
assert_check_and_fix_roundtrip(content, "inlinehilite");
}
#[test]
fn test_keys_roundtrip() {
let content = "# Keys\n\nPress ++ctrl+alt+del++ to open task manager.\n";
assert_check_and_fix_roundtrip(content, "keys");
}
#[test]
fn test_smartsymbols_roundtrip() {
let content = "# Smart Symbols\n\nCopyright (c) and trademark (tm) and arrows -->.\n";
assert_check_and_fix_roundtrip(content, "smartsymbols");
}
#[test]
fn test_snippets_roundtrip() {
let content = "# Snippets\n\nContent before snippet.\n\n--8<-- \"path/to/file.md\"\n\nContent after snippet.\n";
assert_check_and_fix_roundtrip(content, "snippets");
}
#[test]
fn test_superfences_roundtrip() {
let content =
"# SuperFences\n\n```python hl_lines=\"2 3\"\ndef hello():\n print(\"hello\")\n return True\n```\n";
assert_check_and_fix_roundtrip(content, "superfences");
}
#[test]
fn test_tabbed_roundtrip() {
let content = "# Tabs\n\n=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n\n=== \"JavaScript\"\n\n ```javascript\n console.log(\"hello\")\n ```\n";
assert_check_and_fix_roundtrip(content, "tabbed");
}
#[test]
fn test_tasklist_roundtrip() {
let content = "# Tasks\n\n- [x] Completed task\n- [ ] Pending task\n- [x] Another done\n";
assert_check_and_fix_roundtrip(content, "tasklist");
}
#[test]
fn test_betterem_roundtrip() {
let content = "# BetterEm\n\nThis is *emphasized* text and **strong** text.\n\nNested: ***bold and italic***\n";
assert_check_and_fix_roundtrip(content, "betterem");
}
#[test]
fn test_critic_roundtrip() {
let content = "# Critic Markup\n\nThis is {++added text++} and {--removed text--}.\n\nThis is {~~old~>new~~} replacement.\n\n{==highlighted text==} and {>>comment text<<}.\n";
assert_check_and_fix_roundtrip(content, "critic");
}
#[test]
fn test_pymdown_blocks_details_roundtrip() {
let content =
"# PyMdown Blocks\n\n/// details | Click to expand\n type: warning\n\nDetailed content inside.\n\n///\n";
assert_check_and_fix_roundtrip(content, "pymdown_blocks_details");
}
#[test]
fn test_pymdown_blocks_admonition_roundtrip() {
let content =
"# PyMdown Blocks\n\n/// admonition | Important Notice\n type: note\n\nAdmonition content.\n\n///\n";
assert_check_and_fix_roundtrip(content, "pymdown_blocks_admonition");
}
#[test]
fn test_pymdown_blocks_caption_roundtrip() {
let content = "# PyMdown Blocks\n\n/// caption\nFigure 1: Diagram description\n///\n";
assert_check_and_fix_roundtrip(content, "pymdown_blocks_caption");
}
#[test]
fn test_pymdown_blocks_html_roundtrip() {
let content =
"# PyMdown Blocks\n\n/// html | div.custom-class\n\nCustom HTML content with **markdown**.\n\n///\n";
assert_check_and_fix_roundtrip(content, "pymdown_blocks_html");
}
#[test]
fn test_mkdocstrings_roundtrip() {
let content =
"# API Reference\n\n::: my_module.MyClass\n options:\n show_source: true\n heading_level: 2\n";
assert_check_and_fix_roundtrip(content, "mkdocstrings");
}
#[test]
fn test_mkdocstrings_cross_references_roundtrip() {
let content = "# Cross References\n\nSee [my_module.MyClass][] and [my_module.function][] for details.\n";
assert_check_and_fix_roundtrip(content, "mkdocstrings_cross_references");
}
#[test]
fn test_md051_footnote_anchors_no_false_positive() {
let content = "# Footnote Anchors\n\nSee the footnote.[^1]\n\n[:arrow_down: Jump to footnote](#fn:1)\n\n[:arrow_down: Jump to ref](#fnref:1)\n\n[^1]: The footnote content.\n";
let warnings = lint_mkdocs(content);
let md051_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051_warnings.is_empty(),
"MD051 should not flag MkDocs footnote anchors: {md051_warnings:?}"
);
}
#[test]
fn test_md051_option_anchors_no_false_positive() {
let content = "# Option Anchors\n\nSee the [abstract](#+type:abstract) type.\n\nConfigure [option](#+config.theme.name) in mkdocs.yml.\n";
let warnings = lint_mkdocs(content);
let md051_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051_warnings.is_empty(),
"MD051 should not flag MkDocs option anchors: {md051_warnings:?}"
);
}
#[test]
fn test_md051_still_flags_invalid_fragments_in_mkdocs() {
let content =
"# Valid Heading\n\n## Another Heading\n\n[link](#nonexistent-heading)\n\n[link](#also-not-real)\n";
let warnings = lint_mkdocs(content);
let md051_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert_eq!(
md051_warnings.len(),
2,
"MD051 should flag invalid fragments even in MkDocs mode: {md051_warnings:?}"
);
}
#[test]
fn test_md051_footnote_skip_only_applies_to_fn_prefix() {
let content = "# Heading\n\n[link](#function)\n\n[link](#fnord)\n";
let warnings = lint_mkdocs(content);
let md051_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert_eq!(
md051_warnings.len(),
2,
"MD051 should only skip #fn: and #fnref: prefixes, not #function or #fnord: {md051_warnings:?}"
);
}
#[test]
fn test_md051_option_skip_requires_dot_or_colon() {
let content = "# Heading\n\n[link](#+plain)\n\n[link](#+also-invalid)\n";
let warnings = lint_mkdocs(content);
let md051_warnings: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert_eq!(
md051_warnings.len(),
2,
"MD051 should flag #+fragments without dot or colon: {md051_warnings:?}"
);
}
#[test]
fn test_fmt_preserves_all_extensions() {
let content = "# Format Test\n\n\
!!! note \"Important\"\n\
Content with trailing spaces. \n\n\
=== \"Tab 1\"\n\n\
Tab content.\n\n\
::: my_module.Class\n\
options:\n\
show_source: true\n\n\
/// details | Summary\n\
type: note\n\n\
Details content.\n\n\
///\n\n\n\
Text with ==mark== and ^^caret^^ and ++ctrl+c++.\n\n\
$E = mc^2$\n\n\
[^1]: A footnote.\n";
let config = create_mkdocs_config();
let rules = filter_rules(&all_rules(&config), &config.global);
let warnings = lint(content, &rules, false, MarkdownFlavor::MkDocs, None, None).unwrap();
let md009_count = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD009"))
.count();
let md012_count = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD012"))
.count();
assert!(md009_count > 0, "Test document should trigger MD009 (trailing spaces)");
assert!(md012_count > 0, "Test document should trigger MD012 (multiple blanks)");
let coordinator = rumdl_lib::fix_coordinator::FixCoordinator::new();
let mut fixed_content = content.to_string();
let result = coordinator
.apply_fixes_iterative(&rules, &warnings, &mut fixed_content, &config, 10, None)
.expect("Fix should succeed");
assert!(result.rules_fixed > 0, "Should have fixed some issues");
assert!(fixed_content.contains("!!! note"), "Admonitions preserved");
assert!(fixed_content.contains("=== \"Tab 1\""), "Tabs preserved");
assert!(fixed_content.contains("::: my_module.Class"), "mkdocstrings preserved");
assert!(fixed_content.contains("/// details"), "PyMdown blocks preserved");
assert!(fixed_content.contains("==mark=="), "Mark preserved");
assert!(fixed_content.contains("^^caret^^"), "Caret preserved");
assert!(fixed_content.contains("++ctrl+c++"), "Keys preserved");
assert!(fixed_content.contains("$E = mc^2$"), "Math preserved");
assert!(fixed_content.contains("[^1]:"), "Footnotes preserved");
assert!(!fixed_content.contains(" \n"), "Trailing spaces should be removed");
let re_warnings = lint(&fixed_content, &rules, false, MarkdownFlavor::MkDocs, None, None).unwrap();
let trailing_space_warnings: Vec<_> = re_warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD009"))
.collect();
assert!(
trailing_space_warnings.is_empty(),
"MD009 should produce zero warnings after fix: {trailing_space_warnings:?}"
);
let multiple_blank_warnings: Vec<_> = re_warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD012"))
.collect();
assert!(
multiple_blank_warnings.is_empty(),
"MD012 should produce zero warnings after fix: {multiple_blank_warnings:?}"
);
}
}
mod md051_admonition_link_detection {
use super::*;
#[test]
fn test_broken_link_inside_admonition_is_detected() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [two](#two)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside admonition"
);
}
#[test]
fn test_valid_link_inside_admonition_no_warning() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [one](#one)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"MD051 should not flag valid link '#one' inside admonition: {md051:?}"
);
}
#[test]
fn test_broken_link_inside_content_tab_is_detected() {
let content = "# Test\n\n## One\n\n=== \"Tab 1\"\n\n See [two](#two)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside content tab"
);
}
#[test]
fn test_valid_link_inside_content_tab_no_warning() {
let content = "# Test\n\n## One\n\n=== \"Tab 1\"\n\n See [one](#one)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"MD051 should not flag valid link '#one' inside content tab: {md051:?}"
);
}
#[test]
fn test_link_inside_fenced_code_in_admonition_not_flagged() {
let content = "# Test\n\n## One\n\n!!! note\n\n ```markdown\n See [two](#two)\n ```\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"MD051 should not flag links inside fenced code blocks within admonitions: {md051:?}"
);
}
#[test]
fn test_broken_link_with_fenced_code_in_same_admonition() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [two](#two)\n\n ```python\n code = \"example\"\n ```\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' even when fenced code exists in same admonition"
);
}
#[test]
fn test_broken_link_inside_nested_admonition_is_detected() {
let content = "# Test\n\n## One\n\n!!! note\n\n !!! warning\n\n See [two](#two)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside nested admonition"
);
}
#[test]
fn test_mixed_valid_and_broken_links_in_admonition() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [one](#one) and [two](#two)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert_eq!(
md051.len(),
1,
"MD051 should flag only the broken link '#two', not '#one': {md051:?}"
);
}
#[test]
fn test_lint_context_parses_links_inside_admonitions() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [one](#one)\n\n## Three\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let admonition_links: Vec<_> = ctx.links.iter().filter(|l| l.url.contains("#one")).collect();
assert!(
!admonition_links.is_empty(),
"LintContext should parse links inside MkDocs admonitions, found: {:?}",
ctx.links.iter().map(|l| l.url.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn test_lint_context_parses_links_inside_content_tabs() {
let content = "# Test\n\n## One\n\n=== \"Tab 1\"\n\n See [one](#one)\n\n## Three\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let tab_links: Vec<_> = ctx.links.iter().filter(|l| l.url.contains("#one")).collect();
assert!(
!tab_links.is_empty(),
"LintContext should parse links inside MkDocs content tabs, found: {:?}",
ctx.links.iter().map(|l| l.url.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn test_collapsible_admonition_link_detection() {
let content = "# Test\n\n## One\n\n??? note\n\n See [two](#two)\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside collapsible admonition"
);
}
#[test]
fn test_standard_flavor_admonition_not_affected() {
let content = "# Test\n\n## One\n\n!!! note\n\n See [two](#two)\n\n## Three\n";
let warnings = lint_standard(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"Standard flavor should not detect links inside 4-space-indented content: {md051:?}"
);
}
}
mod image_parsing_in_admonitions {
use super::*;
#[test]
fn test_lint_context_parses_images_inside_admonitions() {
let content = "# Test\n\n!!! note\n\n \n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let admonition_images: Vec<_> = ctx.images.iter().filter(|img| img.url.contains("image.png")).collect();
assert!(
!admonition_images.is_empty(),
"LintContext should parse images inside MkDocs admonitions, found: {:?}",
ctx.images.iter().map(|img| img.url.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn test_lint_context_parses_images_inside_content_tabs() {
let content = "# Test\n\n=== \"Tab 1\"\n\n \n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let tab_images: Vec<_> = ctx.images.iter().filter(|img| img.url.contains("image.png")).collect();
assert!(
!tab_images.is_empty(),
"LintContext should parse images inside MkDocs content tabs, found: {:?}",
ctx.images.iter().map(|img| img.url.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn test_image_inside_fenced_code_in_admonition_not_parsed() {
let content = "# Test\n\n!!! note\n\n ```markdown\n \n ```\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let fenced_images: Vec<_> = ctx.images.iter().filter(|img| img.url.contains("image.png")).collect();
assert!(
fenced_images.is_empty(),
"Images inside fenced code blocks within admonitions should not be parsed: {fenced_images:?}",
);
}
#[test]
fn test_md045_detects_missing_alt_text_in_admonition() {
let content = "# Test\n\n!!! note\n\n \n";
let warnings = lint_mkdocs(content);
let md045: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD045"))
.collect();
assert!(
!md045.is_empty(),
"MD045 should detect missing alt text for images inside admonitions"
);
}
#[test]
fn test_md045_no_warning_with_alt_text_in_admonition() {
let content = "# Test\n\n!!! note\n\n \n";
let warnings = lint_mkdocs(content);
let md045: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD045"))
.collect();
assert!(
md045.is_empty(),
"MD045 should not flag images with alt text inside admonitions: {md045:?}"
);
}
#[test]
fn test_standard_flavor_images_in_indented_content_not_parsed() {
let content = "# Test\n\n!!! note\n\n \n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let images: Vec<_> = ctx.images.iter().filter(|img| img.url.contains("image.png")).collect();
assert!(
images.is_empty(),
"Standard flavor should not parse images inside 4-space-indented content: {images:?}"
);
}
}
mod admonition_content_broader_rules {
use super::*;
#[test]
fn test_md034_detects_bare_url_in_admonition() {
let content = "# Test\n\n!!! note\n\n Visit https://example.com for details\n";
let warnings = lint_mkdocs(content);
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(!md034.is_empty(), "MD034 should detect bare URLs inside admonitions");
}
#[test]
fn test_md034_detects_bare_url_in_content_tab() {
let content = "# Test\n\n=== \"Tab 1\"\n\n Visit https://example.com for details\n";
let warnings = lint_mkdocs(content);
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(!md034.is_empty(), "MD034 should detect bare URLs inside content tabs");
}
#[test]
fn test_md034_no_bare_url_in_fenced_code_in_admonition() {
let content = "# Test\n\n!!! note\n\n ```\n https://example.com\n ```\n";
let warnings = lint_mkdocs(content);
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag bare URLs inside fenced code blocks within admonitions: {md034:?}"
);
}
#[test]
fn test_md039_detects_space_in_link_in_admonition() {
let content = "# Test\n\n!!! note\n\n [ link text ](https://example.com)\n";
let warnings = lint_mkdocs(content);
let md039: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD039"))
.collect();
assert!(
!md039.is_empty(),
"MD039 should detect spaces inside link text in admonitions"
);
}
#[test]
fn test_md011_detects_reversed_link_in_admonition() {
let content = "# Test\n\n!!! note\n\n (reversed link)[https://example.com]\n";
let warnings = lint_mkdocs(content);
let md011: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD011"))
.collect();
assert!(
!md011.is_empty(),
"MD011 should detect reversed link syntax inside admonitions"
);
}
#[test]
fn test_md037_detects_space_in_emphasis_in_admonition() {
let content = "# Test\n\n!!! note\n\n This is * not emphasis *\n";
let warnings = lint_mkdocs(content);
let _md037: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD037"))
.collect();
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let line_4 = ctx.lines.get(4); assert!(
line_4.is_some_and(|l| !l.in_code_block),
"Line inside admonition should not be marked as code block"
);
}
#[test]
fn test_md012_detects_multiple_blanks_in_admonition() {
let content = "# Test\n\n!!! note\n\n First paragraph\n\n\n Second paragraph\n";
let warnings = lint_mkdocs(content);
let md012: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD012"))
.collect();
assert!(
!md012.is_empty(),
"MD012 should detect multiple blank lines inside admonitions"
);
}
#[test]
fn test_admonition_lines_not_marked_as_code_block() {
let content = "# Test\n\n!!! note\n\n Content here\n More content\n\n## Next\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
for i in 4..6 {
let info = &ctx.lines[i];
assert!(
!info.in_code_block,
"Line {} should not be in_code_block (is admonition content): {:?}",
i + 1,
info
);
assert!(info.in_admonition, "Line {} should be in_admonition: {:?}", i + 1, info);
}
}
#[test]
fn test_code_spans_detected_in_content_tab() {
let content = "# Test\n\n=== \"Tab 1\"\n\n Visit `https://example.com` to get started.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect exactly one code span inside content tab"
);
assert_eq!(code_spans[0].content, "https://example.com");
}
#[test]
fn test_code_spans_detected_in_admonition() {
let content = "# Test\n\n!!! note\n\n Visit `https://example.com` for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(code_spans.len(), 1, "Should detect code span inside admonition");
assert_eq!(code_spans[0].content, "https://example.com");
}
#[test]
fn test_multiple_code_spans_in_mkdocs_container() {
let content = "# Test\n\n=== \"Tab\"\n\n Use `foo` and `bar` together with `baz`.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(code_spans.len(), 3, "Should detect all three code spans");
assert_eq!(code_spans[0].content, "foo");
assert_eq!(code_spans[1].content, "bar");
assert_eq!(code_spans[2].content, "baz");
}
#[test]
fn test_double_backtick_code_spans_in_mkdocs_container() {
let content = "# Test\n\n=== \"Tab\"\n\n Use ``code with ` backtick`` here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(code_spans.len(), 1, "Should detect double-backtick code span");
assert_eq!(code_spans[0].content, "code with ` backtick");
}
#[test]
fn test_md034_no_warning_for_url_in_code_span_in_content_tab() {
let content = "# Test\n\n=== \"Tab\"\n\n 1. Visit `https://example.com` to get started.\n\n - Set **URL** to `https://example.com/api`\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag URLs inside backtick code spans in content tabs: {md034:?}"
);
}
#[test]
fn test_md034_fix_preserves_url_in_code_span_in_content_tab() {
let content = "# Test\n\n=== \"Tab\"\n\n 1. Visit `https://example.com` to get started.\n\n - Set **URL** to `https://example.com/api`\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD034NoBareUrls;
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix should not modify URLs in code spans");
}
#[test]
fn test_md034_still_flags_bare_url_in_content_tab() {
let content = "# Test\n\n=== \"Tab 1\"\n\n Visit https://example.com for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
!md034.is_empty(),
"MD034 should still flag bare URLs (not in code spans) inside content tabs"
);
}
#[test]
fn test_md034_no_warning_for_url_in_code_span_in_admonition() {
let content = "# Test\n\n!!! note\n\n Visit `https://example.com` for details.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag URLs inside code spans in admonitions: {md034:?}"
);
}
#[test]
fn test_unmatched_triple_backtick_doesnt_hide_inner_code_span() {
let content = "# Test\n\n=== \"Tab\"\n\n See ``` and `foo` here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect `foo` even after unmatched triple backticks"
);
assert_eq!(code_spans[0].content, "foo");
}
#[test]
fn test_escaped_backtick_not_detected_as_code_span() {
let content = "# Test\n\n=== \"Tab\"\n\n This is \\`not code\\` text.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert!(
code_spans.is_empty(),
"Escaped backticks should not form code spans, got: {code_spans:?}"
);
}
#[test]
fn test_code_span_with_escaped_backslash_before_backtick() {
let content = "# Test\n\n=== \"Tab\"\n\n See \\\\`code` here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Double-escaped backslash should not prevent code span detection"
);
assert_eq!(code_spans[0].content, "code");
}
#[test]
fn test_content_tab_lines_not_marked_as_code_block() {
let content = "# Test\n\n=== \"Tab 1\"\n\n Content here\n More content\n\n## Next\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
for i in 4..6 {
let info = &ctx.lines[i];
assert!(
!info.in_code_block,
"Line {} should not be in_code_block (is tab content): {:?}",
i + 1,
info
);
assert!(
info.in_content_tab,
"Line {} should be in_content_tab: {:?}",
i + 1,
info
);
}
}
#[test]
fn test_fenced_code_in_admonition_still_marked_as_code_block() {
let content = "# Test\n\n!!! note\n\n ```python\n code = True\n ```\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let info = &ctx.lines[5];
assert!(
info.in_code_block,
"Line inside fenced code block within admonition should still be in_code_block: {info:?}",
);
assert!(
info.in_admonition,
"Line inside fenced code block within admonition should also be in_admonition: {info:?}",
);
}
#[test]
fn test_code_blocks_byte_ranges_exclude_admonition_content() {
let content = "# Test\n\n!!! note\n\n Content here\n\n## Next\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let content_line_offset = content.find(" Content here").unwrap();
let in_code_block = ctx
.code_blocks
.iter()
.any(|&(start, end)| content_line_offset >= start && content_line_offset < end);
assert!(
!in_code_block,
"Admonition content should not be in code_blocks byte ranges. Ranges: {:?}, offset: {}",
ctx.code_blocks, content_line_offset
);
}
#[test]
fn test_code_blocks_byte_ranges_preserve_fenced_code_in_admonition() {
let content = "# Test\n\n!!! note\n\n ```python\n code = True\n ```\n\n## Next\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_line_offset = content.find(" code = True").unwrap();
let in_code_block = ctx
.code_blocks
.iter()
.any(|&(start, end)| code_line_offset >= start && code_line_offset < end);
assert!(
in_code_block,
"Fenced code content within admonition should be in code_blocks byte ranges. Ranges: {:?}, offset: {}",
ctx.code_blocks, code_line_offset
);
}
#[test]
fn test_mixed_content_and_fenced_code_in_admonition_byte_ranges() {
let content =
"# Test\n\n!!! note\n\n Regular content\n\n ```python\n code = True\n ```\n\n## Next\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let regular_offset = content.find(" Regular content").unwrap();
let code_offset = content.find(" code = True").unwrap();
let regular_in_code = ctx
.code_blocks
.iter()
.any(|&(start, end)| regular_offset >= start && regular_offset < end);
let code_in_code = ctx
.code_blocks
.iter()
.any(|&(start, end)| code_offset >= start && code_offset < end);
assert!(
!regular_in_code,
"Regular admonition content should not be in code_blocks byte ranges"
);
assert!(
code_in_code,
"Fenced code content in admonition should be in code_blocks byte ranges"
);
}
#[test]
fn test_multiline_code_span_in_content_tab() {
let content = "# Test\n\n=== \"Tab\"\n\n Use `some code\n spanning lines` here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect multi-line code span in content tab, got: {code_spans:?}"
);
assert!(
code_spans[0].content.contains("some code"),
"Code span content should contain 'some code': {:?}",
code_spans[0].content
);
assert!(
code_spans[0].content.contains("spanning lines"),
"Code span content should contain 'spanning lines': {:?}",
code_spans[0].content
);
}
#[test]
fn test_multiline_code_span_in_admonition() {
let content = "# Test\n\n!!! note\n\n Run `command\n --flag value` to proceed.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect multi-line code span in admonition, got: {code_spans:?}"
);
assert!(
code_spans[0].content.contains("command"),
"Code span should contain 'command': {:?}",
code_spans[0].content
);
assert!(
code_spans[0].content.contains("--flag value"),
"Code span should contain '--flag value': {:?}",
code_spans[0].content
);
}
#[test]
fn test_mixed_single_and_multiline_code_spans_in_container() {
let content = "# Test\n\n=== \"Tab\"\n\n Use `single` and `multi\n line span` together.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
2,
"Should detect both single-line and multi-line code spans, got: {code_spans:?}"
);
assert_eq!(code_spans[0].content, "single");
assert!(
code_spans[1].content.contains("multi"),
"Second span should contain 'multi': {:?}",
code_spans[1].content
);
}
#[test]
fn test_multiline_code_span_with_url_in_container() {
let content = "# Test\n\n=== \"Tab\"\n\n Visit `https://example.com/\n api/v2` for the endpoint.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect multi-line code span containing URL, got: {code_spans:?}"
);
assert!(
code_spans[0].content.contains("https://example.com/"),
"Code span should contain URL: {:?}",
code_spans[0].content
);
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag URL inside multi-line code span: {md034:?}"
);
}
#[test]
fn test_multiline_code_span_three_lines() {
let content = "# Test\n\n=== \"Tab\"\n\n Run `first\n second\n third` now.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect code span spanning three lines, got: {code_spans:?}"
);
assert!(
code_spans[0].content.contains("first"),
"Should contain 'first': {:?}",
code_spans[0].content
);
assert!(
code_spans[0].content.contains("third"),
"Should contain 'third': {:?}",
code_spans[0].content
);
}
#[test]
fn test_md034_no_warning_for_email_in_multiline_code_span() {
let content = "# Test\n\n=== \"Tab\"\n\n Contact `admin\n @example.com` for help.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect multi-line code span containing email fragment"
);
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag email inside multi-line code span: {md034:?}"
);
}
#[test]
fn test_code_span_in_nested_container() {
let content = "# Test\n\n=== \"Tab\"\n\n !!! note\n\n Visit `https://example.com` here.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect code span in nested container (admonition inside content tab)"
);
assert_eq!(code_spans[0].content, "https://example.com");
let rule = rumdl_lib::MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
let md034: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD034"))
.collect();
assert!(
md034.is_empty(),
"MD034 should not flag URL in code span inside nested container: {md034:?}"
);
}
#[test]
fn test_multiline_code_span_in_nested_container() {
let content = "# Test\n\n=== \"Tab\"\n\n !!! note\n\n Run `command\n --verbose` now.\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let code_spans = ctx.code_spans();
assert_eq!(
code_spans.len(),
1,
"Should detect multi-line code span in nested container, got: {code_spans:?}"
);
assert!(
code_spans[0].content.contains("command"),
"Code span should contain 'command': {:?}",
code_spans[0].content
);
}
}
mod html_markdown_link_detection {
use super::*;
#[test]
fn test_broken_link_inside_div_markdown_is_detected() {
let content = "# Test\n\n## One\n\n<div markdown>\n\n See [two](#two)\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside <div markdown>"
);
}
#[test]
fn test_valid_link_inside_div_markdown_no_warning() {
let content = "# Test\n\n## One\n\n<div markdown>\n\n See [one](#one)\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"MD051 should not flag valid link '#one' inside <div markdown>: {md051:?}"
);
}
#[test]
fn test_broken_link_inside_section_markdown() {
let content = "# Test\n\n## One\n\n<section markdown>\n\n See [two](#two)\n\n</section>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside <section markdown>"
);
}
#[test]
fn test_fenced_code_inside_div_markdown_preserved() {
let content =
"# Test\n\n## One\n\n<div markdown>\n\n ```\n See [two](#two)\n ```\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
md051.is_empty(),
"MD051 should NOT detect links inside fenced code within <div markdown>: {md051:?}"
);
}
#[test]
fn test_mixed_content_and_fenced_code_in_div_markdown() {
let content = "# Test\n\n## One\n\n<div markdown>\n\n See [two](#two)\n\n ```\n See [three](#three)\n ```\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' outside fenced code in <div markdown>"
);
assert_eq!(
md051.len(),
1,
"Should only detect link outside fenced code, not inside: {md051:?}"
);
}
#[test]
fn test_image_inside_div_markdown_detected() {
let content = "# Test\n\n<div markdown>\n\n ![]()\n\n</div>\n";
let warnings = lint_mkdocs(content);
let md045: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD045"))
.collect();
assert!(
!md045.is_empty(),
"MD045 should detect image without alt text inside <div markdown>"
);
}
#[test]
fn test_link_context_line_info_in_div_markdown() {
let content = "# Test\n\n<div markdown>\n\n Some content with [link](#test)\n\n</div>\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let line_info = &ctx.lines[4];
assert!(
line_info.in_mkdocs_html_markdown,
"Content inside <div markdown> should have in_mkdocs_html_markdown set"
);
assert!(
!line_info.in_code_block,
"Content inside <div markdown> should NOT have in_code_block set"
);
}
#[test]
fn test_code_blocks_byte_ranges_exclude_div_markdown_content() {
let content = "# Test\n\n<div markdown>\n\n Regular content here\n\n</div>\n";
let ctx = LintContext::new(content, MarkdownFlavor::MkDocs, None);
let content_offset = content.find(" Regular content").unwrap();
let in_code = ctx
.code_blocks
.iter()
.any(|&(start, end)| content_offset >= start && content_offset < end);
assert!(
!in_code,
"Regular content inside <div markdown> should not be in code_blocks byte ranges"
);
}
#[test]
fn test_div_markdown_with_markdown_eq_1() {
let content = "# Test\n\n## One\n\n<div markdown=\"1\">\n\n See [two](#two)\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link '#two' inside <div markdown=\"1\">"
);
}
#[test]
fn test_standard_flavor_div_markdown_honored() {
let content = "# Test\n\n## One\n\n<div markdown>\n\n See [two](#two)\n\n</div>\n\n## Three\n";
let warnings = lint_standard(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"Standard flavor should honor `<div markdown>` and detect broken link '#two': {warnings:?}"
);
}
#[test]
fn test_div_markdown_inside_admonition() {
let content =
"# Test\n\n## One\n\n!!! note\n\n <div markdown>\n\n See [two](#two)\n\n </div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link in <div markdown> nested inside admonition"
);
}
#[test]
fn test_non_indented_div_markdown_content() {
let content = "# Test\n\n## One\n\n<div markdown>\n\nSee [two](#two)\n\n</div>\n\n## Three\n";
let warnings = lint_mkdocs(content);
let md051: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD051"))
.collect();
assert!(
!md051.is_empty(),
"MD051 should detect broken link in non-indented <div markdown> content"
);
}
#[test]
fn test_md042_empty_link_inside_admonition() {
let content = "# Test\n\n!!! note\n\n [click here]()\n";
let warnings = lint_mkdocs(content);
let md042: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD042"))
.collect();
assert!(
!md042.is_empty(),
"MD042 should detect empty-URL link inside admonition"
);
}
#[test]
fn test_md042_empty_link_inside_div_markdown() {
let content = "# Test\n\n<div markdown>\n\n [click here]()\n\n</div>\n";
let warnings = lint_mkdocs(content);
let md042: Vec<_> = warnings
.iter()
.filter(|w| w.rule_name.as_deref() == Some("MD042"))
.collect();
assert!(
!md042.is_empty(),
"MD042 should detect empty-URL link inside <div markdown>"
);
}
}