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