rumdl 0.2.40

A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)
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 invalid headings (e.g., `#NoSpace` which lacks required space after #)
                if !heading.is_valid {
                    continue;
                }

                // 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;

                // If the heading is indented, add a warning
                if indentation > 0 {
                    let is_setext = matches!(
                        heading.style,
                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
                    );

                    if is_setext {
                        // For Setext headings, we need to fix both the heading text and underline
                        let underline_line = line_num + 1;

                        // Calculate precise character range for the indentation
                        let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
                            line_num + 1, // Convert to 1-indexed
                            1,
                            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 {indentation} spaces"),
                            fix: Some(Fix::new(
                                {
                                    // indent is in bytes, so use byte offset directly
                                    let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
                                    line_start..line_start + 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_index.get_line_start_byte(underline_line + 1).unwrap_or(0);
                                            line_start..line_start + underline_indentation
                                        },
                                        String::new(),
                                    )),
                                });
                            }
                        }
                    } else {
                        // 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_index.get_line_start_byte(line_num + 1).unwrap_or(0);
                                    line_start..line_start + indentation
                                },
                                String::new(),
                            )),
                        });
                    }
                }
            }
        }

        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:?}"
        );

        // But uppercase single-# SHOULD be flagged (likely intended heading)
        let content = "Some text\n  #Summary";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            1,
            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
        );

        // Multi-hash patterns SHOULD always be flagged
        let content = "Some text\n  ##introduction";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            1,
            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
        );

        // Multi-hash with numbers SHOULD be flagged
        let content = "Some text\n  ##123";
        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
        let result = rule.check(&ctx).unwrap();
        assert_eq!(
            result.len(),
            1,
            "##123 SHOULD be flagged as indented heading. 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:?}"
            );
        }
    }
}