use comrak::nodes::{AstNode, NodeValue};
use mdbook_lint_core::error::Result;
use mdbook_lint_core::{
Document, Violation,
rule::{Rule, RuleCategory, RuleMetadata},
violation::Severity,
};
use std::collections::{HashMap, HashSet};
pub struct MD051 {
ignore_case: bool,
ignored_pattern: Option<String>,
}
impl Default for MD051 {
fn default() -> Self {
Self::new()
}
}
impl MD051 {
pub fn new() -> Self {
Self {
ignore_case: false,
ignored_pattern: None,
}
}
#[allow(dead_code)]
pub fn ignore_case(mut self, ignore_case: bool) -> Self {
self.ignore_case = ignore_case;
self
}
pub fn from_config(config: &toml::Value) -> Self {
let mut rule = Self::new();
if let Some(ignore_case) = config.get("ignore_case").and_then(|v| v.as_bool()) {
rule.ignore_case = ignore_case;
}
if let Some(ignored_pattern) = config.get("ignored_pattern").and_then(|v| v.as_str()) {
rule.ignored_pattern = Some(ignored_pattern.to_string());
}
rule
}
#[allow(dead_code)]
pub fn ignored_pattern(mut self, pattern: Option<String>) -> Self {
self.ignored_pattern = pattern;
self
}
fn get_position<'a>(&self, node: &'a AstNode<'a>) -> (usize, usize) {
let data = node.data.borrow();
let pos = data.sourcepos;
(pos.start.line, pos.start.column)
}
fn generate_heading_fragment(&self, text: &str) -> String {
let mut fragment = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() {
fragment.extend(ch.to_lowercase());
} else if ch == '-' || ch == '_' {
fragment.push(ch);
} else if ch.is_whitespace() {
fragment.push('-');
}
}
fragment = fragment.trim_matches('-').to_string();
fragment
}
fn extract_heading_text<'a>(node: &'a AstNode<'a>) -> String {
let mut text = String::new();
for child in node.children() {
match &child.data.borrow().value {
NodeValue::Text(t) => text.push_str(t),
NodeValue::Code(code) => text.push_str(&code.literal),
NodeValue::Emph | NodeValue::Strong => {
text.push_str(&Self::extract_heading_text(child));
}
_ => {}
}
}
text
}
fn collect_valid_fragments<'a>(&self, ast: &'a AstNode<'a>) -> HashSet<String> {
let mut fragments = HashSet::new();
let mut heading_counts: HashMap<String, usize> = HashMap::new();
fragments.insert("top".to_string());
self.traverse_for_fragments(ast, &mut fragments, &mut heading_counts);
fragments
}
fn traverse_for_fragments<'a>(
&self,
node: &'a AstNode<'a>,
fragments: &mut HashSet<String>,
heading_counts: &mut HashMap<String, usize>,
) {
match &node.data.borrow().value {
NodeValue::Heading(_) => {
let heading_text = Self::extract_heading_text(node);
let base_fragment = self.generate_heading_fragment(&heading_text);
let count = heading_counts.entry(base_fragment.clone()).or_insert(0);
let fragment = if *count == 0 {
base_fragment.clone()
} else {
format!("{base_fragment}-{count}")
};
*count += 1;
fragments.insert(fragment);
if let Some(anchor_id) = self.extract_custom_anchor(&heading_text) {
fragments.insert(anchor_id);
}
}
NodeValue::HtmlBlock(html) => {
let ids = self.extract_html_ids(&html.literal);
for id in ids {
fragments.insert(id);
}
let names = self.extract_html_names(&html.literal);
for name in names {
fragments.insert(name);
}
}
NodeValue::HtmlInline(html) => {
let ids = self.extract_html_ids(html);
for id in ids {
fragments.insert(id);
}
let names = self.extract_html_names(html);
for name in names {
fragments.insert(name);
}
}
_ => {}
}
for child in node.children() {
self.traverse_for_fragments(child, fragments, heading_counts);
}
}
fn extract_custom_anchor(&self, text: &str) -> Option<String> {
if let Some(start) = text.find("{#") {
let remaining = &text[start + 2..];
if let Some(end) = remaining.find('}') {
let anchor_id = &remaining[..end];
if anchor_id
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
&& !anchor_id.is_empty()
{
return Some(anchor_id.to_string());
}
}
}
None
}
fn extract_html_ids(&self, html: &str) -> Vec<String> {
use regex::Regex;
let id_regex = Regex::new(r#"(?i)id\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*))"#).unwrap();
let mut ids = Vec::new();
for captures in id_regex.captures_iter(html) {
for i in 1..=3 {
if let Some(id_match) = captures.get(i) {
let id_value = id_match.as_str().trim();
if !id_value.is_empty() {
ids.push(id_value.to_string());
break; }
}
}
}
ids
}
fn extract_html_names(&self, html: &str) -> Vec<String> {
use regex::Regex;
let name_regex =
Regex::new(r#"(?i)<a[^>]*name\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)).*?>"#).unwrap();
let mut names = Vec::new();
for captures in name_regex.captures_iter(html) {
for i in 1..=3 {
if let Some(name_match) = captures.get(i) {
let name_value = name_match.as_str().trim();
if !name_value.is_empty() {
names.push(name_value.to_string());
break; }
}
}
}
names
}
fn is_github_line_reference(&self, fragment: &str) -> bool {
if !fragment.starts_with('L') {
return false;
}
let remaining = &fragment[1..];
let mut chars = remaining.chars().peekable();
if !self.consume_digits(&mut chars) {
return false;
}
if chars.peek() == Some(&'C') {
chars.next();
if !self.consume_digits(&mut chars) {
return false;
}
}
if chars.peek() == Some(&'-') {
chars.next();
if chars.next() != Some('L') {
return false;
}
if !self.consume_digits(&mut chars) {
return false;
}
if chars.peek() == Some(&'C') {
chars.next();
if !self.consume_digits(&mut chars) {
return false;
}
}
}
chars.peek().is_none()
}
fn consume_digits(&self, chars: &mut std::iter::Peekable<std::str::Chars>) -> bool {
let mut consumed_any = false;
while let Some(&ch) = chars.peek() {
if ch.is_ascii_digit() {
chars.next();
consumed_any = true;
} else {
break;
}
}
consumed_any
}
fn check_link_fragments<'a>(
&self,
ast: &'a AstNode<'a>,
valid_fragments: &HashSet<String>,
) -> Vec<Violation> {
let mut violations = Vec::new();
self.traverse_for_links(ast, valid_fragments, &mut violations);
violations
}
fn traverse_for_links<'a>(
&self,
node: &'a AstNode<'a>,
valid_fragments: &HashSet<String>,
violations: &mut Vec<Violation>,
) {
if let NodeValue::Link(link) = &node.data.borrow().value
&& let Some(fragment) = link.url.strip_prefix('#')
{
if fragment.is_empty() {
let pos = self.get_position(node);
violations.push(self.create_violation(
"Link fragment is empty".to_string(),
pos.0,
pos.1,
Severity::Error,
));
return;
}
if let Some(ref pattern) = self.ignored_pattern
&& fragment.contains(pattern)
{
return;
}
if self.is_github_line_reference(fragment) {
return;
}
let fragment_to_check = if self.ignore_case {
fragment.to_lowercase()
} else {
fragment.to_string()
};
let valid_fragments_check: HashSet<String> = if self.ignore_case {
valid_fragments.iter().map(|f| f.to_lowercase()).collect()
} else {
valid_fragments.clone()
};
if !valid_fragments_check.contains(&fragment_to_check) {
let pos = self.get_position(node);
violations.push(self.create_violation(
format!("Link fragment '{fragment}' is not valid"),
pos.0,
pos.1,
Severity::Error,
));
}
}
for child in node.children() {
self.traverse_for_links(child, valid_fragments, violations);
}
}
fn check_fragments_fallback(&self, document: &Document) -> Vec<Violation> {
let mut violations = Vec::new();
for (line_num, line) in document.content.lines().enumerate() {
let line_number = line_num + 1;
let mut chars = line.char_indices().peekable();
let mut in_backticks = false;
while let Some((i, ch)) = chars.next() {
match ch {
'`' => {
in_backticks = !in_backticks;
}
'[' if !in_backticks => {
if let Some((fragment, text_end)) = self.parse_fragment_link(&line[i..]) {
if fragment.is_empty() {
violations.push(self.create_violation(
"Link fragment is empty".to_string(),
line_number,
i + 1,
Severity::Error,
));
for _ in 0..text_end - 1 {
chars.next();
}
continue;
}
if fragment == "top" {
for _ in 0..text_end - 1 {
chars.next();
}
continue;
}
let mut is_suspicious = false;
if self.is_github_line_reference(&fragment) {
for _ in 0..text_end - 1 {
chars.next();
}
continue;
}
if fragment.contains("invalid") || fragment.contains("undefined") {
is_suspicious = true;
}
if !self.ignore_case && fragment != fragment.to_lowercase() {
is_suspicious = true;
}
if is_suspicious {
violations.push(self.create_violation(
format!("Link fragment '{fragment}' may not be valid"),
line_number,
i + 1,
Severity::Warning,
));
}
for _ in 0..text_end - 1 {
chars.next();
}
}
}
_ => {}
}
}
}
violations
}
fn parse_fragment_link(&self, text: &str) -> Option<(String, usize)> {
if !text.starts_with('[') {
return None;
}
let mut bracket_count = 0;
let mut closing_bracket_pos = None;
for (i, ch) in text.char_indices() {
match ch {
'[' => bracket_count += 1,
']' => {
bracket_count -= 1;
if bracket_count == 0 {
closing_bracket_pos = Some(i);
break;
}
}
_ => {}
}
}
let closing_bracket_pos = closing_bracket_pos?;
let remaining = &text[closing_bracket_pos + 1..];
if remaining.starts_with("(#") {
let fragment_start = closing_bracket_pos + 3; if let Some(closing_paren) = remaining.find(')') {
let fragment_end = closing_bracket_pos + 1 + closing_paren;
let fragment = &text[fragment_start..fragment_end];
let total_length = fragment_end + 1;
return Some((fragment.to_string(), total_length));
}
}
None
}
}
impl Rule for MD051 {
fn id(&self) -> &'static str {
"MD051"
}
fn name(&self) -> &'static str {
"link-fragments"
}
fn description(&self) -> &'static str {
"Link fragments should be valid"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Links)
}
fn check_with_ast<'a>(
&self,
document: &Document,
ast: Option<&'a AstNode<'a>>,
) -> Result<Vec<Violation>> {
if let Some(ast) = ast {
let valid_fragments = self.collect_valid_fragments(ast);
let violations = self.check_link_fragments(ast, &valid_fragments);
Ok(violations)
} else {
Ok(self.check_fragments_fallback(document))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::test_helpers::*;
#[test]
fn test_valid_fragments() {
let content = r#"# Heading Name
[Link](#heading-name)
## Another Heading
[Another link](#another-heading)
<div id="custom-id"></div>
[Custom](#custom-id)
<a name="bookmark"></a>
[Bookmark](#bookmark)
[Top link](#top)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_invalid_fragments() {
let content = r#"# Heading Name
[Invalid link](#invalid-fragment)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 3);
assert!(violation.message.contains("invalid-fragment"));
}
#[test]
fn test_duplicate_headings() {
let content = r#"# Test
[Link 1](#test)
# Test
[Link 2](#test-1)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_github_line_references() {
let content = r#"# Code
[Line 20](#L20)
[Range](#L19C5-L21C11)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_case_sensitivity() {
let content = r#"# Heading Name
[Link](#Heading-Name)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 3);
assert_no_violations(MD051::new().ignore_case(true), content);
}
#[test]
fn test_custom_anchor() {
let content = r#"# Heading Name {#custom-anchor}
[Link](#custom-anchor)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_empty_fragment() {
let content = r#"# Heading
[Empty fragment](#)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 3);
}
#[test]
fn test_html_id_attributes() {
let content = r#"# Heading
<div id="custom-id">Content</div>
<span id="another-id">Text</span>
[Link to div](#custom-id)
[Link to span](#another-id)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_html_name_attributes() {
let content = r#"# Heading
<a name="anchor-name"></a>
<div name="form-element">Content</div>
[Link to anchor](#anchor-name)
[Link to element](#form-element)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_html_block_extraction() {
let content = r#"# Heading
<div class="content">
<p id="paragraph-id">Text</p>
<a name="link-name" href="/test">Link</a>
</div>
[Link to paragraph](#paragraph-id)
[Link to anchor](#link-name)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_html_inline_extraction() {
let content = r#"# Heading
This is text with <span id="inline-id">inline HTML</span> and <a name="inline-name">anchor</a>.
[Link to inline](#inline-id)
[Link to anchor](#inline-name)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_complex_fragment_generation() {
let content = r#"# Complex Heading with (Parentheses) & Symbols!
[Link](#complex-heading-with-parentheses--symbols)
## Another_Complex-Title 123
[Another link](#another_complex-title-123)
### Multiple Spaces Between Words
[Space link](#multiple-spaces-between-words)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_dash_preservation() {
let content = r#"# Title---With----Multiple-----Dashes
[Link](#title---with----multiple-----dashes)
## --Leading-And-Trailing--
[Another link](#leading-and-trailing)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_unicode_and_special_chars() {
let content = r#"# Heading with émojis 🚀 and ñ
[Unicode link](#heading-with-émojis--and-ñ)
## Code `inline` and **bold**
[Code link](#code-inline-and-bold)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_custom_anchor_validation() {
let content = r#"# Valid Custom {#valid-anchor}
[Link](#valid-anchor)
# Invalid Custom {#invalid anchor}
[Bad link](#invalid-anchor)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 7);
assert!(violation.message.contains("invalid-anchor"));
}
#[test]
fn test_custom_anchor_edge_cases() {
let content = r#"# Empty Custom {#}
# Valid Custom {#test123}
[Link](#test123)
# Invalid Chars {#test@123}
# Nested {#outer {#inner} }
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_github_line_references_detailed() {
let content = r#"# Code Examples
[Line reference](#L42)
[Line range](#L10-L20)
[Complex range](#L15C3-L25C10)
[Another format](#L1C1-L1C5)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_multiple_document_types() {
let content = r#"# Main Heading
Regular text here.
<div id="html-id">HTML content</div>
<a name="html-name">Anchor</a>
## Sub Heading {#custom-sub}
More content.
[Link to main](#main-heading)
[Link to sub](#custom-sub)
[Link to HTML ID](#html-id)
[Link to HTML name](#html-name)
[GitHub reference](#L100)
[Invalid reference](#Invalid-Reference)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 18);
assert!(violation.message.contains("Invalid-Reference"));
}
#[test]
fn test_duplicate_heading_numbering() {
let content = r#"# Test
[First link](#test)
# Test
[Second link](#test-1)
# Test
[Third link](#test-2)
# Different
[Different link](#different)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_html_parsing_edge_cases() {
let content = r#"# Heading
<!-- Comment with id="not-real" -->
<div id='single-quotes'>Content</div>
<span id="no-closing-quote>Broken</span>
<p id=unquoted-id>Unquoted</p>
[Single quotes](#single-quotes)
[Unquoted](#unquoted-id)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_configuration_options() {
let content = r#"# Test Heading
[Case mismatch](#Test-Heading)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 3);
assert_no_violations(MD051::new().ignore_case(true), content);
}
#[test]
fn test_ignored_pattern() {
let content = r#"# Heading
[External link](#external-pattern)
[Normal link](#invalid-fragment)
"#;
let rule = MD051::new().ignored_pattern(Some("external-*".to_string()));
let violation = assert_single_violation(rule, content);
assert_eq!(violation.line, 4);
assert!(violation.message.contains("invalid-fragment"));
}
#[test]
fn test_empty_document() {
let content = "";
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_no_headings_no_fragments() {
let content = r#"Just some text without headings.
[Invalid link](#Invalid-Fragment)
"#;
let violation = assert_single_violation(MD051::new(), content);
assert_eq!(violation.line, 3);
assert!(violation.message.contains("Invalid-Fragment"));
}
#[test]
fn test_top_fragment() {
let content = r#"# Heading
[Link to top](#top)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_malformed_html() {
let content = r#"# Heading
<div id=>Empty value</div>
<span id>No value</span>
<p id="unclosed>Bad quote</p>
[Should still work](#heading)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_nested_html_elements() {
let content = r#"# Heading
<div class="outer">
<div id="nested-id">
<span name="deep-name">Content</span>
</div>
</div>
[Link to nested](#nested-id)
[Link to deep](#deep-name)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_heading_with_code_and_emphasis() {
let content = r#"# Title with `code` and **bold** and *italic*
[Link](#title-with-code-and-bold-and-italic)
## Another `complex` **formatting** example
[Another link](#another-complex-formatting-example)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_issue_324_backticks_with_plus() {
let content = r#"## `a` + `title`
[somewhere](#a--title)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_issue_323_underscore_in_code() {
let content = r#"## `a_title`
[somewhere](#a_title)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_underscores_preserved_in_slugs() {
let content = r#"# my_function_name
[link](#my_function_name)
## snake_case_heading
[another](#snake_case_heading)
"#;
assert_no_violations(MD051::new(), content);
}
#[test]
fn test_issue_356_backticks_with_plus_using_ast() {
use comrak::Arena;
use mdbook_lint_core::rule::Rule;
let content = r#"## `a` + `title`
[somewhere](#a--title)
"#;
let document = mdbook_lint_core::Document::new(
content.to_string(),
std::path::PathBuf::from("test.md"),
)
.unwrap();
let arena = Arena::new();
let ast = document.parse_ast(&arena);
let rule = MD051::new();
let violations = rule.check_with_ast(&document, Some(ast)).unwrap();
assert_eq!(
violations.len(),
0,
"Expected no violations but found: {violations:#?}"
);
}
#[test]
fn test_issue_410_duplicate_heading_fragments_ast() {
use comrak::Arena;
use mdbook_lint_core::rule::Rule;
let content = r#"# Demo
## Topic
Look at its [details](#details).
### Details
Details about the topic.
## Another Topic
Look at its [details](#details-1).
### Details
Details about the other topic.
"#;
let document = mdbook_lint_core::Document::new(
content.to_string(),
std::path::PathBuf::from("demo.md"),
)
.unwrap();
let arena = Arena::new();
let ast = document.parse_ast(&arena);
let rule = MD051::new();
let violations = rule.check_with_ast(&document, Some(ast)).unwrap();
assert_eq!(
violations.len(),
0,
"Expected no violations but found: {violations:#?}"
);
}
#[test]
fn test_issue_410_broken_fragment_still_errors_ast() {
use comrak::Arena;
use mdbook_lint_core::rule::Rule;
let content = r#"# Demo
### Details
First.
### Details
Second.
[bad link](#details-2)
"#;
let document = mdbook_lint_core::Document::new(
content.to_string(),
std::path::PathBuf::from("demo.md"),
)
.unwrap();
let arena = Arena::new();
let ast = document.parse_ast(&arena);
let rule = MD051::new();
let violations = rule.check_with_ast(&document, Some(ast)).unwrap();
assert_eq!(
violations.len(),
1,
"Expected one violation: {violations:#?}"
);
assert!(violations[0].message.contains("details-2"));
}
#[test]
fn test_issue_399_unicode_headings() {
let rule = MD051::new();
assert_eq!(rule.generate_heading_fragment("Übungen"), "übungen");
assert_eq!(rule.generate_heading_fragment("Ärger"), "ärger");
assert_eq!(rule.generate_heading_fragment("Überprüfung"), "überprüfung");
let content = "# Übungen\n\n[link](#übungen)\n";
assert_no_violations(rule, content);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn heading_text_strategy() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
"[a-zA-Z0-9]".prop_map(|s| s.to_string()),
Just(" ".to_string()),
Just("+".to_string()),
Just("-".to_string()),
Just("_".to_string()),
Just("'".to_string()),
Just("!".to_string()),
Just("?".to_string()),
Just(".".to_string()),
Just("`".to_string()),
],
1..50,
)
.prop_map(|chars| chars.join(""))
}
proptest! {
#[test]
fn slug_contains_only_valid_chars(heading in heading_text_strategy()) {
let rule = MD051::new();
let slug = rule.generate_heading_fragment(&heading);
for ch in slug.chars() {
prop_assert!(
ch.is_alphanumeric() || ch == '-' || ch == '_',
"Invalid character '{}' in slug '{}' from heading '{}'",
ch, slug, heading
);
}
}
#[test]
fn slug_is_lowercase(heading in heading_text_strategy()) {
let rule = MD051::new();
let slug = rule.generate_heading_fragment(&heading);
prop_assert!(
slug == slug.to_lowercase(),
"Slug '{}' from heading '{}' is not lowercase", slug, heading
);
}
#[test]
fn slug_no_leading_trailing_hyphens(heading in heading_text_strategy()) {
let rule = MD051::new();
let slug = rule.generate_heading_fragment(&heading);
if !slug.is_empty() {
prop_assert!(
!slug.starts_with('-'),
"Slug '{}' from heading '{}' starts with hyphen", slug, heading
);
prop_assert!(
!slug.ends_with('-'),
"Slug '{}' from heading '{}' ends with hyphen", slug, heading
);
}
}
#[test]
fn alphanumeric_preserved(heading in "[a-zA-Z0-9]+") {
let rule = MD051::new();
let slug = rule.generate_heading_fragment(&heading);
let expected = heading.to_lowercase();
prop_assert!(
slug == expected,
"Pure alphanumeric heading '{}' should become '{}', got '{}'",
heading, expected, slug
);
}
#[test]
fn spaces_become_hyphens(
prefix in "[a-z]+",
space_count in 1usize..5,
suffix in "[a-z]+"
) {
let rule = MD051::new();
let spaces = " ".repeat(space_count);
let heading = format!("{}{}{}", prefix, spaces, suffix);
let slug = rule.generate_heading_fragment(&heading);
let expected_hyphens = "-".repeat(space_count);
let expected = format!("{}{}{}", prefix, expected_hyphens, suffix);
prop_assert!(
slug == expected,
"Heading '{}' should become '{}', got '{}'", heading, expected, slug
);
}
#[test]
fn underscores_preserved(
prefix in "[a-z]+",
suffix in "[a-z]+"
) {
let rule = MD051::new();
let heading = format!("{}_{}", prefix, suffix);
let slug = rule.generate_heading_fragment(&heading);
prop_assert!(
slug == heading,
"Heading with underscore '{}' should be preserved, got '{}'", heading, slug
);
}
#[test]
fn hyphens_preserved(
prefix in "[a-z]+",
suffix in "[a-z]+"
) {
let rule = MD051::new();
let heading = format!("{}-{}", prefix, suffix);
let slug = rule.generate_heading_fragment(&heading);
prop_assert!(
slug == heading,
"Heading with hyphen '{}' should be preserved, got '{}'", heading, slug
);
}
#[test]
fn punctuation_removed(
prefix in "[a-z]+",
suffix in "[a-z]+"
) {
let rule = MD051::new();
let heading = format!("{}+{}", prefix, suffix);
let slug = rule.generate_heading_fragment(&heading);
let expected = format!("{}{}", prefix, suffix);
prop_assert!(
slug == expected,
"Heading '{}' should have punctuation removed to become '{}', got '{}'",
heading, expected, slug
);
}
}
#[test]
fn ast_path_matches_expected_behavior() {
use comrak::Arena;
use mdbook_lint_core::rule::Rule;
let test_cases = vec", 0),
("## `a_title`\n\n[link](#a_title)", 0),
("## Hello World\n\n[link](#hello-world)", 0),
("## Test---Dashes\n\n[link](#test---dashes)", 0),
];
for (content, expected_violations) in test_cases {
let document = mdbook_lint_core::Document::new(
content.to_string(),
std::path::PathBuf::from("test.md"),
)
.unwrap();
let arena = Arena::new();
let ast = document.parse_ast(&arena);
let rule = MD051::new();
let violations = rule.check_with_ast(&document, Some(ast)).unwrap();
assert_eq!(
violations.len(),
expected_violations,
"Content '{}' expected {} violations but got {}: {:?}",
content.replace('\n', "\\n"),
expected_violations,
violations.len(),
violations
);
}
}
}