use crate::error::Result;
use crate::rule::{AstRule, RuleCategory, RuleMetadata};
use crate::{
Document,
violation::{Severity, Violation},
};
use comrak::nodes::{AstNode, NodeValue};
pub struct MD025 {
level: u8,
}
impl MD025 {
pub fn new() -> Self {
Self { level: 1 }
}
#[allow(dead_code)]
pub fn with_level(level: u8) -> Self {
Self { level }
}
}
impl Default for MD025 {
fn default() -> Self {
Self::new()
}
}
impl AstRule for MD025 {
fn id(&self) -> &'static str {
"MD025"
}
fn name(&self) -> &'static str {
"single-title"
}
fn description(&self) -> &'static str {
"Multiple top-level headings in the same document"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::Structure).introduced_in("mdbook-lint v0.1.0")
}
fn check_ast<'a>(&self, document: &Document, ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
let mut violations = Vec::new();
let mut h1_headings = Vec::new();
for node in ast.descendants() {
if let NodeValue::Heading(heading) = &node.data.borrow().value {
if heading.level == self.level {
if let Some((line, column)) = document.node_position(node) {
let heading_text = document.node_text(node);
let heading_text = heading_text.trim();
h1_headings.push((line, column, heading_text.to_string()));
}
}
}
}
if h1_headings.len() > 1 {
for (_i, (line, column, heading_text)) in h1_headings.iter().enumerate().skip(1) {
violations.push(self.create_violation(
format!(
"Multiple top-level headings in the same document (first at line {}): {}",
h1_headings[0].0, heading_text
),
*line,
*column,
Severity::Error,
));
}
}
Ok(violations)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
use crate::rule::Rule;
use std::path::PathBuf;
#[test]
fn test_md025_single_h1() {
let content = r#"# Single H1 heading
## H2 heading
### H3 heading
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md025_multiple_h1_violation() {
let content = r#"# First H1 heading
Some content here.
# Second H1 heading
More content.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(
violations[0]
.message
.contains("Multiple top-level headings")
);
assert!(violations[0].message.contains("first at line 1"));
assert!(violations[0].message.contains("Second H1 heading"));
assert_eq!(violations[0].line, 4);
}
#[test]
fn test_md025_three_h1_violations() {
let content = r#"# First H1
Content here.
# Second H1
More content.
# Third H1
Even more content.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("first at line 1"));
assert!(violations[1].message.contains("first at line 1"));
assert_eq!(violations[0].line, 4); assert_eq!(violations[1].line, 7); }
#[test]
fn test_md025_no_h1_headings() {
let content = r#"## H2 heading
### H3 heading
#### H4 heading
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md025_setext_headings() {
let content = r#"First H1 Setext
===============
Second H1 Setext
================
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("first at line 1"));
assert!(violations[0].message.contains("Second H1 Setext"));
assert_eq!(violations[0].line, 4);
}
#[test]
fn test_md025_mixed_atx_setext() {
let content = r#"# ATX H1 heading
Setext H1 heading
=================
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("first at line 1"));
assert!(violations[0].message.contains("Setext H1 heading"));
assert_eq!(violations[0].line, 3);
}
#[test]
fn test_md025_custom_level() {
let content = r#"# H1 heading
## First H2 heading
### H3 heading
## Second H2 heading
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::with_level(2);
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("first at line 2"));
assert!(violations[0].message.contains("Second H2 heading"));
assert_eq!(violations[0].line, 4);
}
#[test]
fn test_md025_h1_with_other_levels() {
let content = r#"# Main heading
## Introduction
### Details
## Conclusion
### More details
#### Sub-details
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md025_empty_h1_headings() {
let content = r#"#
Content here.
#
More content.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 4);
}
#[test]
fn test_md025_h1_in_code_blocks() {
let content = r#"# Real H1 heading
```markdown
# Fake H1 in code block
```
Some content.
"#;
let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
let rule = MD025::new();
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
}