rumdl_lib/rules/
md019_no_multiple_space_atx.rs

1/// Rule MD019: No multiple spaces after ATX heading marker
2///
3/// See [docs/md019.md](../../docs/md019.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 MD019NoMultipleSpaceAtx;
9
10impl Default for MD019NoMultipleSpaceAtx {
11    fn default() -> Self {
12        Self::new()
13    }
14}
15
16impl MD019NoMultipleSpaceAtx {
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Count spaces after the ATX marker
22    fn count_spaces_after_marker(&self, line: &str, marker_len: usize) -> usize {
23        let after_marker = &line[marker_len..];
24        after_marker.chars().take_while(|c| *c == ' ' || *c == '\t').count()
25    }
26}
27
28impl Rule for MD019NoMultipleSpaceAtx {
29    fn name(&self) -> &'static str {
30        "MD019"
31    }
32
33    fn description(&self) -> &'static str {
34        "Multiple spaces after hash in heading"
35    }
36
37    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
38        let mut warnings = Vec::new();
39
40        // Create LineIndex once outside the loop
41        let line_index = LineIndex::new(ctx.content.to_string());
42
43        // Check all ATX headings from cached info
44        for (line_num, line_info) in ctx.lines.iter().enumerate() {
45            if let Some(heading) = &line_info.heading {
46                // Only check ATX headings
47                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
48                    let line = &line_info.content;
49                    let trimmed = line.trim_start();
50                    let marker_pos = line_info.indent + heading.marker.len();
51
52                    // Count spaces after marker
53                    if trimmed.len() > heading.marker.len() {
54                        let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
55
56                        if space_count > 1 {
57                            // Calculate range for the extra spaces
58                            let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
59                                line_num + 1,   // Convert to 1-indexed
60                                marker_pos + 1, // Start after marker (1-indexed)
61                                space_count,    // Length of all spaces (not just extra)
62                            );
63
64                            // Calculate byte range for just the extra spaces
65                            let line_start_byte = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
66
67                            // We need to work with the original line, not trimmed
68                            let original_line = &line_info.content;
69                            let marker_byte_pos = line_start_byte + line_info.indent + heading.marker.len();
70
71                            // Get the actual byte length of the spaces/tabs after the marker
72                            let after_marker_start = line_info.indent + heading.marker.len();
73                            let after_marker = &original_line[after_marker_start..];
74                            let space_bytes = after_marker
75                                .as_bytes()
76                                .iter()
77                                .take_while(|&&b| b == b' ' || b == b'\t')
78                                .count();
79
80                            let extra_spaces_start = marker_byte_pos;
81                            let extra_spaces_end = marker_byte_pos + space_bytes;
82
83                            warnings.push(LintWarning {
84                                rule_name: Some(self.name()),
85                                message: format!(
86                                    "Multiple spaces ({}) after {} in heading",
87                                    space_count,
88                                    "#".repeat(heading.level as usize)
89                                ),
90                                line: start_line,
91                                column: start_col,
92                                end_line,
93                                end_column: end_col,
94                                severity: Severity::Warning,
95                                fix: Some(Fix {
96                                    range: extra_spaces_start..extra_spaces_end,
97                                    replacement: " ".to_string(), // Replace extra spaces with single space
98                                }),
99                            });
100                        }
101                    }
102                }
103            }
104        }
105
106        Ok(warnings)
107    }
108
109    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
110        let mut lines = Vec::new();
111
112        for line_info in ctx.lines.iter() {
113            let mut fixed = false;
114
115            if let Some(heading) = &line_info.heading {
116                // Fix ATX headings with multiple spaces
117                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
118                    let line = &line_info.content;
119                    let trimmed = line.trim_start();
120
121                    if trimmed.len() > heading.marker.len() {
122                        let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
123
124                        if space_count > 1 {
125                            // Normalize to single space
126                            lines.push(format!(
127                                "{}{} {}",
128                                " ".repeat(line_info.indent),
129                                heading.marker,
130                                trimmed[heading.marker.len()..].trim_start()
131                            ));
132                            fixed = true;
133                        }
134                    }
135                }
136            }
137
138            if !fixed {
139                lines.push(line_info.content.clone());
140            }
141        }
142
143        // Reconstruct content preserving line endings
144        let mut result = lines.join("\n");
145        if ctx.content.ends_with('\n') && !result.ends_with('\n') {
146            result.push('\n');
147        }
148
149        Ok(result)
150    }
151
152    /// Get the category of this rule for selective processing
153    fn category(&self) -> RuleCategory {
154        RuleCategory::Heading
155    }
156
157    /// Check if this rule should be skipped
158    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
159        ctx.content.is_empty() || !ctx.content.contains('#')
160    }
161
162    fn as_any(&self) -> &dyn std::any::Any {
163        self
164    }
165
166    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
167        None
168    }
169
170    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
171    where
172        Self: Sized,
173    {
174        Box::new(MD019NoMultipleSpaceAtx::new())
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_basic_functionality() {
184        let rule = MD019NoMultipleSpaceAtx::new();
185
186        // Test with heading that has multiple spaces
187        let content = "#  Multiple Spaces\n\nRegular content\n\n##   More Spaces";
188        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
189        let result = rule.check(&ctx).unwrap();
190        assert_eq!(result.len(), 2); // Should flag both headings
191        assert_eq!(result[0].line, 1);
192        assert_eq!(result[1].line, 5);
193
194        // Test with proper headings
195        let content = "# Single Space\n\n## Also correct";
196        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
197        let result = rule.check(&ctx).unwrap();
198        assert!(
199            result.is_empty(),
200            "Properly formatted headings should not generate warnings"
201        );
202    }
203}