rumdl_lib/rules/
md019_no_multiple_space_atx.rs1use 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 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 let line_index = LineIndex::new(ctx.content.to_string());
42
43 for (line_num, line_info) in ctx.lines.iter().enumerate() {
45 if let Some(heading) = &line_info.heading {
46 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 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 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
59 line_num + 1, marker_pos + 1, space_count, );
63
64 let line_start_byte = line_index.get_line_start_byte(line_num + 1).unwrap_or(0);
66
67 let original_line = &line_info.content;
69 let marker_byte_pos = line_start_byte + line_info.indent + heading.marker.len();
70
71 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(), }),
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 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 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 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 fn category(&self) -> RuleCategory {
154 RuleCategory::Heading
155 }
156
157 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 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); assert_eq!(result[0].line, 1);
192 assert_eq!(result[1].line, 5);
193
194 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}