use comrak::nodes::AstNode;
use mdbook_lint_core::error::Result;
use mdbook_lint_core::{
Document,
rule::{AstRule, RuleCategory, RuleMetadata},
violation::{Fix, Severity, Violation},
};
pub struct MD001;
impl AstRule for MD001 {
fn id(&self) -> &'static str {
"MD001"
}
fn name(&self) -> &'static str {
"heading-increment"
}
fn description(&self) -> &'static str {
"Heading levels should only increment by one level at a time"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("markdownlint v0.1.0")
}
fn can_fix(&self) -> bool {
true
}
fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let headings = document.headings(ast);
if headings.is_empty() {
return Ok(violations);
}
let mut previous_level = 0u32;
for heading in headings {
if let Some(level) = Document::heading_level(heading) {
if previous_level == 0 {
previous_level = level;
continue;
}
if level > previous_level + 1 {
let (line, column) = document.node_position(heading).unwrap_or((1, 1));
let heading_text = document.node_text(heading);
let message = format!(
"Expected heading level {} (max {}) but got level {}{}",
previous_level + 1,
previous_level + 1,
level,
if heading_text.is_empty() {
String::new()
} else {
format!(": {}", heading_text.trim())
}
);
let expected_level = previous_level + 1;
let line_content = &document.lines[line - 1];
let fixed_line = if line_content.trim_start().starts_with('#') {
let trimmed = line_content.trim_start();
let content_start =
trimmed.find(|c: char| c != '#').unwrap_or(trimmed.len());
let heading_content = if content_start < trimmed.len() {
&trimmed[content_start..]
} else {
""
};
format!("{}{}", "#".repeat(expected_level as usize), heading_content)
} else {
let heading_text = document.node_text(heading);
let heading_text = heading_text.trim();
format!("{} {}", "#".repeat(expected_level as usize), heading_text)
};
let fix = Fix::line_replacement(
format!("Change heading level from {} to {}", level, expected_level),
fixed_line,
line,
line_content,
document.line_ending(line),
);
violations.push(self.create_violation_with_fix(
message,
line,
column,
Severity::Error,
fix,
));
}
previous_level = level;
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md001_valid_sequence() {
let content = r#"# Level 1
## Level 2
### Level 3
## Level 2 again
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_skip_level() {
let content = r#"# Level 1
### Level 3 - skipped level 2
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD001");
assert_eq!(violations[0].line, 2);
assert_eq!(violations[0].severity, Severity::Error);
assert!(violations[0].message.contains("Expected heading level 2"));
assert!(violations[0].message.contains("got level 3"));
}
#[test]
fn test_md001_multiple_skips() {
let content = r#"# Level 1
#### Level 4 - skipped levels 2 and 3
## Level 2
##### Level 5 - skipped level 4
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("Expected heading level 2"));
assert!(violations[0].message.contains("got level 4"));
assert_eq!(violations[1].line, 4);
assert!(violations[1].message.contains("Expected heading level 3"));
assert!(violations[1].message.contains("got level 5"));
}
#[test]
fn test_md001_decrease_is_ok() {
let content = r#"# Level 1
## Level 2
### Level 3
# Level 1 again - this is OK
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_no_headings() {
let content = "Just some text without headings.";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_single_heading() {
let content = "### Starting with level 3";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_fix_skip_level() {
let content = r#"# Level 1
### Level 3 - skipped level 2
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Change heading level from 3 to 2");
assert_eq!(
fix.replacement,
Some("## Level 3 - skipped level 2\n".to_string())
);
}
#[test]
fn test_md001_fix_multiple_skips() {
let content = r#"# Level 1
##### Level 5 - skipped levels"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].fix.is_some());
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.description, "Change heading level from 5 to 2");
assert_eq!(
fix.replacement,
Some("## Level 5 - skipped levels".to_string())
);
}
#[test]
fn test_md001_can_fix() {
let rule = MD001;
assert!(mdbook_lint_core::AstRule::can_fix(&rule));
}
#[test]
fn test_md001_empty_file() {
let content = "";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_whitespace_only_file() {
let content = " \n\n\t\t\n \n";
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_unicode_headings() {
let content = r#"# 日本語タイトル
## Ελληνικά κεφαλίδα
### 中文标题
#### Заголовок на русском
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_unicode_headings_with_skip() {
let content = r#"# 日本語タイトル
#### Skipped to level 4 中文
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("Expected heading level 2"));
}
#[test]
fn test_md001_emoji_headings() {
let content = r#"# 🚀 Getting Started
## 📖 Introduction
### 💡 Tips and Tricks
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_very_long_heading() {
let long_text = "A".repeat(1000);
let content = format!("# {}\n### {} - skipped level\n", long_text, long_text);
let document = Document::new(content, PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 2);
}
#[test]
fn test_md001_heading_with_special_characters() {
let content = r#"# Title with `code` and **bold**
## Section with [link](url) and *italic*
### Sub with ~~strikethrough~~ and <html>
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_setext_headings_valid() {
let content = r#"Title
=====
Section
-------
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_mixed_atx_setext() {
let content = r#"Title
=====
### Skipped to h3 after setext h1
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 4);
}
#[test]
fn test_md001_headings_in_blockquote() {
let content = r#"> # Quoted heading 1
> ## Quoted heading 2
> ### Quoted heading 3
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_headings_with_trailing_hashes() {
let content = r#"# Title #
## Section ##
### Subsection ###
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_all_six_levels_sequential() {
let content = r#"# H1
## H2
### H3
#### H4
##### H5
###### H6
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md001_skip_from_h1_to_h6() {
let content = r#"# H1
###### H6 - skipped 4 levels
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("Expected heading level 2"));
assert!(violations[0].message.contains("got level 6"));
}
#[test]
fn test_md001_fix_preserves_unicode() {
let content = r#"# 日本語
### 中文标题
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD001;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert!(fix.replacement.as_ref().unwrap().contains("中文标题"));
assert!(fix.replacement.as_ref().unwrap().starts_with("## "));
}
}