rumdl_lib/rules/
md018_no_missing_space_atx.rs1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::calculate_single_line_range;
6use lazy_static::lazy_static;
7use regex::Regex;
8
9lazy_static! {
10 static ref EMOJI_HASHTAG_PATTERN: Regex = Regex::new(r"^#️⃣|^#⃣").unwrap();
12
13 static ref UNICODE_HASHTAG_PATTERN: Regex = Regex::new(r"^#[\u{FE0F}\u{20E3}]").unwrap();
15}
16
17#[derive(Clone)]
18pub struct MD018NoMissingSpaceAtx;
19
20impl Default for MD018NoMissingSpaceAtx {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl MD018NoMissingSpaceAtx {
27 pub fn new() -> Self {
28 Self
29 }
30
31 fn check_atx_heading_line(&self, line: &str) -> Option<(usize, String)> {
33 let trimmed_line = line.trim_start();
35 let indent = line.len() - trimmed_line.len();
36
37 if !trimmed_line.starts_with('#') {
38 return None;
39 }
40
41 if EMOJI_HASHTAG_PATTERN.is_match(trimmed_line) || UNICODE_HASHTAG_PATTERN.is_match(trimmed_line) {
43 return None;
44 }
45
46 let hash_count = trimmed_line.chars().take_while(|&c| c == '#').count();
48 if hash_count == 0 || hash_count > 6 {
49 return None;
50 }
51
52 let after_hashes = &trimmed_line[hash_count..];
54
55 if after_hashes
57 .chars()
58 .next()
59 .is_some_and(|ch| matches!(ch, '\u{FE0F}' | '\u{20E3}' | '\u{FE0E}'))
60 {
61 return None;
62 }
63
64 if !after_hashes.is_empty() && !after_hashes.starts_with(' ') && !after_hashes.starts_with('\t') {
66 let content = after_hashes.trim();
68
69 if content.chars().all(|c| c == '#') {
71 return None;
72 }
73
74 if content.len() < 2 {
76 return None;
77 }
78
79 if content.starts_with('*') || content.starts_with('_') {
81 return None;
82 }
83
84 if hash_count == 1 && !content.is_empty() {
87 let first_char = content.chars().next();
88 if let Some(ch) = first_char {
89 if (ch.is_lowercase() || ch.is_numeric()) && !content.contains(' ') {
92 return None;
93 }
94 }
95 }
96
97 let fixed = format!("{}{} {}", " ".repeat(indent), "#".repeat(hash_count), after_hashes);
99 return Some((indent + hash_count, fixed));
100 }
101
102 None
103 }
104
105 fn get_line_byte_range(&self, content: &str, line_num: usize) -> std::ops::Range<usize> {
107 let mut current_line = 1;
108 let mut start_byte = 0;
109
110 for (i, c) in content.char_indices() {
111 if current_line == line_num && c == '\n' {
112 return start_byte..i;
113 } else if c == '\n' {
114 current_line += 1;
115 if current_line == line_num {
116 start_byte = i + 1;
117 }
118 }
119 }
120
121 if current_line == line_num {
123 return start_byte..content.len();
124 }
125
126 0..0
128 }
129}
130
131impl Rule for MD018NoMissingSpaceAtx {
132 fn name(&self) -> &'static str {
133 "MD018"
134 }
135
136 fn description(&self) -> &'static str {
137 "No space after hash in heading"
138 }
139
140 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
141 let mut warnings = Vec::new();
142
143 for (line_num, line_info) in ctx.lines.iter().enumerate() {
145 if let Some(heading) = &line_info.heading {
146 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
148 let line = &line_info.content;
150 let trimmed = line.trim_start();
151
152 if EMOJI_HASHTAG_PATTERN.is_match(trimmed) || UNICODE_HASHTAG_PATTERN.is_match(trimmed) {
154 continue;
155 }
156
157 if trimmed.len() > heading.marker.len() {
158 let after_marker = &trimmed[heading.marker.len()..];
159 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
160 {
161 let hash_end_col = line_info.indent + heading.marker.len() + 1; let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
164 line_num + 1, hash_end_col,
166 0, );
168
169 warnings.push(LintWarning {
170 rule_name: Some(self.name()),
171 message: format!("No space after {} in heading", "#".repeat(heading.level as usize)),
172 line: start_line,
173 column: start_col,
174 end_line,
175 end_column: end_col,
176 severity: Severity::Warning,
177 fix: Some(Fix {
178 range: self.get_line_byte_range(ctx.content, line_num + 1),
179 replacement: format!(
180 "{}{} {}",
181 " ".repeat(line_info.indent),
182 heading.marker,
183 after_marker
184 ),
185 }),
186 });
187 }
188 }
189 }
190 } else if !line_info.in_code_block && !line_info.is_blank {
191 if let Some((hash_end_pos, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
193 let (start_line, start_col, end_line, end_col) = calculate_single_line_range(
194 line_num + 1, hash_end_pos + 1, 0, );
198
199 warnings.push(LintWarning {
200 rule_name: Some(self.name()),
201 message: "No space after hash in heading".to_string(),
202 line: start_line,
203 column: start_col,
204 end_line,
205 end_column: end_col,
206 severity: Severity::Warning,
207 fix: Some(Fix {
208 range: self.get_line_byte_range(ctx.content, line_num + 1),
209 replacement: fixed_line,
210 }),
211 });
212 }
213 }
214 }
215
216 Ok(warnings)
217 }
218
219 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
220 let mut lines = Vec::new();
221
222 for line_info in ctx.lines.iter() {
223 let mut fixed = false;
224
225 if let Some(heading) = &line_info.heading {
226 if matches!(heading.style, crate::lint_context::HeadingStyle::ATX) {
228 let line = &line_info.content;
229 let trimmed = line.trim_start();
230
231 if EMOJI_HASHTAG_PATTERN.is_match(trimmed) || UNICODE_HASHTAG_PATTERN.is_match(trimmed) {
233 continue;
234 }
235
236 if trimmed.len() > heading.marker.len() {
237 let after_marker = &trimmed[heading.marker.len()..];
238 if !after_marker.is_empty() && !after_marker.starts_with(' ') && !after_marker.starts_with('\t')
239 {
240 lines.push(format!(
242 "{}{} {}",
243 " ".repeat(line_info.indent),
244 heading.marker,
245 after_marker
246 ));
247 fixed = true;
248 }
249 }
250 }
251 } else if !line_info.in_code_block && !line_info.is_blank {
252 if let Some((_, fixed_line)) = self.check_atx_heading_line(&line_info.content) {
254 lines.push(fixed_line);
255 fixed = true;
256 }
257 }
258
259 if !fixed {
260 lines.push(line_info.content.clone());
261 }
262 }
263
264 let mut result = lines.join("\n");
266 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
267 result.push('\n');
268 }
269
270 Ok(result)
271 }
272
273 fn category(&self) -> RuleCategory {
275 RuleCategory::Heading
276 }
277
278 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
280 !ctx.lines.iter().any(|line| line.content.contains('#'))
282 }
283
284 fn as_any(&self) -> &dyn std::any::Any {
285 self
286 }
287
288 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
289 None
290 }
291
292 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
293 where
294 Self: Sized,
295 {
296 Box::new(MD018NoMissingSpaceAtx::new())
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::lint_context::LintContext;
304
305 #[test]
306 fn test_basic_functionality() {
307 let rule = MD018NoMissingSpaceAtx;
308
309 let content = "# Heading 1\n## Heading 2\n### Heading 3";
311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
312 let result = rule.check(&ctx).unwrap();
313 assert!(result.is_empty());
314
315 let content = "#Heading 1\n## Heading 2\n###Heading 3";
317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
318 let result = rule.check(&ctx).unwrap();
319 assert_eq!(result.len(), 2); assert_eq!(result[0].line, 1);
321 assert_eq!(result[1].line, 3);
322 }
323
324 #[test]
325 fn test_malformed_heading_detection() {
326 let rule = MD018NoMissingSpaceAtx::new();
327
328 assert!(rule.check_atx_heading_line("##Introduction").is_some());
330 assert!(rule.check_atx_heading_line("###Background").is_some());
331 assert!(rule.check_atx_heading_line("####Details").is_some());
332 assert!(rule.check_atx_heading_line("#Summary").is_some());
333 assert!(rule.check_atx_heading_line("######Conclusion").is_some());
334 assert!(rule.check_atx_heading_line("##Table of Contents").is_some());
335
336 assert!(rule.check_atx_heading_line("###").is_none()); assert!(rule.check_atx_heading_line("#").is_none()); assert!(rule.check_atx_heading_line("##a").is_none()); assert!(rule.check_atx_heading_line("#*emphasis").is_none()); assert!(rule.check_atx_heading_line("#######TooBig").is_none()); }
343
344 #[test]
345 fn test_malformed_heading_with_context() {
346 let rule = MD018NoMissingSpaceAtx::new();
347
348 let content = r#"# Test Document
350
351##Introduction
352This should be detected.
353
354 ##CodeBlock
355This should NOT be detected (indented code block).
356
357```
358##FencedCodeBlock
359This should NOT be detected (fenced code block).
360```
361
362##Conclusion
363This should be detected.
364"#;
365
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
367 let result = rule.check(&ctx).unwrap();
368
369 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
371 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&14)); assert!(!detected_lines.contains(&6)); assert!(!detected_lines.contains(&10)); }
376
377 #[test]
378 fn test_malformed_heading_fix() {
379 let rule = MD018NoMissingSpaceAtx::new();
380
381 let content = r#"##Introduction
382This is a test.
383
384###Background
385More content."#;
386
387 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
388 let fixed = rule.fix(&ctx).unwrap();
389
390 let expected = r#"## Introduction
391This is a test.
392
393### Background
394More content."#;
395
396 assert_eq!(fixed, expected);
397 }
398
399 #[test]
400 fn test_mixed_proper_and_malformed_headings() {
401 let rule = MD018NoMissingSpaceAtx::new();
402
403 let content = r#"# Proper Heading
404
405##Malformed Heading
406
407## Another Proper Heading
408
409###Another Malformed
410
411#### Proper with space
412"#;
413
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
415 let result = rule.check(&ctx).unwrap();
416
417 assert_eq!(result.len(), 2);
419 let detected_lines: Vec<usize> = result.iter().map(|w| w.line).collect();
420 assert!(detected_lines.contains(&3)); assert!(detected_lines.contains(&7)); }
423}