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