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_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 = ctx.line_start_byte(underline_line + 1).unwrap_or(0);
119                                            line_start..line_start + underline_indentation
120                                        },
121                                        String::new(),
122                                    )),
123                                });
124                            }
125                        }
126                    } else {
127                        // For ATX headings, just fix the single line
128
129                        // Calculate precise character range for the indentation
130                        let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
131                            line_num + 1, // Convert to 1-indexed
132                            1,
133                            indentation,
134                        );
135
136                        warnings.push(LintWarning {
137                            rule_name: Some(self.name().to_string()),
138                            line: atx_start_line,
139                            column: atx_start_col,
140                            end_line: atx_end_line,
141                            end_column: atx_end_col,
142                            severity: Severity::Warning,
143                            message: format!("Heading should not be indented by {indentation} spaces"),
144                            fix: Some(Fix::new(
145                                {
146                                    let line_start = ctx.line_start_byte(line_num + 1).unwrap_or(0);
147                                    line_start..line_start + indentation
148                                },
149                                String::new(),
150                            )),
151                        });
152                    }
153                }
154            }
155        }
156
157        Ok(warnings)
158    }
159
160    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
161        if self.should_skip(ctx) {
162            return Ok(ctx.content.to_string());
163        }
164        let warnings = self.check(ctx)?;
165        if warnings.is_empty() {
166            return Ok(ctx.content.to_string());
167        }
168        let warnings =
169            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
170        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
171            .map_err(crate::rule::LintError::InvalidInput)
172    }
173
174    /// Get the category of this rule for selective processing
175    fn category(&self) -> RuleCategory {
176        RuleCategory::Heading
177    }
178
179    /// Check if this rule should be skipped
180    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
181        // Fast path: check if document likely has headings
182        if !ctx.likely_has_headings() {
183            return true;
184        }
185        // Verify headings actually exist
186        ctx.lines.iter().all(|line| line.heading.is_none())
187    }
188
189    fn as_any(&self) -> &dyn std::any::Any {
190        self
191    }
192
193    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
194    where
195        Self: Sized,
196    {
197        Box::new(MD023HeadingStartLeft)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::lint_context::LintContext;
205    #[test]
206    fn test_basic_functionality() {
207        let rule = MD023HeadingStartLeft;
208
209        // Test with properly aligned headings
210        let content = "# Heading 1\n## Heading 2\n### Heading 3";
211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
212        let result = rule.check(&ctx).unwrap();
213        assert!(result.is_empty());
214
215        // Test with indented headings
216        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
217        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
218        let result = rule.check(&ctx).unwrap();
219        assert_eq!(result.len(), 3); // Should flag all three indented headings
220        assert_eq!(result[0].line, 1);
221        assert_eq!(result[1].line, 2);
222        assert_eq!(result[2].line, 3);
223
224        // Test with setext headings
225        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
227        let result = rule.check(&ctx).unwrap();
228        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
229        assert_eq!(result[0].line, 3);
230        assert_eq!(result[1].line, 4);
231    }
232
233    #[test]
234    fn test_issue_refs_skipped_but_real_headings_caught() {
235        let rule = MD023HeadingStartLeft;
236
237        // Issue refs should NOT be flagged (starts with number)
238        let content = "- fix: issue\n  #29039)";
239        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
240        let result = rule.check(&ctx).unwrap();
241        assert!(
242            result.is_empty(),
243            "#29039) should not be flagged as indented heading. Got: {result:?}"
244        );
245
246        // Hashtags should NOT be flagged (starts with lowercase)
247        let content = "Some text\n  #hashtag";
248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
249        let result = rule.check(&ctx).unwrap();
250        assert!(
251            result.is_empty(),
252            "#hashtag should not be flagged as indented heading. Got: {result:?}"
253        );
254
255        // But uppercase single-# SHOULD be flagged (likely intended heading)
256        let content = "Some text\n  #Summary";
257        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
258        let result = rule.check(&ctx).unwrap();
259        assert_eq!(
260            result.len(),
261            1,
262            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
263        );
264
265        // Multi-hash patterns SHOULD always be flagged
266        let content = "Some text\n  ##introduction";
267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
268        let result = rule.check(&ctx).unwrap();
269        assert_eq!(
270            result.len(),
271            1,
272            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
273        );
274
275        // Multi-hash with numbers SHOULD be flagged
276        let content = "Some text\n  ##123";
277        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
278        let result = rule.check(&ctx).unwrap();
279        assert_eq!(
280            result.len(),
281            1,
282            "##123 SHOULD be flagged as indented heading. Got: {result:?}"
283        );
284
285        // Properly aligned headings should pass
286        let content = "# Summary\n## Details";
287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
288        let result = rule.check(&ctx).unwrap();
289        assert!(
290            result.is_empty(),
291            "Properly aligned headings should pass. Got: {result:?}"
292        );
293    }
294
295    #[test]
296    fn test_mkdocs_admonition_indented_heading_not_flagged() {
297        // A heading intentionally indented to stay nested
298        // inside a MkDocs admonition body must not be flagged, since the
299        // indentation is required for it to belong to the admonition.
300        let rule = MD023HeadingStartLeft;
301        let content = "!!! note\n\n    # Foo";
302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
303        let result = rule.check(&ctx).unwrap();
304
305        assert!(
306            result.is_empty(),
307            "heading nested in an admonition body should not be flagged, got: {result:?}"
308        );
309    }
310
311    #[test]
312    fn test_mkdocs_content_tab_indented_heading_not_flagged() {
313        // The same false positive occurs inside a MkDocs content tab body.
314        let rule = MD023HeadingStartLeft;
315        let content = "=== \"Tab A\"\n\n    # Foo";
316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
317        let result = rule.check(&ctx).unwrap();
318
319        assert!(
320            result.is_empty(),
321            "heading nested in a content tab body should not be flagged, got: {result:?}"
322        );
323    }
324
325    #[test]
326    fn test_mkdocs_accidental_indent_still_flagged() {
327        // Control: a heading indented outside any admonition/tab (accidental
328        // indentation, not container nesting) must still be flagged under
329        // MkDocs flavor, and its fix must still de-indent it.
330        let rule = MD023HeadingStartLeft;
331        let content = "Some text\n\n  # Foo";
332        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
333        let result = rule.check(&ctx).unwrap();
334        assert_eq!(
335            result.len(),
336            1,
337            "accidentally indented top-level heading should still be flagged, got: {result:?}"
338        );
339
340        let fixed = rule.fix(&ctx).unwrap();
341        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
342    }
343
344    #[test]
345    fn test_standard_flavor_indented_heading_still_flagged_and_fixed() {
346        // Control: standard flavor has no MkDocs container concept, so an
347        // indented heading is still an accidental indent and must still be
348        // flagged and de-indented by the fix. Uses 3 spaces: at 4+ spaces
349        // CommonMark parses the line as an indented code block rather than a
350        // heading at all, which is unrelated to this rule's guard.
351        let rule = MD023HeadingStartLeft;
352        let content = "Some text\n\n   # Foo";
353        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354        let result = rule.check(&ctx).unwrap();
355        assert_eq!(
356            result.len(),
357            1,
358            "indented heading should still be flagged under standard flavor, got: {result:?}"
359        );
360
361        let fixed = rule.fix(&ctx).unwrap();
362        assert_eq!(fixed, "Some text\n\n# Foo", "fix should still de-indent it");
363    }
364
365    #[test]
366    fn test_html_markdown_div_indented_heading_still_flagged() {
367        // A markdown="1" HTML div is tag-scoped, not indentation-scoped:
368        // content needs no indentation to belong to it, so an indented
369        // heading inside one is an accidental indent in every flavor.
370        let rule = MD023HeadingStartLeft;
371        let content = "<div markdown=\"1\">\n\n  # Bar\n\n</div>";
372        for flavor in [
373            crate::config::MarkdownFlavor::Standard,
374            crate::config::MarkdownFlavor::MkDocs,
375        ] {
376            let ctx = LintContext::new(content, flavor, None);
377            let result = rule.check(&ctx).unwrap();
378            assert_eq!(
379                result.len(),
380                1,
381                "indented heading inside a markdown=\"1\" div must stay flagged under {flavor:?}, got: {result:?}"
382            );
383        }
384    }
385}