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