Skip to main content

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::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        // Check all ATX headings from cached info
41        for (line_num, line_info) in ctx.lines.iter().enumerate() {
42            let marker_byte = line_info.byte_offset + line_info.indent;
43            if ctx.overlaps_template_code(marker_byte, marker_byte + 1) {
44                continue;
45            }
46            if let Some(heading) = &line_info.heading {
47                // Only check ATX headings
48                if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
49                    let line = line_info.content(ctx.content);
50                    let trimmed = line.trim_start();
51                    let marker_pos = line_info.indent + heading.marker.len();
52
53                    // Count spaces after marker
54                    if trimmed.len() > heading.marker.len() {
55                        let space_count = self.count_spaces_after_marker(trimmed, heading.marker.len());
56
57                        if space_count > 1 {
58                            // Calculate range for the extra spaces
59                            let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
60                                line_num + 1,   // Convert to 1-indexed
61                                marker_pos + 1, // Start after marker (1-indexed)
62                                space_count,    // Length of all spaces (not just extra)
63                            );
64
65                            // Calculate byte range for just the extra spaces
66                            let line_start_byte = ctx.line_start_byte(line_num + 1).unwrap_or(0);
67
68                            // We need to work with the original line, not trimmed
69                            let original_line = line_info.content(ctx.content);
70                            let marker_byte_pos = line_start_byte + line_info.indent + heading.marker.len();
71
72                            // Get the actual byte length of the spaces/tabs after the marker
73                            let after_marker_start = line_info.indent + heading.marker.len();
74                            let after_marker = &original_line[after_marker_start..];
75                            let space_bytes = after_marker
76                                .as_bytes()
77                                .iter()
78                                .take_while(|&&b| b == b' ' || b == b'\t')
79                                .count();
80
81                            let extra_spaces_start = marker_byte_pos;
82                            let extra_spaces_end = marker_byte_pos + space_bytes;
83
84                            warnings.push(LintWarning {
85                                rule_name: Some(self.name().to_string()),
86                                message: format!(
87                                    "Multiple spaces ({}) after {} in heading",
88                                    space_count,
89                                    "#".repeat(heading.level as usize)
90                                ),
91                                line: start_line,
92                                column: start_col,
93                                end_line,
94                                end_column: end_col,
95                                severity: Severity::Warning,
96                                fix: Some(Fix::new(extra_spaces_start..extra_spaces_end, " ".to_string())),
97                            });
98                        }
99                    }
100                }
101            }
102        }
103
104        Ok(warnings)
105    }
106
107    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
108        if self.should_skip(ctx) {
109            return Ok(ctx.content.to_string());
110        }
111        let warnings = self.check(ctx)?;
112        if warnings.is_empty() {
113            return Ok(ctx.content.to_string());
114        }
115        let warnings =
116            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
117        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
118            .map_err(crate::rule::LintError::InvalidInput)
119    }
120
121    /// Get the category of this rule for selective processing
122    fn category(&self) -> RuleCategory {
123        RuleCategory::Heading
124    }
125
126    /// Check if this rule should be skipped
127    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
128        ctx.content.is_empty() || !ctx.likely_has_headings()
129    }
130
131    fn as_any(&self) -> &dyn std::any::Any {
132        self
133    }
134
135    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
136    where
137        Self: Sized,
138    {
139        Box::new(MD019NoMultipleSpaceAtx::new())
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn test_basic_functionality() {
149        let rule = MD019NoMultipleSpaceAtx::new();
150
151        // Test with heading that has multiple spaces
152        let content = "#  Multiple Spaces\n\nRegular content\n\n##   More Spaces";
153        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
154        let result = rule.check(&ctx).unwrap();
155        assert_eq!(result.len(), 2); // Should flag both headings
156        assert_eq!(result[0].line, 1);
157        assert_eq!(result[1].line, 5);
158
159        // Test with proper headings
160        let content = "# Single Space\n\n## Also correct";
161        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
162        let result = rule.check(&ctx).unwrap();
163        assert!(
164            result.is_empty(),
165            "Properly formatted headings should not generate warnings"
166        );
167    }
168}