rumdl 0.2.79

A fast Markdown linter and formatter written in Rust
Documentation
/// Rule MD023: Headings must start at the left margin
///
/// See [docs/md023.md](../../docs/md023.md) for full documentation, configuration, and examples.
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::range_utils::calculate_single_line_range;

#[derive(Clone)]
pub struct MD023HeadingStartLeft;

impl Rule for MD023HeadingStartLeft {
    fn name(&self) -> &'static str {
        "MD023"
    }

    fn description(&self) -> &'static str {
        "Headings must start at the beginning of the line"
    }

    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
        // Early return for empty content
        if ctx.lines.is_empty() {
            return Ok(vec![]);
        }

        let mut warnings = Vec::new();

        // Process all headings using cached heading information
        for (line_num, line_info) in ctx.lines.iter().enumerate() {
            // Skip lines inside PyMdown blocks, admonitions, and content tabs:
            // those containers are indentation-scoped, so a heading indented
            // there is intentionally nested, not an accidental indent. This
            // deliberately excludes markdown="1" HTML divs (tag-scoped, content
            // needs no indentation, and detected in every flavor).
            if line_info.in_pymdown_block || line_info.in_admonition || line_info.in_content_tab {
                continue;
            }

            if let Some(heading) = &line_info.heading {
                // Skip hashtag-like patterns (e.g., #tag, #123, #29039) for ATX level 1
                // These are likely issue refs or social hashtags, not intended headings
                if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
                    // Get first "word" of heading text (up to space, comma, or closing paren)
                    let first_word: String = heading
                        .text
                        .trim()
                        .chars()
                        .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
                        .collect();
                    if let Some(first_char) = first_word.chars().next() {
                        // Skip if first word starts with lowercase or number
                        if first_char.is_lowercase() || first_char.is_numeric() {
                            continue;
                        }
                    }
                }

                let indentation = line_info.indent;
                let is_setext = matches!(
                    heading.style,
                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
                );

                // A setext heading's text is the whole paragraph its underline
                // ends, and CommonMark strips the leading whitespace of every one
                // of those lines, so each of them carries indentation of its own
                if is_setext {
                    // For Setext headings, we need to fix both the heading text and underline
                    let underline_line = line_num + 1;
                    let first_text_line = line_num + 1 - heading.text_lines;

                    for text_line in first_text_line..=line_num {
                        let text_indentation = ctx.lines[text_line].indent;
                        if text_indentation == 0 {
                            continue;
                        }

                        // Calculate precise character range for the indentation
                        let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
                            text_line + 1, // Convert to 1-indexed
                            1,
                            text_indentation,
                        );

                        // Add warning for the heading text line
                        warnings.push(LintWarning {
                            rule_name: Some(self.name().to_string()),
                            line: start_line_calc,
                            column: start_col,
                            end_line,
                            end_column: end_col,
                            severity: Severity::Warning,
                            message: format!("Setext heading should not be indented by {text_indentation} spaces"),
                            fix: Some(Fix::new(
                                {
                                    // indent is in bytes, so use byte offset directly
                                    let line_start = ctx.line_start_byte(text_line + 1).unwrap_or(0);
                                    line_start..line_start + text_indentation
                                },
                                String::new(),
                            )),
                        });
                    }

                    // Add warning for the underline - only if it's indented
                    if underline_line < ctx.lines.len() {
                        let underline_indentation = ctx.lines[underline_line].indent;
                        if underline_indentation > 0 {
                            let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
                                calculate_single_line_range(underline_line + 1, 1, underline_indentation);

                            warnings.push(LintWarning {
                                rule_name: Some(self.name().to_string()),
                                line: underline_start_line,
                                column: underline_start_col,
                                end_line: underline_end_line,
                                end_column: underline_end_col,
                                severity: Severity::Warning,
                                message: "Setext heading underline should not be indented".to_string(),
                                fix: Some(Fix::new(
                                    {
                                        let line_start = ctx.line_start_byte(underline_line + 1).unwrap_or(0);
                                        line_start..line_start + underline_indentation
                                    },
                                    String::new(),
                                )),
                            });
                        }
                    }
                } else if indentation > 0 {
                    // For ATX headings, just fix the single line

                    // Calculate precise character range for the indentation
                    let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
                        line_num + 1, // Convert to 1-indexed
                        1,
                        indentation,
                    );

                    warnings.push(LintWarning {
                        rule_name: Some(self.name().to_string()),
                        line: atx_start_line,
                        column: atx_start_col,
                        end_line: atx_end_line,
                        end_column: atx_end_col,
                        severity: Severity::Warning,
                        message: format!("Heading should not be indented by {indentation} spaces"),
                        fix: Some(Fix::new(
                            {
                                let line_start = ctx.line_start_byte(line_num + 1).unwrap_or(0);
                                line_start..line_start + indentation
                            },
                            String::new(),
                        )),
                    });
                }
            }
        }

        // Protect the indentation edits themselves: visible template headings
        // can have Markdown indentation outside the template delimiters.
        warnings.retain(|warning| {
            warning
                .fix
                .as_ref()
                .is_none_or(|fix| !ctx.overlaps_template_code(fix.range.start, fix.range.end))
        });
        Ok(warnings)
    }

    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
        if self.should_skip(ctx) {
            return Ok(ctx.content.to_string());
        }
        let warnings = self.check(ctx)?;
        if warnings.is_empty() {
            return Ok(ctx.content.to_string());
        }
        let warnings =
            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
            .map_err(crate::rule::LintError::InvalidInput)
    }

    /// Get the category of this rule for selective processing
    fn category(&self) -> RuleCategory {
        RuleCategory::Heading
    }

    /// Check if this rule should be skipped
    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
        // Fast path: check if document likely has headings
        if !ctx.likely_has_headings() {
            return true;
        }
        // Verify headings actually exist
        ctx.lines.iter().all(|line| line.heading.is_none())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
    where
        Self: Sized,
    {
        Box::new(MD023HeadingStartLeft)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lint_context::LintContext;
    #[test]
    fn test_basic_functionality() {
        let rule = MD023HeadingStartLeft;

        // Test with properly aligned headings
        let content = "# Heading 1\n## Heading 2\n### Heading 3";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(result.is_empty());

        // Test with indented headings
        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(result.len(), 3); // Should flag all three indented headings
        assert_eq!(result[0].line, 1);
        assert_eq!(result[1].line, 2);
        assert_eq!(result[2].line, 3);

        // Test with setext headings
        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
        assert_eq!(result[0].line, 3);
        assert_eq!(result[1].line, 4);
    }

    #[test]
    fn test_issue_refs_skipped_but_real_headings_caught() {
        let rule = MD023HeadingStartLeft;

        // Issue refs should NOT be flagged (starts with number)
        let content = "- fix: issue\n  #29039)";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "#29039) should not be flagged as indented heading. Got: {result:?}"
        );

        // Hashtags should NOT be flagged (starts with lowercase)
        let content = "Some text\n  #hashtag";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "#hashtag should not be flagged as indented heading. Got: {result:?}"
        );

        // A `#` run with no space after it is paragraph text in CommonMark,
        // whatever follows it, so there is no heading to move left
        for content in [
            "Some text\n  #Summary",
            "Some text\n  ##introduction",
            "Some text\n  ##123",
        ] {
            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
            let result = rule.check(&ctx).unwrap();
            assert!(result.is_empty(), "{content:?} is not a heading. Got: {result:?}");
        }

        // The same line with its space is an indented heading
        let content = "Some text\n\n  # Summary";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(result.len(), 1, "indented `# Summary` is flagged. Got: {result:?}");

        // Properly aligned headings should pass
        let content = "# Summary\n## Details";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert!(
            result.is_empty(),
            "Properly aligned headings should pass. Got: {result:?}"
        );
    }

    #[test]
    fn test_mkdocs_admonition_indented_heading_not_flagged() {
        // A heading intentionally indented to stay nested
        // inside a MkDocs admonition body must not be flagged, since the
        // indentation is required for it to belong to the admonition.
        let rule = MD023HeadingStartLeft;
        let content = "!!! note\n\n    # Foo";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "heading nested in an admonition body should not be flagged, got: {result:?}"
        );
    }

    #[test]
    fn test_mkdocs_content_tab_indented_heading_not_flagged() {
        // The same false positive occurs inside a MkDocs content tab body.
        let rule = MD023HeadingStartLeft;
        let content = "=== \"Tab A\"\n\n    # Foo";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
        let result = rule.check(&ctx).unwrap();

        assert!(
            result.is_empty(),
            "heading nested in a content tab body should not be flagged, got: {result:?}"
        );
    }

    #[test]
    fn test_mkdocs_accidental_indent_still_flagged() {
        // Control: a heading indented outside any admonition/tab (accidental
        // indentation, not container nesting) must still be flagged under
        // MkDocs flavor, and its fix must still de-indent it.
        let rule = MD023HeadingStartLeft;
        let content = "Some text\n\n  # Foo";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            1,
            "accidentally indented top-level heading should still be flagged, got: {result:?}"
        );

        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
    }

    #[test]
    fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
        // Control: standard flavor has no MkDocs container concept, so an
        // indented heading is still an accidental indent and must still be
        // flagged and de-indented by the fix. Uses 3 spaces: at 4+ spaces
        // CommonMark parses the line as an indented code block rather than a
        // heading at all, which is unrelated to this rule's guard.
        let rule = MD023HeadingStartLeft;
        let content = "Some text\n\n   # Foo";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            1,
            "indented heading should still be flagged under standard flavor, got: {result:?}"
        );

        let fixed = rule.fix(&ctx).unwrap();
        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
    }

    #[test]
    fn test_html_markdown_div_indented_heading_still_flagged() {
        // A markdown="1" HTML div is tag-scoped, not indentation-scoped:
        // content needs no indentation to belong to it, so an indented
        // heading inside one is an accidental indent in every flavor.
        let rule = MD023HeadingStartLeft;
        let content = "<div markdown=\"1\">\n\n  # Bar\n\n</div>";
        for flavor in [
            crate::config::MarkdownFlavor::Standard,
            crate::config::MarkdownFlavor::MkDocs,
        ] {
            let ctx = LintContext::new(content, flavor, None);
            let result = rule.check(&ctx).unwrap();
            assert_eq!(
                result.len(),
                1,
                "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
            );
        }
    }
}