use comrak::nodes::{AstNode, NodeValue};
use mdbook_lint_core::error::Result;
use mdbook_lint_core::rule::{AstRule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
violation::{Fix, Severity, Violation},
};
pub struct MD023;
impl AstRule for MD023 {
fn id(&self) -> &'static str {
"MD023"
}
fn name(&self) -> &'static str {
"heading-start-left"
}
fn description(&self) -> &'static str {
"Headings must start at the beginning of the line"
}
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 code_block_ranges = self.get_code_block_line_ranges(ast);
for (line_number, line) in document.lines.iter().enumerate() {
let line_num = line_number + 1;
if code_block_ranges
.iter()
.any(|(start, end)| line_num >= *start && line_num <= *end)
{
continue;
}
let trimmed = line.trim_start();
if trimmed.starts_with('#') && !trimmed.starts_with("#!") && line != trimmed {
let leading_whitespace = line.len() - trimmed.len();
let fixed_line = trimmed.to_string();
let fix = Fix::line_replacement(
format!(
"Remove {} character{} of indentation",
leading_whitespace,
if leading_whitespace == 1 { "" } else { "s" }
),
fixed_line,
line_num,
line,
document.line_ending(line_num),
);
violations.push(self.create_violation_with_fix(
format!(
"Heading is indented by {} character{}",
leading_whitespace,
if leading_whitespace == 1 { "" } else { "s" }
),
line_num,
1,
Severity::Warning,
fix,
));
}
}
Ok(violations)
}
}
impl MD023 {
fn get_code_block_line_ranges<'a>(&self, ast: &'a AstNode<'a>) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
self.collect_code_block_ranges(ast, &mut ranges);
ranges
}
#[allow(clippy::only_used_in_recursion)]
fn collect_code_block_ranges<'a>(
&self,
node: &'a AstNode<'a>,
ranges: &mut Vec<(usize, usize)>,
) {
if let NodeValue::CodeBlock(_) = &node.data.borrow().value {
let sourcepos = node.data.borrow().sourcepos;
if sourcepos.start.line > 0 && sourcepos.end.line > 0 {
ranges.push((sourcepos.start.line, sourcepos.end.line));
}
}
for child in node.children() {
self.collect_code_block_ranges(child, ranges);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mdbook_lint_core::rule::Rule;
use std::path::PathBuf;
fn create_test_document(content: &str) -> Document {
Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
}
#[test]
fn test_md023_valid_headings() {
let content = "# Heading 1\n## Heading 2\n### Heading 3";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_single_space_indent() {
let content = " # Indented heading";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].rule_id, "MD023");
assert_eq!(violations[0].line, 1);
assert_eq!(violations[0].column, 1);
assert!(violations[0].message.contains("indented by 1 character"));
}
#[test]
fn test_md023_multiple_spaces_indent() {
let content = " ## Heading with 3 spaces";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("indented by 3 characters"));
}
#[test]
fn test_md023_tab_indent() {
let content = "\t# Tab indented heading";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_mixed_whitespace_indent() {
let content = " \t # Mixed whitespace indent";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_multiple_violations() {
let content = " # Heading 1\n## Valid heading\n ### Heading 3\n#### Valid heading";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 3);
}
#[test]
fn test_md023_setext_headings_ignored() {
let content = " Setext Heading\n ==============\n\n Another Setext\n --------------";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_code_blocks_skipped() {
let content = "```\n # This is in a code block\n ## Should not trigger\n```\n\n # This is in an indented code block\n ## Also should not trigger";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_mixed_with_code_blocks() {
let content = " # Indented outside code block\n\n```\n # Inside fenced code block\n```\n\n ## Another indented heading\n\n # Inside indented code block";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert_eq!(violations[0].line, 1);
assert_eq!(violations[1].line, 7);
}
#[test]
fn test_md023_blockquote_headings() {
let content = "> # Heading in blockquote\n> ## Indented heading in blockquote";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 0);
}
#[test]
fn test_md023_closed_atx_headings() {
let content = " # Indented closed heading #\n ## Another indented ##";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 2);
assert!(violations[0].message.contains("indented by 2 characters"));
assert!(violations[1].message.contains("indented by 3 characters"));
}
#[test]
fn test_md023_shebang_lines_ignored() {
let content =
"#!/bin/bash\n #This should trigger\n #!/usr/bin/env python3\n# This is valid";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
assert_eq!(violations[0].line, 2);
assert!(violations[0].message.contains("indented by 2 characters"));
}
#[test]
fn test_md023_fix_single_space_indent() {
let content = " # Heading";
let document = create_test_document(content);
let rule = MD023;
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.replacement.as_ref().unwrap(), "# Heading");
assert_eq!(fix.description, "Remove 1 character of indentation");
}
#[test]
fn test_md023_fix_multiple_spaces_indent() {
let content = " ## Heading with 3 spaces";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(
fix.replacement.as_ref().unwrap(),
"## Heading with 3 spaces"
);
assert_eq!(fix.description, "Remove 3 characters of indentation");
}
#[test]
fn test_md023_fix_small_indent() {
let content = " # Two space indented";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "# Two space indented");
assert_eq!(fix.description, "Remove 2 characters of indentation");
}
#[test]
fn test_md023_fix_mixed_small_whitespace() {
let content = " ### Two space indent";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "### Two space indent");
assert_eq!(fix.description, "Remove 2 characters of indentation");
}
#[test]
fn test_md023_fix_closed_atx() {
let content = " ## Closed heading ##";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.replacement.as_ref().unwrap(), "## Closed heading ##");
}
#[test]
fn test_md023_fix_multiple_headings() {
let content = " # First\n ## Second\n ### Third";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
assert_eq!(
violations[0]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"# First\n"
);
assert_eq!(
violations[1]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"## Second\n"
);
assert_eq!(
violations[2]
.fix
.as_ref()
.unwrap()
.replacement
.as_ref()
.unwrap(),
"### Third"
);
}
#[test]
fn test_md023_fix_position_accuracy() {
let content = " # Indented";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 1);
let fix = violations[0].fix.as_ref().unwrap();
assert_eq!(fix.start.line, 1);
assert_eq!(fix.start.column, 1);
assert_eq!(fix.end.line, 1);
assert_eq!(fix.end.column, content.chars().count() + 1);
}
#[test]
fn test_md023_fix_all_heading_levels() {
let content = " #H1\n ##H2\n ###H3";
let document = create_test_document(content);
let rule = MD023;
let violations = rule.check(&document).unwrap();
assert_eq!(violations.len(), 3);
for violation in violations.iter() {
assert!(violation.fix.is_some());
let fix = violation.fix.as_ref().unwrap();
assert!(fix.replacement.as_ref().unwrap().starts_with("#"));
assert!(!fix.replacement.as_ref().unwrap().starts_with(" "));
}
}
}