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
30            if line_info.in_pymdown_block {
31                continue;
32            }
33
34            if let Some(heading) = &line_info.heading {
35                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
36                if !heading.is_valid {
37                    continue;
38                }
39
40                // Skip hashtag-like patterns (e.g., #tag, #123, #29039) for ATX level 1
41                // These are likely issue refs or social hashtags, not intended headings
42                if heading.level == 1 && matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
43                    // Get first "word" of heading text (up to space, comma, or closing paren)
44                    let first_word: String = heading
45                        .text
46                        .trim()
47                        .chars()
48                        .take_while(|c| !c.is_whitespace() && *c != ',' && *c != ')')
49                        .collect();
50                    if let Some(first_char) = first_word.chars().next() {
51                        // Skip if first word starts with lowercase or number
52                        if first_char.is_lowercase() || first_char.is_numeric() {
53                            continue;
54                        }
55                    }
56                }
57
58                let indentation = line_info.indent;
59
60                // If the heading is indented, add a warning
61                if indentation > 0 {
62                    let is_setext = matches!(
63                        heading.style,
64                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
65                    );
66
67                    if is_setext {
68                        // For Setext headings, we need to fix both the heading text and underline
69                        let underline_line = line_num + 1;
70
71                        // Calculate precise character range for the indentation
72                        let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
73                            line_num + 1, // Convert to 1-indexed
74                            1,
75                            indentation,
76                        );
77
78                        // Add warning for the heading text line
79                        warnings.push(LintWarning {
80                            rule_name: Some(self.name().to_string()),
81                            line: start_line_calc,
82                            column: start_col,
83                            end_line,
84                            end_column: end_col,
85                            severity: Severity::Warning,
86                            message: format!("Setext heading should not be indented by {indentation} spaces"),
87                            fix: Some(Fix {
88                                range: ctx.line_index.line_col_to_byte_range_with_length(
89                                    line_num + 1,
90                                    start_col,
91                                    indentation,
92                                ),
93                                replacement: String::new(), // Remove the indentation
94                            }),
95                        });
96
97                        // Add warning for the underline - only if it's indented
98                        if underline_line < ctx.lines.len() {
99                            let underline_indentation = ctx.lines[underline_line].indent;
100                            if underline_indentation > 0 {
101                                // Calculate precise character range for the underline indentation
102                                let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
103                                    calculate_single_line_range(
104                                        underline_line + 1, // Convert to 1-indexed
105                                        1,
106                                        underline_indentation,
107                                    );
108
109                                warnings.push(LintWarning {
110                                    rule_name: Some(self.name().to_string()),
111                                    line: underline_start_line,
112                                    column: underline_start_col,
113                                    end_line: underline_end_line,
114                                    end_column: underline_end_col,
115                                    severity: Severity::Warning,
116                                    message: "Setext heading underline should not be indented".to_string(),
117                                    fix: Some(Fix {
118                                        range: ctx.line_index.line_col_to_byte_range_with_length(
119                                            underline_line + 1,
120                                            underline_start_col,
121                                            underline_indentation,
122                                        ),
123                                        replacement: String::new(), // Remove the indentation
124                                    }),
125                                });
126                            }
127                        }
128                    } else {
129                        // For ATX headings, just fix the single line
130
131                        // Calculate precise character range for the indentation
132                        let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
133                            line_num + 1, // Convert to 1-indexed
134                            1,
135                            indentation,
136                        );
137
138                        warnings.push(LintWarning {
139                            rule_name: Some(self.name().to_string()),
140                            line: atx_start_line,
141                            column: atx_start_col,
142                            end_line: atx_end_line,
143                            end_column: atx_end_col,
144                            severity: Severity::Warning,
145                            message: format!("Heading should not be indented by {indentation} spaces"),
146                            fix: Some(Fix {
147                                range: ctx.line_index.line_col_to_byte_range_with_length(
148                                    line_num + 1,
149                                    atx_start_col,
150                                    indentation,
151                                ),
152                                replacement: String::new(), // Remove the indentation
153                            }),
154                        });
155                    }
156                }
157            }
158        }
159
160        Ok(warnings)
161    }
162
163    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
164        let mut fixed_lines = Vec::new();
165        let mut skip_next = false;
166
167        for (i, line_info) in ctx.lines.iter().enumerate() {
168            let line_num = i + 1;
169            if skip_next {
170                skip_next = false;
171                continue;
172            }
173
174            // If rule is disabled for this line, keep original
175            if ctx.inline_config().is_rule_disabled(self.name(), line_num) {
176                fixed_lines.push(line_info.content(ctx.content).to_string());
177                // For setext headings, also skip the underline
178                if let Some(heading) = &line_info.heading
179                    && matches!(
180                        heading.style,
181                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
182                    )
183                    && i + 1 < ctx.lines.len()
184                {
185                    fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
186                    skip_next = true;
187                }
188                continue;
189            }
190
191            // Check if this line is a heading
192            if let Some(heading) = &line_info.heading {
193                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
194                if !heading.is_valid {
195                    fixed_lines.push(line_info.content(ctx.content).to_string());
196                    continue;
197                }
198
199                let indentation = line_info.indent;
200                let is_setext = matches!(
201                    heading.style,
202                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
203                );
204
205                if indentation > 0 {
206                    // This heading needs to be fixed
207                    if is_setext {
208                        // For Setext headings, add the heading text without indentation
209                        fixed_lines.push(line_info.content(ctx.content).trim().to_string());
210                        // Then add the underline without indentation
211                        if i + 1 < ctx.lines.len() {
212                            fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
213                            skip_next = true;
214                        }
215                    } else {
216                        // For ATX headings, simply trim the indentation
217                        fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
218                    }
219                } else {
220                    // This heading is already at the beginning of the line
221                    fixed_lines.push(line_info.content(ctx.content).to_string());
222                    if is_setext && i + 1 < ctx.lines.len() {
223                        fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
224                        skip_next = true;
225                    }
226                }
227            } else {
228                // Not a heading, copy as-is
229                fixed_lines.push(line_info.content(ctx.content).to_string());
230            }
231        }
232
233        let result = fixed_lines.join("\n");
234        if ctx.content.ends_with('\n') {
235            Ok(result + "\n")
236        } else {
237            Ok(result)
238        }
239    }
240
241    /// Get the category of this rule for selective processing
242    fn category(&self) -> RuleCategory {
243        RuleCategory::Heading
244    }
245
246    /// Check if this rule should be skipped
247    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
248        // Fast path: check if document likely has headings
249        if !ctx.likely_has_headings() {
250            return true;
251        }
252        // Verify headings actually exist
253        ctx.lines.iter().all(|line| line.heading.is_none())
254    }
255
256    fn as_any(&self) -> &dyn std::any::Any {
257        self
258    }
259
260    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
261    where
262        Self: Sized,
263    {
264        Box::new(MD023HeadingStartLeft)
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::lint_context::LintContext;
272    #[test]
273    fn test_basic_functionality() {
274        let rule = MD023HeadingStartLeft;
275
276        // Test with properly aligned headings
277        let content = "# Heading 1\n## Heading 2\n### Heading 3";
278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
279        let result = rule.check(&ctx).unwrap();
280        assert!(result.is_empty());
281
282        // Test with indented headings
283        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
285        let result = rule.check(&ctx).unwrap();
286        assert_eq!(result.len(), 3); // Should flag all three indented headings
287        assert_eq!(result[0].line, 1);
288        assert_eq!(result[1].line, 2);
289        assert_eq!(result[2].line, 3);
290
291        // Test with setext headings
292        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
294        let result = rule.check(&ctx).unwrap();
295        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
296        assert_eq!(result[0].line, 3);
297        assert_eq!(result[1].line, 4);
298    }
299
300    #[test]
301    fn test_issue_refs_skipped_but_real_headings_caught() {
302        let rule = MD023HeadingStartLeft;
303
304        // Issue refs should NOT be flagged (starts with number)
305        let content = "- fix: issue\n  #29039)";
306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307        let result = rule.check(&ctx).unwrap();
308        assert!(
309            result.is_empty(),
310            "#29039) should not be flagged as indented heading. Got: {result:?}"
311        );
312
313        // Hashtags should NOT be flagged (starts with lowercase)
314        let content = "Some text\n  #hashtag";
315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
316        let result = rule.check(&ctx).unwrap();
317        assert!(
318            result.is_empty(),
319            "#hashtag should not be flagged as indented heading. Got: {result:?}"
320        );
321
322        // But uppercase single-# SHOULD be flagged (likely intended heading)
323        let content = "Some text\n  #Summary";
324        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
325        let result = rule.check(&ctx).unwrap();
326        assert_eq!(
327            result.len(),
328            1,
329            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
330        );
331
332        // Multi-hash patterns SHOULD always be flagged
333        let content = "Some text\n  ##introduction";
334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
335        let result = rule.check(&ctx).unwrap();
336        assert_eq!(
337            result.len(),
338            1,
339            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
340        );
341
342        // Multi-hash with numbers SHOULD be flagged
343        let content = "Some text\n  ##123";
344        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345        let result = rule.check(&ctx).unwrap();
346        assert_eq!(
347            result.len(),
348            1,
349            "##123 SHOULD be flagged as indented heading. Got: {result:?}"
350        );
351
352        // Properly aligned headings should pass
353        let content = "# Summary\n## Details";
354        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355        let result = rule.check(&ctx).unwrap();
356        assert!(
357            result.is_empty(),
358            "Properly aligned headings should pass. Got: {result:?}"
359        );
360    }
361}