Skip to main content

rumdl_lib/rules/
md023_heading_start_left.rs

1/// Rule MD023: Headings must start at the left margin
2///
3/// See [docs/md023.md](../../docs/md023.md) for full documentation, configuration, and examples.
4use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6
7#[derive(Clone)]
8pub struct MD023HeadingStartLeft;
9
10impl Rule for MD023HeadingStartLeft {
11    fn name(&self) -> &'static str {
12        "MD023"
13    }
14
15    fn description(&self) -> &'static str {
16        "Headings must start at the beginning of the line"
17    }
18
19    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
20        // Early return for empty content
21        if ctx.lines.is_empty() {
22            return Ok(vec![]);
23        }
24
25        let mut warnings = Vec::new();
26
27        // Process all headings using cached heading information
28        for (line_num, line_info) in ctx.lines.iter().enumerate() {
29            // Skip lines inside PyMdown blocks, admonitions, and content tabs:
30            // those containers are indentation-scoped, so a heading indented
31            // there is intentionally nested, not an accidental indent. This
32            // deliberately excludes markdown="1" HTML divs (tag-scoped, content
33            // needs no indentation, and detected in every flavor).
34            if line_info.in_pymdown_block || line_info.in_admonition || line_info.in_content_tab {
35                continue;
36            }
37
38            if let Some(heading) = &line_info.heading {
39                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
40                if !heading.is_valid {
41                    continue;
42                }
43
44                // Skip hashtag-like patterns (e.g., #tag, #123, #29039) for ATX level 1
45                // These are likely issue refs or social hashtags, not intended headings
46                if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
47                    // Get first "word" of heading text (up to space, comma, or closing paren)
48                    let first_word: String = heading
49                        .text
50                        .trim()
51                        .chars()
52                        .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
53                        .collect();
54                    if let Some(first_char) = first_word.chars().next() {
55                        // Skip if first word starts with lowercase or number
56                        if first_char.is_lowercase() || first_char.is_numeric() {
57                            continue;
58                        }
59                    }
60                }
61
62                let indentation = line_info.indent;
63
64                // If the heading is indented, add a warning
65                if indentation > 0 {
66                    let is_setext = matches!(
67                        heading.style,
68                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
69                    );
70
71                    if is_setext {
72                        // For Setext headings, we need to fix both the heading text and underline
73                        let underline_line = line_num + 1;
74
75                        // Calculate precise character range for the indentation
76                        let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
77                            line_num + 1, // Convert to 1-indexed
78                            1,
79                            indentation,
80                        );
81
82                        // Add warning for the heading text line
83                        warnings.push(LintWarning {
84                            rule_name: Some(self.name().to_string()),
85                            line: start_line_calc,
86                            column: start_col,
87                            end_line,
88                            end_column: end_col,
89                            severity: Severity::Warning,
90                            message: format!("Setext heading should not be indented by {indentation} spaces"),
91                            fix: Some(Fix::new(
92                                {
93                                    // indent is in bytes, so use byte offset directly
94                                    let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
95                                    line_start..line_start + indentation
96                                },
97                                String::new(),
98                            )),
99                        });
100
101                        // Add warning for the underline - only if it's indented
102                        if underline_line < ctx.lines.len() {
103                            let underline_indentation = ctx.lines[underline_line].indent;
104                            if underline_indentation > 0 {
105                                let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
106                                    calculate_single_line_range(underline_line + 1, 1, underline_indentation);
107
108                                warnings.push(LintWarning {
109                                    rule_name: Some(self.name().to_string()),
110                                    line: underline_start_line,
111                                    column: underline_start_col,
112                                    end_line: underline_end_line,
113                                    end_column: underline_end_col,
114                                    severity: Severity::Warning,
115                                    message: "Setext heading underline should not be indented".to_string(),
116                                    fix: Some(Fix::new(
117                                        {
118                                            let line_start =
119                                                ctx.line_index.get_line_start_byte(underline_line + 1).unwrap_or(0);
120                                            line_start..line_start + underline_indentation
121                                        },
122                                        String::new(),
123                                    )),
124                                });
125                            }
126                        }
127                    } else {
128                        // For ATX headings, just fix the single line
129
130                        // Calculate precise character range for the indentation
131                        let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
132                            line_num + 1, // Convert to 1-indexed
133                            1,
134                            indentation,
135                        );
136
137                        warnings.push(LintWarning {
138                            rule_name: Some(self.name().to_string()),
139                            line: atx_start_line,
140                            column: atx_start_col,
141                            end_line: atx_end_line,
142                            end_column: atx_end_col,
143                            severity: Severity::Warning,
144                            message: format!("Heading should not be indented by {indentation} spaces"),
145                            fix: Some(Fix::new(
146                                {
147                                    let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
148                                    line_start..line_start + indentation
149                                },
150                                String::new(),
151                            )),
152                        });
153                    }
154                }
155            }
156        }
157
158        Ok(warnings)
159    }
160
161    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
162        if self.should_skip(ctx) {
163            return Ok(ctx.content.to_string());
164        }
165        let warnings = self.check(ctx)?;
166        if warnings.is_empty() {
167            return Ok(ctx.content.to_string());
168        }
169        let warnings =
170            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
171        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
172            .map_err(crate::rule::LintError::InvalidInput)
173    }
174
175    /// Get the category of this rule for selective processing
176    fn category(&self) -> RuleCategory {
177        RuleCategory::Heading
178    }
179
180    /// Check if this rule should be skipped
181    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
182        // Fast path: check if document likely has headings
183        if !ctx.likely_has_headings() {
184            return true;
185        }
186        // Verify headings actually exist
187        ctx.lines.iter().all(|line| line.heading.is_none())
188    }
189
190    fn as_any(&self) -> &dyn std::any::Any {
191        self
192    }
193
194    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
195    where
196        Self: Sized,
197    {
198        Box::new(MD023HeadingStartLeft)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::lint_context::LintContext;
206    #[test]
207    fn test_basic_functionality() {
208        let rule = MD023HeadingStartLeft;
209
210        // Test with properly aligned headings
211        let content = "# Heading 1\n## Heading 2\n### Heading 3";
212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
213        let result = rule.check(&ctx).unwrap();
214        assert!(result.is_empty());
215
216        // Test with indented headings
217        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
219        let result = rule.check(&ctx).unwrap();
220        assert_eq!(result.len(), 3); // Should flag all three indented headings
221        assert_eq!(result[0].line, 1);
222        assert_eq!(result[1].line, 2);
223        assert_eq!(result[2].line, 3);
224
225        // Test with setext headings
226        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
228        let result = rule.check(&ctx).unwrap();
229        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
230        assert_eq!(result[0].line, 3);
231        assert_eq!(result[1].line, 4);
232    }
233
234    #[test]
235    fn test_issue_refs_skipped_but_real_headings_caught() {
236        let rule = MD023HeadingStartLeft;
237
238        // Issue refs should NOT be flagged (starts with number)
239        let content = "- fix: issue\n  #29039)";
240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
241        let result = rule.check(&ctx).unwrap();
242        assert!(
243            result.is_empty(),
244            "#29039) should not be flagged as indented heading. Got: {result:?}"
245        );
246
247        // Hashtags should NOT be flagged (starts with lowercase)
248        let content = "Some text\n  #hashtag";
249        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
250        let result = rule.check(&ctx).unwrap();
251        assert!(
252            result.is_empty(),
253            "#hashtag should not be flagged as indented heading. Got: {result:?}"
254        );
255
256        // But uppercase single-# SHOULD be flagged (likely intended heading)
257        let content = "Some text\n  #Summary";
258        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
259        let result = rule.check(&ctx).unwrap();
260        assert_eq!(
261            result.len(),
262            1,
263            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
264        );
265
266        // Multi-hash patterns SHOULD always be flagged
267        let content = "Some text\n  ##introduction";
268        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
269        let result = rule.check(&ctx).unwrap();
270        assert_eq!(
271            result.len(),
272            1,
273            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
274        );
275
276        // Multi-hash with numbers SHOULD be flagged
277        let content = "Some text\n  ##123";
278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279        let result = rule.check(&ctx).unwrap();
280        assert_eq!(
281            result.len(),
282            1,
283            "##123 SHOULD be flagged as indented heading. Got: {result:?}"
284        );
285
286        // Properly aligned headings should pass
287        let content = "# Summary\n## Details";
288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
289        let result = rule.check(&ctx).unwrap();
290        assert!(
291            result.is_empty(),
292            "Properly aligned headings should pass. Got: {result:?}"
293        );
294    }
295
296    #[test]
297    fn test_mkdocs_admonition_indented_heading_not_flagged() {
298        // A heading intentionally indented to stay nested
299        // inside a MkDocs admonition body must not be flagged, since the
300        // indentation is required for it to belong to the admonition.
301        let rule = MD023HeadingStartLeft;
302        let content = "!!! note\n\n    # Foo";
303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
304        let result = rule.check(&ctx).unwrap();
305
306        assert!(
307            result.is_empty(),
308            "heading nested in an admonition body should not be flagged, got: {result:?}"
309        );
310    }
311
312    #[test]
313    fn test_mkdocs_content_tab_indented_heading_not_flagged() {
314        // The same false positive occurs inside a MkDocs content tab body.
315        let rule = MD023HeadingStartLeft;
316        let content = "=== \"Tab A\"\n\n    # Foo";
317        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
318        let result = rule.check(&ctx).unwrap();
319
320        assert!(
321            result.is_empty(),
322            "heading nested in a content tab body should not be flagged, got: {result:?}"
323        );
324    }
325
326    #[test]
327    fn test_mkdocs_accidental_indent_still_flagged() {
328        // Control: a heading indented outside any admonition/tab (accidental
329        // indentation, not container nesting) must still be flagged under
330        // MkDocs flavor, and its fix must still de-indent it.
331        let rule = MD023HeadingStartLeft;
332        let content = "Some text\n\n  # Foo";
333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
334        let result = rule.check(&ctx).unwrap();
335        assert_eq!(
336            result.len(),
337            1,
338            "accidentally indented top-level heading should still be flagged, got: {result:?}"
339        );
340
341        let fixed = rule.fix(&ctx).unwrap();
342        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
343    }
344
345    #[test]
346    fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
347        // Control: standard flavor has no MkDocs container concept, so an
348        // indented heading is still an accidental indent and must still be
349        // flagged and de-indented by the fix. Uses 3 spaces: at 4+ spaces
350        // CommonMark parses the line as an indented code block rather than a
351        // heading at all, which is unrelated to this rule's guard.
352        let rule = MD023HeadingStartLeft;
353        let content = "Some text\n\n   # Foo";
354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355        let result = rule.check(&ctx).unwrap();
356        assert_eq!(
357            result.len(),
358            1,
359            "indented heading should still be flagged under standard flavor, got: {result:?}"
360        );
361
362        let fixed = rule.fix(&ctx).unwrap();
363        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
364    }
365
366    #[test]
367    fn test_html_markdown_div_indented_heading_still_flagged() {
368        // A markdown="1" HTML div is tag-scoped, not indentation-scoped:
369        // content needs no indentation to belong to it, so an indented
370        // heading inside one is an accidental indent in every flavor.
371        let rule = MD023HeadingStartLeft;
372        let content = "<div markdown=\"1\">\n\n  # Bar\n\n</div>";
373        for flavor in [
374            crate::config::MarkdownFlavor::Standard,
375            crate::config::MarkdownFlavor::MkDocs,
376        ] {
377            let ctx = LintContext::new(content, flavor, None);
378            let result = rule.check(&ctx).unwrap();
379            assert_eq!(
380                result.len(),
381                1,
382                "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
383            );
384        }
385    }
386}