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                let indentation = line_info.indent;
31
32                // If the heading is indented, add a warning
33                if indentation > 0 {
34                    let is_setext = matches!(
35                        heading.style,
36                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
37                    );
38
39                    if is_setext {
40                        // For Setext headings, we need to fix both the heading text and underline
41                        let underline_line = line_num + 1;
42
43                        // Calculate precise character range for the indentation
44                        let (start_line_calc, start_col, end_line, end_col) = calculate_single_line_range(
45                            line_num + 1, // Convert to 1-indexed
46                            1,
47                            indentation,
48                        );
49
50                        // Add warning for the heading text line
51                        warnings.push(LintWarning {
52                            rule_name: Some(self.name().to_string()),
53                            line: start_line_calc,
54                            column: start_col,
55                            end_line,
56                            end_column: end_col,
57                            severity: Severity::Warning,
58                            message: format!("Setext heading should not be indented by {indentation} spaces"),
59                            fix: Some(Fix {
60                                range: ctx.line_index.line_col_to_byte_range_with_length(
61                                    line_num + 1,
62                                    start_col,
63                                    indentation,
64                                ),
65                                replacement: String::new(), // Remove the indentation
66                            }),
67                        });
68
69                        // Add warning for the underline - only if it's indented
70                        if underline_line < ctx.lines.len() {
71                            let underline_indentation = ctx.lines[underline_line].indent;
72                            if underline_indentation > 0 {
73                                // Calculate precise character range for the underline indentation
74                                let (underline_start_line, underline_start_col, underline_end_line, underline_end_col) =
75                                    calculate_single_line_range(
76                                        underline_line + 1, // Convert to 1-indexed
77                                        1,
78                                        underline_indentation,
79                                    );
80
81                                warnings.push(LintWarning {
82                                    rule_name: Some(self.name().to_string()),
83                                    line: underline_start_line,
84                                    column: underline_start_col,
85                                    end_line: underline_end_line,
86                                    end_column: underline_end_col,
87                                    severity: Severity::Warning,
88                                    message: "Setext heading underline should not be indented".to_string(),
89                                    fix: Some(Fix {
90                                        range: ctx.line_index.line_col_to_byte_range_with_length(
91                                            underline_line + 1,
92                                            underline_start_col,
93                                            underline_indentation,
94                                        ),
95                                        replacement: String::new(), // Remove the indentation
96                                    }),
97                                });
98                            }
99                        }
100                    } else {
101                        // For ATX headings, just fix the single line
102
103                        // Calculate precise character range for the indentation
104                        let (atx_start_line, atx_start_col, atx_end_line, atx_end_col) = calculate_single_line_range(
105                            line_num + 1, // Convert to 1-indexed
106                            1,
107                            indentation,
108                        );
109
110                        warnings.push(LintWarning {
111                            rule_name: Some(self.name().to_string()),
112                            line: atx_start_line,
113                            column: atx_start_col,
114                            end_line: atx_end_line,
115                            end_column: atx_end_col,
116                            severity: Severity::Warning,
117                            message: format!("Heading should not be indented by {indentation} spaces"),
118                            fix: Some(Fix {
119                                range: ctx.line_index.line_col_to_byte_range_with_length(
120                                    line_num + 1,
121                                    atx_start_col,
122                                    indentation,
123                                ),
124                                replacement: String::new(), // Remove the indentation
125                            }),
126                        });
127                    }
128                }
129            }
130        }
131
132        Ok(warnings)
133    }
134
135    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
136        let mut fixed_lines = Vec::new();
137        let mut skip_next = false;
138
139        for (i, line_info) in ctx.lines.iter().enumerate() {
140            if skip_next {
141                skip_next = false;
142                continue;
143            }
144
145            // Check if this line is a heading
146            if let Some(heading) = &line_info.heading {
147                let indentation = line_info.indent;
148                let is_setext = matches!(
149                    heading.style,
150                    crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
151                );
152
153                if indentation > 0 {
154                    // This heading needs to be fixed
155                    if is_setext {
156                        // For Setext headings, add the heading text without indentation
157                        fixed_lines.push(line_info.content(ctx.content).trim().to_string());
158                        // Then add the underline without indentation
159                        if i + 1 < ctx.lines.len() {
160                            fixed_lines.push(ctx.lines[i + 1].content(ctx.content).trim().to_string());
161                            skip_next = true;
162                        }
163                    } else {
164                        // For ATX headings, simply trim the indentation
165                        fixed_lines.push(line_info.content(ctx.content).trim_start().to_string());
166                    }
167                } else {
168                    // This heading is already at the beginning of the line
169                    fixed_lines.push(line_info.content(ctx.content).to_string());
170                    if is_setext && i + 1 < ctx.lines.len() {
171                        fixed_lines.push(ctx.lines[i + 1].content(ctx.content).to_string());
172                        skip_next = true;
173                    }
174                }
175            } else {
176                // Not a heading, copy as-is
177                fixed_lines.push(line_info.content(ctx.content).to_string());
178            }
179        }
180
181        let result = fixed_lines.join("\n");
182        if ctx.content.ends_with('\n') {
183            Ok(result + "\n")
184        } else {
185            Ok(result)
186        }
187    }
188
189    /// Get the category of this rule for selective processing
190    fn category(&self) -> RuleCategory {
191        RuleCategory::Heading
192    }
193
194    /// Check if this rule should be skipped
195    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
196        // Fast path: check if document likely has headings
197        if !ctx.likely_has_headings() {
198            return true;
199        }
200        // Verify headings actually exist
201        ctx.lines.iter().all(|line| line.heading.is_none())
202    }
203
204    fn as_any(&self) -> &dyn std::any::Any {
205        self
206    }
207
208    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
209    where
210        Self: Sized,
211    {
212        Box::new(MD023HeadingStartLeft)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::lint_context::LintContext;
220    #[test]
221    fn test_basic_functionality() {
222        let rule = MD023HeadingStartLeft;
223
224        // Test with properly aligned headings
225        let content = "# Heading 1\n## Heading 2\n### Heading 3";
226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227        let result = rule.check(&ctx).unwrap();
228        assert!(result.is_empty());
229
230        // Test with indented headings
231        let content = "  # Heading 1\n ## Heading 2\n   ### Heading 3";
232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233        let result = rule.check(&ctx).unwrap();
234        assert_eq!(result.len(), 3); // Should flag all three indented headings
235        assert_eq!(result[0].line, 1);
236        assert_eq!(result[1].line, 2);
237        assert_eq!(result[2].line, 3);
238
239        // Test with setext headings
240        let content = "Heading 1\n=========\n  Heading 2\n  ---------";
241        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
242        let result = rule.check(&ctx).unwrap();
243        assert_eq!(result.len(), 2); // Should flag the indented heading and underline
244        assert_eq!(result[0].line, 3);
245        assert_eq!(result[1].line, 4);
246    }
247}