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: {
89                                    // indent is in bytes, so use byte offset directly
90                                    let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
91                                    line_start..line_start + indentation
92                                },
93                                replacement: String::new(),
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                                let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
102                                    calculate_single_line_range(underline_line + 1, 1, underline_indentation);
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: {
114                                            let line_start =
115                                                ctx.line_index.get_line_start_byte(underline_line + 1).unwrap_or(0);
116                                            line_start..line_start + underline_indentation
117                                        },
118                                        replacement: String::new(),
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: {
143                                    let line_start = ctx.line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
144                                    line_start..line_start + indentation
145                                },
146                                replacement: String::new(),
147                            }),
148                        });
149                    }
150                }
151            }
152        }
153
154        Ok(warnings)
155    }
156
157    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
158        if self.should_skip(ctx) {
159            return Ok(ctx.content.to_string());
160        }
161        let warnings = self.check(ctx)?;
162        if warnings.is_empty() {
163            return Ok(ctx.content.to_string());
164        }
165        let warnings =
166            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
167        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
168            .map_err(crate::rule::LintError::InvalidInput)
169    }
170
171    /// Get the category of this rule for selective processing
172    fn category(&self) -> RuleCategory {
173        RuleCategory::Heading
174    }
175
176    /// Check if this rule should be skipped
177    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
178        // Fast path: check if document likely has headings
179        if !ctx.likely_has_headings() {
180            return true;
181        }
182        // Verify headings actually exist
183        ctx.lines.iter().all(|line| line.heading.is_none())
184    }
185
186    fn as_any(&self) -> &dyn std::any::Any {
187        self
188    }
189
190    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
191    where
192        Self: Sized,
193    {
194        Box::new(MD023HeadingStartLeft)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::lint_context::LintContext;
202    #[test]
203    fn test_basic_functionality() {
204        let rule = MD023HeadingStartLeft;
205
206        // Test with properly aligned headings
207        let content = "# Heading 1\n## Heading 2\n### Heading 3";
208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
209        let result = rule.check(&ctx).unwrap();
210        assert!(result.is_empty());
211
212        // Test with indented headings
213        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
214        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
215        let result = rule.check(&ctx).unwrap();
216        assert_eq!(result.len(), 3); // Should flag all three indented headings
217        assert_eq!(result[0].line, 1);
218        assert_eq!(result[1].line, 2);
219        assert_eq!(result[2].line, 3);
220
221        // Test with setext headings
222        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
223        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
224        let result = rule.check(&ctx).unwrap();
225        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
226        assert_eq!(result[0].line, 3);
227        assert_eq!(result[1].line, 4);
228    }
229
230    #[test]
231    fn test_issue_refs_skipped_but_real_headings_caught() {
232        let rule = MD023HeadingStartLeft;
233
234        // Issue refs should NOT be flagged (starts with number)
235        let content = "- fix: issue\n  #29039)";
236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
237        let result = rule.check(&ctx).unwrap();
238        assert!(
239            result.is_empty(),
240            "#29039) should not be flagged as indented heading. Got: {result:?}"
241        );
242
243        // Hashtags should NOT be flagged (starts with lowercase)
244        let content = "Some text\n  #hashtag";
245        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
246        let result = rule.check(&ctx).unwrap();
247        assert!(
248            result.is_empty(),
249            "#hashtag should not be flagged as indented heading. Got: {result:?}"
250        );
251
252        // But uppercase single-# SHOULD be flagged (likely intended heading)
253        let content = "Some text\n  #Summary";
254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
255        let result = rule.check(&ctx).unwrap();
256        assert_eq!(
257            result.len(),
258            1,
259            "#Summary SHOULD be flagged as indented heading. Got: {result:?}"
260        );
261
262        // Multi-hash patterns SHOULD always be flagged
263        let content = "Some text\n  ##introduction";
264        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
265        let result = rule.check(&ctx).unwrap();
266        assert_eq!(
267            result.len(),
268            1,
269            "##introduction SHOULD be flagged as indented heading. Got: {result:?}"
270        );
271
272        // Multi-hash with numbers SHOULD be flagged
273        let content = "Some text\n  ##123";
274        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
275        let result = rule.check(&ctx).unwrap();
276        assert_eq!(
277            result.len(),
278            1,
279            "##123 SHOULD be flagged as indented heading. Got: {result:?}"
280        );
281
282        // Properly aligned headings should pass
283        let content = "# Summary\n## Details";
284        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
285        let result = rule.check(&ctx).unwrap();
286        assert!(
287            result.is_empty(),
288            "Properly aligned headings should pass. Got: {result:?}"
289        );
290    }
291}