use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD051LinkFragments;
use rumdl_lib::utils::anchor_styles::AnchorStyle;
#[test]
fn test_valid_link_fragment() {
let ctx = LintContext::new(
"# Test Heading\n\nThis is a [link](#test-heading) to the heading.",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_invalid_link_fragment() {
let ctx = LintContext::new(
"# Test Heading\n\nThis is a [link](#wrong-heading) to the heading.",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_multiple_headings() {
let ctx = LintContext::new(
"# First Heading\n\n## Second Heading\n\n[Link 1](#first-heading)\n[Link 2](#second-heading)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_special_characters() {
let ctx = LintContext::new(
"# Test & Heading!\n\nThis is a [link](#test--heading) to the heading.",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_no_fragments() {
let ctx = LintContext::new(
"# Test Heading\n\nThis is a [link](https://example.com) without fragment.",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_empty_content() {
let ctx = LintContext::new("", rumdl_lib::config::MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_multiple_invalid_fragments() {
let ctx = LintContext::new(
"# Test Heading\n\n[Link 1](#wrong1)\n[Link 2](#wrong2)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_case_sensitivity() {
let ctx = LintContext::new(
r#"
# My Heading
[Valid Link](#my-heading)
[Valid Link Different Case](#MY-HEADING)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(0, warnings.len());
}
#[test]
fn test_complex_heading_structures() {
let ctx = LintContext::new(
"# Heading 1\n\nSome text\n\nHeading 2\n-------\n\n### Heading 3\n\n[Link to 1](#heading-1)\n[Link to 2](#heading-2)\n[Link to 3](#heading-3)\n[Link to missing](#heading-4)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
let ctx = LintContext::new(
"# Heading & Special! Characters\n\n[Link](#heading--special-characters)\n[Bad Link](#heading-special-characters-bad)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1);
}
#[test]
fn test_heading_id_generation() {
let ctx = LintContext::new(
r#"
# Heading 1
[Link with space](#heading-1)
[Link with underscore](#heading-1)
[Link with multiple hyphens](#heading-1)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(0, warnings.len());
}
#[test]
fn test_heading_to_fragment_edge_cases() {
let ctx = LintContext::new(
"# Heading\n\n# Heading\n\n[Link 1](somepath#heading)\n[Link 2](somepath#heading-1)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
let ctx = LintContext::new(
"# @#$%^\n\n[Link](somepath#)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
let ctx = LintContext::new(
"# Heading\n\n[Internal](somepath#heading)\n[External](https://example.com#heading)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0);
}
#[test]
fn test_fragment_in_code_blocks() {
let ctx = LintContext::new(
"# Real Heading\n\n```markdown\n# Fake Heading\n[Link](somepath#fake-heading)\n```\n\n[Link](somepath#real-heading)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
println!("Result has {} warnings", result.len());
for (i, warning) in result.iter().enumerate() {
println!("Warning {}: line {}, message: {}", i, warning.line, warning.message);
}
assert_eq!(result.len(), 0);
let ctx = LintContext::new(
"```markdown\n# Code Heading\n```\n\n[Link](#code-heading)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let result = rule.check(&ctx).unwrap();
println!("Second test has {} warnings", result.len());
for (i, warning) in result.iter().enumerate() {
println!("Warning {}: line {}, message: {}", i, warning.line, warning.message);
}
assert_eq!(result.len(), 1);
}
#[test]
fn test_fragment_with_complex_content() {
let ctx = LintContext::new(
r#"
# Heading with **bold** and *italic*
[Link to heading](#heading-with-bold-and-italic)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(
0,
warnings.len(),
"Link should correctly match the heading with stripped formatting"
);
}
#[test]
fn test_nested_formatting_in_fragments() {
let ctx = LintContext::new(
r#"
# Heading with **bold *italic* text**
[Link to heading](#heading-with-bold-italic-text)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(
0,
warnings.len(),
"Link should match heading with nested bold and italic formatting"
);
}
#[test]
fn test_multiple_formatting_styles() {
let ctx = LintContext::new(
r#"
# Heading with _underscores_ and **asterisks** mixed
[Link to heading](#heading-with-underscores-and-asterisks-mixed)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(
0,
warnings.len(),
"Link should match heading with mixed formatting styles"
);
}
#[test]
fn test_complex_nested_formatting() {
let ctx = LintContext::new(
r#"
# **Bold** with *italic* and `code` and [link](https://example.com)
[Link to heading](#bold-with-italic-and-code-and-link)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::with_anchor_style(AnchorStyle::KramdownGfm);
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert_eq!(0, warnings.len(), "Link should match heading with complex formatting");
}
#[test]
fn test_formatting_edge_cases() {
let ctx = LintContext::new(
r#"
# Heading with a**partial**bold and *italic with **nested** formatting*
[Link to partial bold](#heading-with-apartialbold-and-italic-with-nested-formatting)
[Link to nested formatting](#heading-with-apartialbold-and-italic-with-nested-formatting)
"#,
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx);
assert!(result.is_ok());
let warnings = result.unwrap();
assert!(
warnings.len() <= 1,
"At least one link should match the heading with partial formatting"
);
}
#[test]
fn test_performance_md051() {
let mut content = String::with_capacity(50_000);
for i in 0..50 {
content.push_str(&format!("# Heading {i}\n\n"));
content.push_str("Some content paragraph with details about this section.\n\n");
if i % 3 == 0 {
content.push_str(&format!("## Subheading {i}.1\n\n"));
content.push_str("Subheading content with more details.\n\n");
content.push_str(&format!("## Subheading {i}.2\n\n"));
content.push_str("More subheading content here.\n\n");
}
}
content.push_str("# Links Section\n\n");
for i in 0..100 {
if i % 3 == 0 {
content.push_str(&format!("[Link to invalid heading](#heading-{})\n", i + 100));
} else {
content.push_str(&format!("[Link to heading {}](#heading-{})\n", i % 50, i % 50));
}
}
let start = std::time::Instant::now();
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let duration = start.elapsed();
println!(
"MD051 check duration: {:?} for content length {}",
duration,
content.len()
);
println!("Found {} invalid fragments", result.len());
assert!(result.len() >= 30);
assert!(result.len() <= 40);
}
#[test]
fn test_inline_code_spans() {
let ctx = LintContext::new(
"# Real Heading\n\nThis is a real link: [Link](somepath#real-heading)\n\nThis is a code example: `[Example](#missing-section)`",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Link in inline code span should be ignored");
let ctx = LintContext::new(
"# Heading One\n\n`[Invalid](#missing)` and [Valid](#heading-one) and `[Another Invalid](#nowhere)`",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Only links outside code spans should be checked");
let ctx = LintContext::new(
"# Heading One\n\n`[Example](#missing-section)` and [Invalid Link](#section-two)",
rumdl_lib::config::MarkdownFlavor::Standard,
None,
);
println!("=== Test 3 Debug ===");
println!("Content: {:?}", ctx.content);
println!("Line count: {}", ctx.lines.len());
for (i, line_info) in ctx.lines.iter().enumerate() {
println!(
"Line {}: content='{}', in_code_block={}, byte_offset={}",
i,
line_info.content(ctx.content),
line_info.in_code_block,
line_info.byte_offset
);
if let Some(heading) = &line_info.heading {
println!(" Has heading: level={}, text='{}'", heading.level, heading.text);
}
}
let result = rule.check(&ctx).unwrap();
println!("Test 3 - Result count: {}", result.len());
for (i, warning) in result.iter().enumerate() {
println!(
"Warning {}: line {}, col {}, message: {}",
i, warning.line, warning.column, warning.message
);
}
assert_eq!(result.len(), 1, "Only real invalid links should be caught");
assert_eq!(result[0].line, 3, "Warning should be on line 3");
assert!(
result[0].message.contains("section-two"),
"Warning should be about 'section-two'"
);
}
#[test]
fn test_readme_fragments() {
let content = r#"# rumdl - A high-performance Markdown linter, written in Rust
## Table of Contents
- [rumdl - A high-performance Markdown linter, written in Rust](#rumdl---a-high-performance-markdown-linter-written-in-rust)
- [Table of Contents](#table-of-contents)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"README-like fragments should match their headings; got warnings: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_md051_fragment_generation_regression() {
let rule = MD051LinkFragments::new();
let test_cases = vec![
("Simple Heading", "simple-heading"),
("1. Numbered Heading", "1-numbered-heading"),
("Heading with Spaces", "heading-with-spaces"),
("Test & Example", "test--example"),
("A&B", "ab"), ("A & B", "a--b"),
("Multiple & Ampersands & Here", "multiple--ampersands--here"),
("Test. Period", "test-period"),
("Test: Colon", "test-colon"),
("Test! Exclamation", "test-exclamation"),
("Test? Question", "test-question"),
("Test (Parentheses)", "test-parentheses"),
("Test [Brackets]", "test-brackets"),
("1. Heading with Numbers & Symbols!", "1-heading-with-numbers--symbols"),
(
"Multiple!!! Exclamations & Symbols???",
"multiple-exclamations--symbols",
),
(
"Heading with (Parentheses) & [Brackets]",
"heading-with-parentheses--brackets",
),
("Special Characters: @#$%^&*()", "special-characters-"),
("Only!!! Symbols!!!", "only-symbols"),
(" Spaces ", "spaces"), ("Already-hyphenated", "already-hyphenated"),
("Multiple---hyphens", "multiple---hyphens"), ];
for (heading, expected_fragment) in test_cases {
let content = format!("# {heading}\n\n[Link](#{expected_fragment})");
let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Fragment generation failed for heading '{}': expected fragment '{}' should be found, but got {} warnings: {:?}",
heading,
expected_fragment,
result.len(),
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
}
#[test]
fn test_md051_real_world_scenarios() {
let content = r#"
# Main Title
## 1. Getting Started & Setup
[Link to setup](#1-getting-started--setup)
## 2. Configuration & Options
[Link to config](#2-configuration--options)
## 3. Advanced Usage (Examples)
[Link to advanced](#3-advanced-usage-examples)
## 4. FAQ & Troubleshooting
[Link to FAQ](#4-faq--troubleshooting)
## 5. API Reference: Methods & Properties
[Link to API](#5-api-reference-methods--properties)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Expected no warnings, but got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_md051_ampersand_variations() {
let content = r#"
# Test & Example
[Link 1](#test--example)
# A&B
[Link 2](#ab)
# Multiple & Symbols & Here
[Link 3](#multiple--symbols--here)
# Test&End
[Link 4](#testend)
# &Start
[Link 5](#start)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Expected no warnings for ampersand cases, but got: {:?}",
result.iter().map(|w| &w.message).collect::<Vec<_>>()
);
}
#[test]
fn test_cross_file_fragment_links() {
let content = r#"
# Main Heading
## Internal Section
This document has some internal links:
- [Valid internal link](#main-heading)
- [Another valid internal link](#internal-section)
- [Invalid internal link](#missing-section)
And some cross-file links that should be ignored by MD051:
- [Link to other file](README.md#installation)
- [Bug reports](ISSUE_POLICY.md#bug-reports)
- [Triage process](ISSUE_TRIAGE.rst#triage-section)
- [External file fragment](../docs/config.md#settings)
- [YAML config](config.yml#database)
- [JSON settings](app.json#server-config)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected exactly 1 warning for missing internal fragment"
);
assert!(
result[0].message.contains("missing-section"),
"Warning should be about the missing internal section, got: {}",
result[0].message
);
for warning in &result {
assert!(
!warning.message.contains("installation")
&& !warning.message.contains("bug-reports")
&& !warning.message.contains("triage-section")
&& !warning.message.contains("settings"),
"Cross-file fragment should not be flagged: {}",
warning.message
);
}
}
#[test]
fn test_fragment_only_vs_cross_file_links() {
let content = r#"
# Existing Heading
## Another Section
Test various link types:
- [Fragment only - valid](#existing-heading)
- [Fragment only - invalid](#nonexistent-heading)
- [Cross-file with fragment](other.md#some-section)
- [Cross-file no fragment](other.md)
- [Fragment only - valid 2](#another-section)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected exactly 1 warning for invalid fragment-only link"
);
assert!(
result[0].message.contains("nonexistent-heading"),
"Warning should be about the nonexistent heading, got: {}",
result[0].message
);
}
#[test]
fn test_file_extension_edge_cases() {
let content = r#"
# Main Heading
## Test Section
Cross-file links with various extensions (should be ignored by MD051):
- [Case insensitive](README.MD#section)
- [Upper case extension](file.HTML#heading)
- [Mixed case](doc.Rst#title)
- [Markdown variants](guide.markdown#intro)
- [Markdown short](notes.mkdn#summary)
- [Markdown extended](README.mdx#component)
- [Text file](data.txt#line)
- [XML file](config.xml#database)
- [YAML file](settings.yaml#server)
- [YAML short](app.yml#config)
- [JSON file](package.json#scripts)
- [PDF document](manual.pdf#chapter)
- [Word document](report.docx#results)
- [HTML page](index.htm#navbar)
- [Programming file](script.py#function)
- [Config file](settings.toml#section)
- [Generic extension](file.abc#section)
Fragment-only links (should be validated):
- [Valid fragment](#main-heading)
- [Another valid](#test-section)
- [Invalid fragment](#missing-section)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected exactly 1 warning for invalid fragment-only link"
);
assert!(
result[0].message.contains("missing-section"),
"Warning should be about missing-section, got: {}",
result[0].message
);
}
#[test]
fn test_complex_url_patterns() {
let content = r#"
# Main Heading
## Documentation
Cross-file links (should be ignored):
- [Query params](file.md?version=1.0#section)
- [Relative path](../docs/readme.md#installation)
- [Deep relative](../../other/file.md#content)
- [Current dir](./local.md#section)
- [Encoded spaces](file%20name.md#section)
- [Complex path](path/to/deep/file.md#heading)
- [Windows style](folder\file.md#section)
- [Double hash](file.md#section#subsection)
- [Empty fragment](file.md#)
- [Archive file](data.tar.gz#section)
- [Backup file](config.ini.backup#settings)
- [No extension with dot](.gitignore#rules)
- [Hidden no extension](.hidden#section)
- [No extension](somefile#section)
Fragment-only tests:
- [Valid](#main-heading)
- [Invalid](#nonexistent)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for invalid fragment-only link");
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_nonexistent = warning_messages.iter().any(|msg| msg.contains("nonexistent"));
assert!(contains_nonexistent, "Should warn about #nonexistent fragment");
}
#[test]
fn test_edge_case_file_extensions() {
let content = r#"
# Valid Heading
Cross-file links (should be ignored):
- [Multiple dots](file.name.ext#section)
- [Just extension](.md#section)
- [URL with port](http://example.com:8080/file.md#section)
- [Network path](//server/file.md#section)
- [Absolute path](/absolute/file.md#section)
- [No extension](somefile#section)
- [Hidden file](.hidden#section)
Ambiguous paths (dot but empty extension, fragment validated):
- [Dot but no extension](file.#section)
- [Trailing dot](file.#section)
Fragment-only (should be validated):
- [Valid fragment](#valid-heading)
- [Invalid fragment](#invalid-heading)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
3,
"Expected 3 warnings: 2 trailing dot + 1 invalid fragment"
);
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_section = warning_messages.iter().filter(|msg| msg.contains("section")).count();
let contains_invalid = warning_messages.iter().any(|msg| msg.contains("invalid-heading"));
assert_eq!(
contains_section, 2,
"Should have 2 warnings about #section from trailing dot paths"
);
assert!(contains_invalid, "Should warn about #invalid-heading fragment");
}
#[test]
fn test_malformed_and_boundary_cases() {
let content = r#"
# Test Heading
Boundary cases:
- [Empty URL]()
- [Just hash](#)
- [Hash no content](file.md#)
- [Multiple hashes](file.md##double)
- [Fragment with symbols](file.md#section-with-symbols!)
- [Very long filename](very-long-filename-that-exceeds-normal-length.md#section)
Reference links:
[ref1]: other.md#section
[ref2]: #test-heading
[ref3]: missing.md#section
- [Reference to cross-file][ref1]
- [Reference to valid fragment][ref2]
- [Reference to another cross-file][ref3]
Fragment validation:
- [Valid](#test-heading)
- [Invalid](#missing)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for invalid fragment");
assert!(
result[0].message.contains("missing"),
"Warning should be about missing fragment, got: {}",
result[0].message
);
}
#[test]
fn test_performance_stress_case() {
let mut content = String::from("# Main\n\n## Section\n\n");
for i in 0..100 {
content.push_str(&format!("- [Link {i}](file{i}.md#section)\n"));
}
content.push_str("- [Valid](#main)\n");
content.push_str("- [Valid 2](#section)\n");
content.push_str("- [Invalid](#missing)\n");
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning even with many cross-file links");
assert!(
result[0].message.contains("missing"),
"Warning should be about missing fragment, got: {}",
result[0].message
);
}
#[test]
fn test_unicode_and_special_characters() {
let content = r#"
# Test Heading
## Café & Restaurant
Cross-file links with Unicode/special chars (should be ignored):
- [Unicode filename](文档.md#section)
- [Spaces in filename](my file.md#section)
- [Numbers in extension](file.md2#section)
- [Mixed case extension](FILE.Md#section)
- [Unicode no extension](文档#section)
Paths with special chars (not extension-less, fragment validated):
- [Special chars no extension](file@name#section)
Fragment tests:
- [Valid unicode](#café--restaurant)
- [Valid heading](#test-heading)
- [Invalid](#missing-heading)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Expected 2 warnings: 1 path with special char + 1 invalid fragment"
);
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_section = warning_messages.iter().any(|msg| msg.contains("section"));
let contains_missing = warning_messages.iter().any(|msg| msg.contains("missing-heading"));
let contains_cafe = warning_messages.iter().any(|msg| msg.contains("café-restaurant"));
assert!(
contains_section,
"Should warn about #section fragment from file@name#section"
);
assert!(contains_missing, "Should warn about #missing-heading fragment");
assert!(
!contains_cafe,
"Should NOT warn about #café-restaurant fragment (matches heading per GitHub spec)"
);
}
#[test]
fn test_edge_case_regressions() {
let content = r#"
# Documentation
## Setup Guide
Links without fragments (should be ignored):
- [No extension no hash](filename)
- [Extension no hash](file.md)
Cross-file links (should be ignored):
- [Extension and hash](file.md#section)
- [Multiple dots in name](config.local.json#settings)
- [Extension in path](path/file.ext/sub.md#section)
- [Query with fragment](file.md?v=1#section)
- [Anchor with query](file.md#section?param=value)
- [Multiple extensions](archive.tar.gz#section)
- [Case sensitive](FILE.MD#section)
- [Generic extension](data.abc#section)
Paths with potential extensions (treated as cross-file links):
- [Dot in middle](my.file#section)
- [Custom extension](data.custom#section)
Fragment-only validation tests:
- [Hash only](#setup-guide)
- [Valid](#documentation)
- [Valid 2](#setup-guide)
- [Invalid](#nonexistent)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning: 1 invalid fragment");
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_nonexistent = warning_messages.iter().any(|msg| msg.contains("nonexistent"));
assert!(contains_nonexistent, "Should warn about #nonexistent fragment");
}
#[test]
fn test_url_protocol_edge_cases() {
let content = r#"
# Main Heading
## Setup
Protocol-based URLs (should be ignored as external links):
- [HTTP URL](http://example.com/page.html#section)
- [HTTPS URL](https://example.com/docs.md#heading)
- [FTP URL](ftp://server.com/file.txt#anchor)
- [File protocol](file:///path/to/doc.md#section)
- [Mailto with fragment](mailto:user@example.com#subject)
Fragment-only tests:
- [Valid](#main-heading)
- [Invalid](#missing)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for invalid fragment");
assert!(
result[0].message.contains("missing"),
"Warning should be about missing fragment, got: {}",
result[0].message
);
}
#[test]
fn test_fragment_normalization_edge_cases() {
let content = r#"
# Test Heading
## Special Characters & Symbols
## Code `inline` Example
## Multiple Spaces
Fragment tests with normalization:
- [Valid basic](#test-heading)
- [Valid special](#special-characters--symbols)
- [Valid code](#code-inline-example)
- [Valid spaces](#multiple---spaces)
- [Valid case insensitive](#Test-Heading)
- [Invalid symbols](#special-characters-&-symbols)
- [Invalid spacing](#multiple spaces)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Expected 1 warning for invalid fragment with unencoded &"
);
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_symbols = warning_messages
.iter()
.any(|msg| msg.contains("special-characters-&-symbols"));
assert!(
contains_symbols,
"Should warn about & symbol in fragment (should be --)"
);
}
#[test]
fn test_edge_case_file_paths() {
let content = r#"
# Main Heading
Cross-file links with tricky paths (should be ignored):
- [Relative current](./README.md#section)
- [Relative parent](../docs/guide.md#intro)
- [Deep relative](../../other/project/file.md#content)
- [Absolute path](/usr/local/docs/manual.md#chapter)
- [Windows path](C:\Users\docs\readme.md#section)
- [Network path](\\server\share\file.md#section)
- [URL with port](http://localhost:8080/docs.md#api)
Fragment-only (should be validated):
- [Valid](#main-heading)
- [Invalid](#nonexistent)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for invalid fragment");
assert!(
result[0].message.contains("nonexistent"),
"Warning should be about nonexistent fragment, got: {}",
result[0].message
);
}
#[test]
fn test_malformed_link_edge_cases() {
let content = r#"
# Valid Heading
## Test Section
Malformed and edge case links:
- [Empty fragment](file.md#)
- [Just hash](#)
- [Multiple hashes](file.md#section#subsection)
- [Hash in middle](file.md#section?param=value)
- [No closing bracket](file.md#section
- [Valid file](document.pdf#page)
- [Valid fragment](#valid-heading)
- [Invalid fragment](#missing-heading)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(!result.is_empty(), "Expected at least 1 warning for invalid fragment");
let warning_messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
let contains_missing = warning_messages.iter().any(|msg| msg.contains("missing-heading"));
assert!(contains_missing, "Should warn about missing-heading fragment");
}
#[test]
fn test_performance_with_many_links() {
let mut content = String::from("# Main Heading\n\n## Section One\n\n");
for i in 0..100 {
content.push_str(&format!("- [Link {i}](file{i}.md#section)\n"));
}
content.push_str("- [Valid](#main-heading)\n");
content.push_str("- [Valid 2](#section-one)\n");
content.push_str("- [Invalid](#nonexistent)\n");
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(&content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let start = std::time::Instant::now();
let result = rule.check(&ctx).unwrap();
let duration = start.elapsed();
assert_eq!(result.len(), 1, "Expected 1 warning for invalid fragment");
assert!(
result[0].message.contains("nonexistent"),
"Warning should be about nonexistent fragment"
);
assert!(
duration.as_millis() < 100,
"Performance test failed: took {}ms",
duration.as_millis()
);
println!(
"MD051 performance test: {}ms for {} links",
duration.as_millis(),
ctx.links.len()
);
}
#[test]
fn test_custom_header_id_formats() {
let content = r#"# Kramdown Style {#kramdown-id}
Some content here.
## Python-markdown with spaces { #spaced-id }
More content.
### Python-markdown with colon {:#colon-id}
Even more content.
#### Python-markdown full format {: #full-format }
Final content.
Links to test all formats:
- [Link to kramdown](#kramdown-id)
- [Link to spaced](#spaced-id)
- [Link to colon](#colon-id)
- [Link to full format](#full-format)
Links that should fail:
- [Link to nonexistent](#nonexistent-id)
"#;
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for nonexistent fragment");
assert!(
result[0].message.contains("nonexistent-id"),
"Warning should be about nonexistent fragment, got: {}",
result[0].message
);
for warning in &result {
assert!(
!warning.message.contains("kramdown-id")
&& !warning.message.contains("spaced-id")
&& !warning.message.contains("colon-id")
&& !warning.message.contains("full-format"),
"Valid custom ID format should not be flagged as missing: {}",
warning.message
);
}
}
#[test]
fn test_extended_attr_list_support() {
let content = r#"# Simple ID { #simple-id }
## ID with single class {: #with-class .highlight }
### ID with multiple classes {: #multi-class .class1 .class2 }
#### ID with key-value attributes {: #with-attrs data-test="value" style="color: red" }
##### Complex combination {: #complex .highlight .important data-role="button" title="Test" }
###### Edge case with quotes {: #quotes title="Has \"nested\" quotes" }
Links to test extended attr-list support:
- [Simple ID](#simple-id)
- [With class](#with-class)
- [Multiple classes](#multi-class)
- [With attributes](#with-attrs)
- [Complex](#complex)
- [Quotes](#quotes)
Links that should fail:
- [Nonexistent](#nonexistent)
"#;
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for nonexistent fragment");
assert!(
result[0].message.contains("nonexistent"),
"Warning should be about nonexistent fragment, got: {}",
result[0].message
);
for warning in &result {
assert!(
!warning.message.contains("simple-id")
&& !warning.message.contains("with-class")
&& !warning.message.contains("multi-class")
&& !warning.message.contains("with-attrs")
&& !warning.message.contains("complex")
&& !warning.message.contains("quotes"),
"Valid attr-list ID should not be flagged as missing: {}",
warning.message
);
}
}
#[test]
fn test_jekyll_kramdown_next_line_attr_list() {
let content = r#"# Main Title
## ATX Header
{#atx-next-line}
### Another ATX
{ #atx-spaced }
#### ATX with Class
{: #atx-with-class .highlight}
##### ATX Complex
{: #atx-complex .class1 .class2 data-test="value"}
Links to test next-line attr-list:
- [ATX Next Line](#atx-next-line)
- [ATX Spaced](#atx-spaced)
- [ATX with Class](#atx-with-class)
- [ATX Complex](#atx-complex)
Links that should fail:
- [Nonexistent](#nonexistent-next-line)
"#;
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Expected 1 warning for nonexistent fragment");
assert!(
result[0].message.contains("nonexistent-next-line"),
"Warning should be about nonexistent fragment, got: {}",
result[0].message
);
for warning in &result {
assert!(
!warning.message.contains("atx-next-line")
&& !warning.message.contains("atx-spaced")
&& !warning.message.contains("atx-with-class")
&& !warning.message.contains("atx-complex"),
"Valid next-line attr-list ID should not be flagged as missing: {}",
warning.message
);
}
}
#[test]
fn test_mixed_inline_and_next_line_attr_list() {
let content = r#"# Mixed Styles
## Inline Style {#inline-id}
### Next Line Style
{#next-line-id}
#### Inline with Class {: #inline-class .highlight }
##### Next Line with Class
{: #next-line-class .important }
Links:
- [Inline](#inline-id)
- [Next Line](#next-line-id)
- [Inline Class](#inline-class)
- [Next Line Class](#next-line-class)
"#;
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Expected no warnings, got: {result:?}");
}
#[test]
fn debug_issue_39_fragment_generation() {
let content = r#"
# Testing & Coverage
## cbrown --> sbrown: --unsafe-paths
## cbrown -> sbrown
## The End - yay
## API Reference: Methods & Properties
Links for testing:
- [Testing coverage](#testing--coverage)
- [Complex path](#cbrown----sbrown---unsafe-paths)
- [Simple arrow](#cbrown---sbrown)
- [API ref](#api-reference-methods--properties)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
println!("Number of errors: {}", result.len());
for warning in &result {
println!("Warning: {}", warning.message);
}
if result.is_empty() {
println!("SUCCESS: All fragments now match!");
} else {
println!("STILL BROKEN: Fragment generation needs more work");
}
}
#[test]
fn test_issue_39_duplicate_headings() {
let content = r#"
# Title
## Section
This is a [reference](#section-1) to the second section.
## Section
There will be another section.
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "Link to duplicate heading should work");
}
#[test]
fn test_issue_39_complex_punctuation_arrows() {
let content = r#"
## cbrown --> sbrown: --unsafe-paths
## cbrown -> sbrown
## Arrow Test <-> bidirectional
## Double Arrow ==> Test
Links to test:
- [Complex unsafe](#cbrown----sbrown---unsafe-paths)
- [Simple arrow](#cbrown---sbrown)
- [Bidirectional](#arrow-test---bidirectional)
- [Double arrow](#double-arrow--test)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Complex arrow patterns should work: {:?}",
result.iter().map(|r| &r.message).collect::<Vec<_>>()
);
}
#[test]
fn test_issue_39_ampersand_and_colons() {
let content = r#"
# Testing & Coverage
## API Reference: Methods & Properties
## Config: Database & Cache Settings
Links to test:
- [Testing coverage](#testing--coverage)
- [API reference](#api-reference-methods--properties)
- [Config settings](#config-database--cache-settings)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Ampersand and colon patterns should work: {:?}",
result.iter().map(|r| &r.message).collect::<Vec<_>>()
);
}
#[test]
fn test_issue_39_mixed_punctuation_clusters() {
let content = r#"
## Step 1: Setup (Prerequisites)
## Error #404 - Not Found!
## FAQ: What's Next?
## Version 2.0.1 - Release Notes
Links to test:
- [Setup guide](#step-1-setup-prerequisites)
- [Error page](#error-404---not-found)
- [FAQ section](#faq-whats-next)
- [Release notes](#version-201---release-notes)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Mixed punctuation clusters should work: {:?}",
result.iter().map(|r| &r.message).collect::<Vec<_>>()
);
}
#[test]
fn test_issue_39_consecutive_hyphens_and_spaces() {
let content = r#"
## Test --- Multiple Hyphens
## Test -- Spaced Hyphens
## Test - Single - Hyphen
Links to test:
- [Multiple](#test-----multiple-hyphens)
- [Spaced](#test------spaced-hyphens)
- [Single](#test---single---hyphen)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Consecutive hyphens should be collapsed: {:?}",
result.iter().map(|r| &r.message).collect::<Vec<_>>()
);
}
#[test]
fn test_issue_39_edge_cases_from_comments() {
let content = r#"
### PHP $_REQUEST
### sched_debug
#### Add ldap_monitor to delegator$
### cbrown --> sbrown: --unsafe-paths
Links to test:
- [PHP request](#php-_request)
- [Sched debug](#sched_debug)
- [LDAP monitor](#add-ldap_monitor-to-delegator)
- [Complex path](#cbrown----sbrown---unsafe-paths)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Edge cases from issue comments should work: {:?}",
result.iter().map(|r| &r.message).collect::<Vec<_>>()
);
}
#[test]
fn test_html_anchor_tags() {
let content = r#"# Regular Heading
## Heading with anchor<a id="custom-id"></a>
## Another heading<a name="old-style"></a>
Links to test:
- [Regular heading](#regular-heading) - should work
- [Custom ID](#custom-id) - should work
- [Old style name](#old-style) - should work
- [Missing anchor](#missing) - should fail
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag missing anchor");
assert!(result[0].message.contains("#missing"));
}
#[test]
fn test_html_span_div_anchors() {
let content = r#"# Document Title
## Section with span <span id="span-anchor">text</span>
<div id="div-anchor">
Some content in a div
</div>
<section id="section-anchor">
A section element
</section>
<h3 id="h3-anchor">HTML heading</h3>
Links to test:
- [Span anchor](#span-anchor) - should work
- [Div anchor](#div-anchor) - should work
- [Section anchor](#section-anchor) - should work
- [H3 anchor](#h3-anchor) - should work
- [Non-existent](#does-not-exist) - should fail
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should only flag non-existent anchor");
assert!(result[0].message.contains("#does-not-exist"));
}
#[test]
fn test_html_anchors_in_code_blocks() {
let content = r#"# Test Document
```html
<a id="code-anchor">This is in a code block</a>
```
Links to test:
- [Code anchor](#code-anchor) - should fail (anchor is in code block)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Anchors in code blocks should be ignored");
}
#[test]
fn test_multiple_ids_on_same_element() {
let content = r#"# Test Document
<div id="first-id" id="second-id">Content</div>
Links to test:
- [First ID](#first-id) - should work
- [Second ID](#second-id) - should fail (HTML only uses first id)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Only first id attribute should be recognized");
assert!(result[0].message.contains("#second-id"));
}
#[test]
fn test_mixed_markdown_and_html_anchors() {
let content = r#"# Main Title
## Regular Markdown Heading
## Heading with custom ID {#custom-markdown-id}
## Heading with HTML anchor<a id="html-anchor"></a>
<div id="standalone-div">Content</div>
Links to test:
- [Main title](#main-title) - Markdown auto-generated
- [Regular heading](#regular-markdown-heading) - Markdown auto-generated
- [Custom Markdown ID](#custom-markdown-id) - Markdown custom ID
- [HTML anchor](#html-anchor) - HTML anchor on heading
- [Div anchor](#standalone-div) - Standalone HTML element
- [Wrong link](#wrong) - Should fail
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should support both Markdown and HTML anchors");
assert!(result[0].message.contains("#wrong"));
}
#[test]
fn test_case_sensitivity_html_anchors() {
let content = r#"# Test Document
<div id="CamelCase">Content</div>
<span id="lowercase">Content</span>
Links to test:
- [Exact match CamelCase](#CamelCase) - should work
- [Wrong case camelcase](#camelcase) - should fail
- [Exact match lowercase](#lowercase) - should work
- [Wrong case LOWERCASE](#LOWERCASE) - should fail
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "HTML anchors should be case-sensitive");
}
#[test]
fn test_html_anchors_parity_with_markdownlint() {
let content = r#"# Getting Started<a id="getting-started"></a>
## Configuration<a id="configuration"></a>
## Rules<a id="rules"></a>
## Contributing<a id="contributing"></a>
## Support<a id="support"></a>
## Acknowledgements<a id="acknowledgements"></a>
## Who's Using Ruff?<a id="whose-using-ruff"></a>
## License<a id="license"></a>
Table of contents:
1. [Getting Started](#getting-started)
1. [Configuration](#configuration)
1. [Rules](#rules)
1. [Contributing](#contributing)
1. [Support](#support)
1. [Acknowledgements](#acknowledgements)
1. [Who's Using Ruff?](#whose-using-ruff)
1. [License](#license)
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 0, "All HTML anchor links should be valid");
}
#[test]
fn test_issue_82_arrow_patterns() {
let content = r#"# Document
## Table of Contents
- [WAL->L0 Compaction](#wal-l0-compaction)
- [foo->bar->baz](#foo-bar-baz)
- [Header->with->Arrows](#header-with-arrows)
## WAL->L0 Compaction
Content about WAL to L0 compaction.
## foo->bar->baz
Content about foo bar baz.
## Header->with->Arrows
Content with arrows.
"#;
let rule = MD051LinkFragments::new();
let ctx = LintContext::new(content, rumdl_lib::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
0,
"Arrow patterns in headers should generate correct anchors (issue #82)"
);
}
mod extensionless_links {
use rumdl_lib::config::{Config, MarkdownFlavor};
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD051LinkFragments;
use rumdl_lib::workspace_index::WorkspaceIndex;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_extensionless_link_exact_reproduction() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("b.md");
fs::write(&target_file, "# header1\n\nContent here.\n").unwrap();
let source_file = base_path.join("a.md");
let source_content = r#"# Source Document
This links to [header1 in b](b#header1).
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let target_content_str = fs::read_to_string(&target_file).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let (_, target_index) = rumdl_lib::lint_and_index(
&target_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
workspace_index.insert_file(target_file.clone(), target_index.clone());
let target_file_index = workspace_index.get_file(&target_file).unwrap();
assert!(
target_file_index.has_anchor("header1"),
"Target file should have 'header1' anchor indexed"
);
let has_cross_file_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "b" && link.fragment == "header1");
assert!(
has_cross_file_link,
"Extension-less link 'b#header1' should be recognized as cross-file link.\n\
Cross-file links found: {:?}",
source_index.cross_file_links
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
0,
"Extension-less link to existing fragment should have no warnings.\n\
Current warnings: {warnings:?}",
);
}
#[test]
fn test_extensionless_link_missing_fragment() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("page.md");
fs::write(&target_file, "# Other Heading\n\nContent.\n").unwrap();
let source_file = base_path.join("index.md");
let source_content = r#"# Index
Link to [missing section](page#missing-section).
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let target_content_str = fs::read_to_string(&target_file).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let (_, target_index) = rumdl_lib::lint_and_index(
&target_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
workspace_index.insert_file(target_file.clone(), target_index.clone());
let has_cross_file_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "page" && link.fragment == "missing-section");
assert!(
has_cross_file_link,
"Extension-less link 'page#missing-section' should be recognized as cross-file"
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
1,
"Should warn about missing fragment in extension-less link"
);
assert!(
warnings[0].message.contains("missing-section"),
"Warning should mention the missing fragment"
);
assert!(
warnings[0].message.contains("page"),
"Warning should mention the target file"
);
}
#[test]
fn test_extensionless_link_subdirectory() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let docs_dir = base_path.join("docs");
fs::create_dir_all(&docs_dir).unwrap();
let target_file = docs_dir.join("guide.md");
fs::write(&target_file, "# Getting Started\n\n## Installation\n\n## Usage\n").unwrap();
let source_file = base_path.join("README.md");
let source_content = r#"# Main README
See the [installation guide](docs/guide#installation).
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let target_content_str = fs::read_to_string(&target_file).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let (_, target_index) = rumdl_lib::lint_and_index(
&target_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
workspace_index.insert_file(target_file.clone(), target_index.clone());
let has_cross_file_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "docs/guide" && link.fragment == "installation");
assert!(
has_cross_file_link,
"Extension-less link in subdirectory should be recognized"
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
0,
"Extension-less link to existing fragment in subdirectory should be valid"
);
}
#[test]
fn test_extensionless_vs_fragment_only() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("other.md");
fs::write(&target_file, "# Target Heading\n").unwrap();
let source_file = base_path.join("main.md");
let source_content = r#"# Main Document
## Local Section
- [Fragment only](#local-section) - should validate against THIS file
- [Extension-less cross-file](other#target-heading) - should validate against other.md
- [Extension-less missing](other#missing) - should warn about missing fragment
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let target_content_str = fs::read_to_string(&target_file).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let (_, target_index) = rumdl_lib::lint_and_index(
&target_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
workspace_index.insert_file(target_file.clone(), target_index.clone());
let has_fragment_only = source_index
.cross_file_links
.iter()
.any(|link| link.target_path.is_empty() || link.target_path == "#");
assert!(
!has_fragment_only,
"Fragment-only link should NOT be in cross_file_links"
);
let has_extensionless = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "other");
assert!(
has_extensionless,
"Extension-less link 'other#target-heading' should be in cross_file_links"
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
1,
"Should only warn about missing fragment in extension-less link"
);
assert!(
warnings[0].message.contains("missing"),
"Warning should be about missing fragment"
);
}
#[test]
fn test_extensionless_link_file_not_exists() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let source_file = base_path.join("index.md");
let source_content = r#"# Index
Link to [non-existent](nonexistent#section).
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
let has_cross_file_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "nonexistent");
assert!(
has_cross_file_link,
"Extension-less link should be recognized even if file doesn't exist yet"
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
0,
"No warnings for files not in workspace (expected behavior)"
);
}
#[test]
fn test_extensionless_link_markdown_variants() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target1 = base_path.join("page.markdown");
fs::write(&target1, "# Page Markdown\n").unwrap();
let target2 = base_path.join("doc.md");
fs::write(&target2, "# Doc MD\n").unwrap();
let source_file = base_path.join("index.md");
let source_content = r#"# Index
- [Page](page#page-markdown)
- [Doc](doc#doc-md)
"#;
fs::write(&source_file, source_content).unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let source_content_str = fs::read_to_string(&source_file).unwrap();
let target1_content = fs::read_to_string(&target1).unwrap();
let target2_content = fs::read_to_string(&target2).unwrap();
let (_, source_index) = rumdl_lib::lint_and_index(
&source_content_str,
&rules,
false,
MarkdownFlavor::default(),
None,
None,
);
let (_, target1_index) =
rumdl_lib::lint_and_index(&target1_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, target2_index) =
rumdl_lib::lint_and_index(&target2_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(source_file.clone(), source_index.clone());
workspace_index.insert_file(target1.clone(), target1_index.clone());
workspace_index.insert_file(target2.clone(), target2_index.clone());
let has_page_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "page");
let has_doc_link = source_index
.cross_file_links
.iter()
.any(|link| link.target_path == "doc");
assert!(
has_page_link && has_doc_link,
"Both extension-less links should be recognized"
);
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(
warnings.len(),
0,
"Extension-less links to .md and .markdown files should both work"
);
}
}
mod url_encoded_cjk_tests {
use super::*;
use rumdl_lib::config::{Config, MarkdownFlavor};
use rumdl_lib::rules::MD051LinkFragments;
use rumdl_lib::workspace_index::WorkspaceIndex;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_raw_cjk_fragment_works() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## インストール\n\nContent here.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(&source_file, "# Source\n\n[Install](target.md#インストール)\n").unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty(), "Raw CJK fragment should work: {warnings:?}");
}
#[test]
fn test_url_encoded_japanese_fragment() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## インストール\n\nContent here.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(
&source_file,
"# Source\n\n[Install](target.md#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)\n",
)
.unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"URL-encoded Japanese fragment should match raw anchor: {warnings:?}"
);
}
#[test]
fn test_url_encoded_korean_fragment() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## 한êµì–´\n\nKorean content.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(
&source_file,
"# Source\n\n[Korean](target.md#%ED%95%9C%EA%B5%AD%EC%96%B4)\n",
)
.unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"URL-encoded Korean fragment should match raw anchor: {warnings:?}"
);
}
#[test]
fn test_url_encoded_chinese_fragment() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## 䏿–‡\n\nChinese content.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(&source_file, "# Source\n\n[Chinese](target.md#%E4%B8%AD%E6%96%87)\n").unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"URL-encoded Chinese fragment should match raw anchor: {warnings:?}"
);
}
#[test]
fn test_mixed_encoding_fragment() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## Mixed テスト\n\nMixed content.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(
&source_file,
"# Source\n\n[Mixed](target.md#mixed-%E3%83%86%E3%82%B9%E3%83%88)\n",
)
.unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"Mixed ASCII + URL-encoded CJK should work: {warnings:?}"
);
}
#[test]
fn test_invalid_url_encoding_fallback() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## Valid Heading\n\nContent.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(&source_file, "# Source\n\n[Bad](target.md#%ZZ%invalid)\n").unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert_eq!(warnings.len(), 1, "Invalid URL encoding should warn");
}
#[test]
fn test_url_encoding_case_insensitive() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## テスト\n\nContent.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(
&source_file,
"# Source\n\n[Test](target.md#%e3%83%86%e3%82%b9%e3%83%88)\n",
)
.unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty(), "Lowercase URL encoding should work: {warnings:?}");
}
#[test]
fn test_url_encoded_cjk_with_spaces() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let target_file = base_path.join("target.md");
fs::write(&target_file, "# Target\n\n## 한êµì–´ 테스트\n\nContent.\n").unwrap();
let source_file = base_path.join("source.md");
fs::write(
&source_file,
"# Source\n\n[Test](target.md#%ED%95%9C%EA%B5%AD%EC%96%B4-%ED%85%8C%EC%8A%A4%ED%8A%B8)\n",
)
.unwrap();
let rules = rumdl_lib::rules::all_rules(&Config::default());
let target_content = fs::read_to_string(&target_file).unwrap();
let source_content = fs::read_to_string(&source_file).unwrap();
let (_, target_index) =
rumdl_lib::lint_and_index(&target_content, &rules, false, MarkdownFlavor::default(), None, None);
let (_, source_index) =
rumdl_lib::lint_and_index(&source_content, &rules, false, MarkdownFlavor::default(), None, None);
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(target_file.clone(), target_index);
workspace_index.insert_file(source_file.clone(), source_index.clone());
let md051 = MD051LinkFragments::default();
let warnings = md051
.cross_file_check(&source_file, &source_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"URL-encoded CJK with spaces->hyphens should work: {warnings:?}"
);
}
}
mod code_span_slug_tests {
use rumdl_lib::config::MarkdownFlavor;
use rumdl_lib::lint_context::LintContext;
use rumdl_lib::rule::Rule;
use rumdl_lib::rules::MD051LinkFragments;
#[test]
fn test_code_span_preserves_underscores_in_slug() {
let content = "## Introduction\n\n### `__hello__`\n\nThe `__hello__` module\n\n## Summary\n\n- This should match: [`__hello__`](#__hello__)\n- This should NOT match: [`hello`](#hello)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should have exactly 1 warning for #hello, got: {result:?}"
);
assert!(
result[0].message.contains("#hello"),
"Should flag #hello as non-existent, got: {}",
result[0].message
);
}
#[test]
fn test_bare_emphasis_underscores_stripped() {
let content =
"### __hello__\n\n- This should match: [hello](#hello)\n- This should NOT match: [__hello__](#__hello__)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Should have exactly 1 warning for #__hello__, got: {result:?}"
);
assert!(
result[0].message.contains("#__hello__"),
"Should flag #__hello__ as non-existent, got: {}",
result[0].message
);
}
#[test]
fn test_mixed_code_and_emphasis() {
let content = "### `__init__` method for __MyClass__\n\n- [correct](#__init__-method-for-myclass)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #__init__-method-for-myclass should match, got: {result:?}"
);
}
#[test]
fn test_multiple_code_spans_in_heading() {
let content = "## `__a__` and `__b__`\n\n- [link](#__a__-and-__b__)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #__a__-and-__b__ should match heading with two code spans, got: {result:?}"
);
}
#[test]
fn test_multiple_code_spans_wrong_slug() {
let content = "## `__a__` and `__b__`\n\n- [link](#a-and-b)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Link #a-and-b should NOT match heading with code spans, got: {result:?}"
);
}
#[test]
fn test_code_span_with_parentheses() {
let content = "## `__init__(self, name)`\n\n- [link](#__init__self-name)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #__init__self-name should match heading with parens in code span, got: {result:?}"
);
}
#[test]
fn test_digit_starting_custom_id_on_non_heading() {
let content = "Third-Party Library { #3rd-party }\n\n: Some definition.\n\n[link](#3rd-party)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #3rd-party should match non-heading anchor {{ #3rd-party }}, got: {result:?}"
);
}
#[test]
fn test_digit_starting_custom_id_on_heading() {
let content = "# Third-Party Library { #3rd-party }\n\n[link](#3rd-party)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #3rd-party should match heading anchor {{ #3rd-party }}, got: {result:?}"
);
}
#[test]
fn test_various_digit_starting_ids() {
let content = "\
Section One { #1 }\n\
\n\
Section Two { #123-foo }\n\
\n\
Section Three { #1st-section }\n\
\n\
Section Four { #2nd_item }\n\
\n\
[one](#1)\n\
[two](#123-foo)\n\
[three](#1st-section)\n\
[four](#2nd_item)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All digit-starting anchor links should resolve, got: {result:?}"
);
}
#[test]
fn test_digit_starting_id_with_class() {
let content = "Term { #3rd-party .glossary }\n\n[link](#3rd-party)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Link #3rd-party should match anchor with class, got: {result:?}"
);
}
#[test]
fn test_digit_starting_id_invalid_link_still_warns() {
let content = "Term { #3rd-party }\n\n[link](#nonexistent)\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD051LinkFragments::new();
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Link #nonexistent should still be flagged even with digit-starting anchors present, got: {result:?}"
);
}
}