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            if skip_next {
169                skip_next = false;
170                continue;
171            }
172
173            // Check if this line is a heading
174            if let Some(heading) = &line_info.heading {
175                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
176                if !heading.is_valid {
177                    fixed_lines.push(line_info.content(ctx.content).to_string());
178                    continue;
179                }
180
181                let indentation = line_info.indent;
182                let is_setext = matches!(
183                    heading.style,
184                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
185                );
186
187                if indentation > 0 {
188                    // This heading needs to be fixed
189                    if is_setext {
190                        // For Setext headings, add the heading text without indentation
191                        fixed_lines.push(line_info.content(ctx.content).trim().to_string());
192                        // Then add the underline without indentation
193                        if i + 1 < ctx.lines.len() {
194                            fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
195                            skip_next = true;
196                        }
197                    } else {
198                        // For ATX headings, simply trim the indentation
199                        fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
200                    }
201                } else {
202                    // This heading is already at the beginning of the line
203                    fixed_lines.push(line_info.content(ctx.content).to_string());
204                    if is_setext && i + 1 < ctx.lines.len() {
205                        fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
206                        skip_next = true;
207                    }
208                }
209            } else {
210                // Not a heading, copy as-is
211                fixed_lines.push(line_info.content(ctx.content).to_string());
212            }
213        }
214
215        let result = fixed_lines.join("\n");
216        if ctx.content.ends_with('\n') {
217            Ok(result + "\n")
218        } else {
219            Ok(result)
220        }
221    }
222
223    /// Get the category of this rule for selective processing
224    fn category(&self) -> RuleCategory {
225        RuleCategory::Heading
226    }
227
228    /// Check if this rule should be skipped
229    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
230        // Fast path: check if document likely has headings
231        if !ctx.likely_has_headings() {
232            return true;
233        }
234        // Verify headings actually exist
235        ctx.lines.iter().all(|line| line.heading.is_none())
236    }
237
238    fn as_any(&self) -> &dyn std::any::Any {
239        self
240    }
241
242    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
243    where
244        Self: Sized,
245    {
246        Box::new(MD023HeadingStartLeft)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::lint_context::LintContext;
254    #[test]
255    fn test_basic_functionality() {
256        let rule = MD023HeadingStartLeft;
257
258        // Test with properly aligned headings
259        let content = "# Heading 1\n## Heading 2\n### Heading 3";
260        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
261        let result = rule.check(&ctx).unwrap();
262        assert!(result.is_empty());
263
264        // Test with indented headings
265        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
266        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
267        let result = rule.check(&ctx).unwrap();
268        assert_eq!(result.len(), 3); // Should flag all three indented headings
269        assert_eq!(result[0].line, 1);
270        assert_eq!(result[1].line, 2);
271        assert_eq!(result[2].line, 3);
272
273        // Test with setext headings
274        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
275        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
276        let result = rule.check(&ctx).unwrap();
277        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
278        assert_eq!(result[0].line, 3);
279        assert_eq!(result[1].line, 4);
280    }
281
282    #[test]
283    fn test_issue_refs_skipped_but_real_headings_caught() {
284        let rule = MD023HeadingStartLeft;
285
286        // Issue refs should NOT be flagged (starts with number)
287        let content = "- fix: issue\n  #29039)";
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            "#29039) should not be flagged as indented heading. Got: {result:?}"
293        );
294
295        // Hashtags should NOT be flagged (starts with lowercase)
296        let content = "Some text\n  #hashtag";
297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
298        let result = rule.check(&ctx).unwrap();
299        assert!(
300            result.is_empty(),
301            "#hashtag should not be flagged as indented heading. Got: {result:?}"
302        );
303
304        // But uppercase single-# SHOULD be flagged (likely intended heading)
305        let content = "Some text\n  #Summary";
306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
307        let result = rule.check(&ctx).unwrap();
308        assert_eq!(
309            result.len(),
310            1,
311            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
312        );
313
314        // Multi-hash patterns SHOULD always be flagged
315        let content = "Some text\n  ##introduction";
316        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
317        let result = rule.check(&ctx).unwrap();
318        assert_eq!(
319            result.len(),
320            1,
321            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
322        );
323
324        // Multi-hash with numbers SHOULD be flagged
325        let content = "Some text\n  ##123";
326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
327        let result = rule.check(&ctx).unwrap();
328        assert_eq!(
329            result.len(),
330            1,
331            "##123 SHOULD be flagged as indented heading. Got: {result:?}"
332        );
333
334        // Properly aligned headings should pass
335        let content = "# Summary\n## Details";
336        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337        let result = rule.check(&ctx).unwrap();
338        assert!(
339            result.is_empty(),
340            "Properly aligned headings should pass. Got: {result:?}"
341        );
342    }
343}