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