1use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::utils::skip_context::is_table_line;
31use std::sync::Arc;
32
33use regex::Regex;
35use std::sync::LazyLock;
36
37static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
38 Regex::new(r" {2,}").unwrap()
40});
41
42#[derive(Debug, Clone, Default)]
43pub struct MD064NoMultipleConsecutiveSpaces;
44
45impl MD064NoMultipleConsecutiveSpaces {
46 pub fn new() -> Self {
47 Self
48 }
49
50 fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
52 code_spans
53 .iter()
54 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
55 }
56
57 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
60 let remaining = &line[match_end..];
62 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
63 }
64
65 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
67 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
69 }
70
71 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
73 let before = line[..match_start].trim_start();
74
75 if before == "*" || before == "-" || before == "+" {
77 return true;
78 }
79
80 if before.len() >= 2 {
83 let last_char = before.chars().last().unwrap();
84 if last_char == '.' || last_char == ')' {
85 let prefix = &before[..before.len() - 1];
86 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
87 return true;
88 }
89 }
90 }
91
92 false
93 }
94
95 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
98 let before = line[..match_start].trim_start();
99
100 if before.is_empty() {
102 return false;
103 }
104
105 let trimmed = before.trim_end();
107 if trimmed.chars().all(|c| c == '>') {
108 return true;
109 }
110
111 if trimmed.ends_with('>') {
113 let inner = trimmed.trim_end_matches('>').trim();
114 if inner.is_empty() || inner.chars().all(|c| c == '>') {
115 return true;
116 }
117 }
118
119 false
120 }
121
122 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
126 space_count >= 4 && space_count.is_multiple_of(4)
127 }
128
129 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
132 let trimmed = line.trim_start();
133
134 if trimmed.starts_with('[')
136 && let Some(bracket_end) = trimmed.find("]:")
137 {
138 let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
139 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
141 return true;
142 }
143 }
144
145 false
146 }
147
148 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
151 let trimmed = line.trim_start();
152
153 if trimmed.starts_with("[^")
155 && let Some(bracket_end) = trimmed.find("]:")
156 {
157 let leading_spaces = line.len() - trimmed.len();
158 let colon_pos = leading_spaces + bracket_end + 2;
159 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
161 return true;
162 }
163 }
164
165 false
166 }
167
168 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
171 let before = line[..match_start].trim_start();
172
173 before == ":"
175 }
176
177 fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
180 let before = line[..match_start].trim_start();
181
182 if before.len() >= 4 {
185 let patterns = [
186 "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
187 ];
188 for pattern in patterns {
189 if before == pattern {
190 return true;
191 }
192 }
193 }
194
195 false
196 }
197
198 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
201 let trimmed = line.trim();
202
203 if !trimmed.contains('|') {
205 return false;
206 }
207
208 if trimmed.starts_with('|') || trimmed.ends_with('|') {
210 return false;
211 }
212
213 let parts: Vec<&str> = trimmed.split('|').collect();
217 if parts.len() >= 2 {
218 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
221 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
222 if first_has_content || last_has_content {
223 return true;
224 }
225 }
226
227 false
228 }
229}
230
231impl Rule for MD064NoMultipleConsecutiveSpaces {
232 fn name(&self) -> &'static str {
233 "MD064"
234 }
235
236 fn description(&self) -> &'static str {
237 "Multiple consecutive spaces"
238 }
239
240 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
241 let content = ctx.content;
242
243 if !content.contains(" ") {
245 return Ok(vec![]);
246 }
247
248 let mut warnings = Vec::new();
249 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
250 let line_index = &ctx.line_index;
251
252 for line in ctx
254 .filtered_lines()
255 .skip_front_matter()
256 .skip_code_blocks()
257 .skip_html_blocks()
258 .skip_html_comments()
259 .skip_mkdocstrings()
260 .skip_esm_blocks()
261 {
262 if !line.content.contains(" ") {
264 continue;
265 }
266
267 if is_table_line(line.content) {
269 continue;
270 }
271
272 if self.is_table_without_outer_pipes(line.content) {
274 continue;
275 }
276
277 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
278
279 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
281 let match_start = mat.start();
282 let match_end = mat.end();
283 let space_count = match_end - match_start;
284
285 if self.is_leading_indentation(line.content, match_start) {
287 continue;
288 }
289
290 if self.is_trailing_whitespace(line.content, match_end) {
292 continue;
293 }
294
295 if self.is_tab_replacement_pattern(space_count) {
298 continue;
299 }
300
301 if self.is_after_list_marker(line.content, match_start) {
303 continue;
304 }
305
306 if self.is_after_blockquote_marker(line.content, match_start) {
308 continue;
309 }
310
311 if self.is_after_footnote_marker(line.content, match_start) {
313 continue;
314 }
315
316 if self.is_reference_link_definition(line.content, match_start) {
318 continue;
319 }
320
321 if self.is_after_definition_marker(line.content, match_start) {
323 continue;
324 }
325
326 if self.is_after_task_checkbox(line.content, match_start) {
328 continue;
329 }
330
331 let abs_byte_start = line_start_byte + match_start;
333
334 if self.is_in_code_span(&code_spans, abs_byte_start) {
336 continue;
337 }
338
339 let abs_byte_end = line_start_byte + match_end;
341
342 warnings.push(LintWarning {
343 rule_name: Some(self.name().to_string()),
344 message: format!("Multiple consecutive spaces ({space_count}) found"),
345 line: line.line_num,
346 column: match_start + 1, end_line: line.line_num,
348 end_column: match_end + 1, severity: Severity::Warning,
350 fix: Some(Fix {
351 range: abs_byte_start..abs_byte_end,
352 replacement: " ".to_string(), }),
354 });
355 }
356 }
357
358 Ok(warnings)
359 }
360
361 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
362 let content = ctx.content;
363
364 if !content.contains(" ") {
366 return Ok(content.to_string());
367 }
368
369 let warnings = self.check(ctx)?;
371 if warnings.is_empty() {
372 return Ok(content.to_string());
373 }
374
375 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
377 .into_iter()
378 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
379 .collect();
380
381 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
382
383 let mut result = content.to_string();
385 for (range, replacement) in fixes {
386 if range.start < result.len() && range.end <= result.len() {
387 result.replace_range(range, &replacement);
388 }
389 }
390
391 Ok(result)
392 }
393
394 fn category(&self) -> RuleCategory {
396 RuleCategory::Whitespace
397 }
398
399 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
401 ctx.content.is_empty() || !ctx.content.contains(" ")
402 }
403
404 fn as_any(&self) -> &dyn std::any::Any {
405 self
406 }
407
408 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
409 where
410 Self: Sized,
411 {
412 Box::new(MD064NoMultipleConsecutiveSpaces::new())
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use crate::lint_context::LintContext;
420
421 #[test]
422 fn test_basic_multiple_spaces() {
423 let rule = MD064NoMultipleConsecutiveSpaces::new();
424
425 let content = "This is a sentence with extra spaces.";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429 assert_eq!(result.len(), 1);
430 assert_eq!(result[0].line, 1);
431 assert_eq!(result[0].column, 8); }
433
434 #[test]
435 fn test_no_issues_single_spaces() {
436 let rule = MD064NoMultipleConsecutiveSpaces::new();
437
438 let content = "This is a normal sentence with single spaces.";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441 let result = rule.check(&ctx).unwrap();
442 assert!(result.is_empty());
443 }
444
445 #[test]
446 fn test_skip_inline_code() {
447 let rule = MD064NoMultipleConsecutiveSpaces::new();
448
449 let content = "Use `code with spaces` for formatting.";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert!(result.is_empty());
454 }
455
456 #[test]
457 fn test_skip_code_blocks() {
458 let rule = MD064NoMultipleConsecutiveSpaces::new();
459
460 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert!(result.is_empty());
465 }
466
467 #[test]
468 fn test_skip_leading_indentation() {
469 let rule = MD064NoMultipleConsecutiveSpaces::new();
470
471 let content = " This is indented text.";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert!(result.is_empty());
476 }
477
478 #[test]
479 fn test_skip_trailing_spaces() {
480 let rule = MD064NoMultipleConsecutiveSpaces::new();
481
482 let content = "Line with trailing spaces \nNext line.";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert!(result.is_empty());
487 }
488
489 #[test]
490 fn test_skip_all_trailing_spaces() {
491 let rule = MD064NoMultipleConsecutiveSpaces::new();
492
493 let content = "Two spaces \nThree spaces \nFour spaces \n";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(result.is_empty());
498 }
499
500 #[test]
501 fn test_skip_front_matter() {
502 let rule = MD064NoMultipleConsecutiveSpaces::new();
503
504 let content = "---\ntitle: Test Title\n---\n\nContent here.";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507 let result = rule.check(&ctx).unwrap();
508 assert!(result.is_empty());
509 }
510
511 #[test]
512 fn test_skip_html_comments() {
513 let rule = MD064NoMultipleConsecutiveSpaces::new();
514
515 let content = "<!-- comment with spaces -->\n\nContent here.";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519 assert!(result.is_empty());
520 }
521
522 #[test]
523 fn test_multiple_issues_one_line() {
524 let rule = MD064NoMultipleConsecutiveSpaces::new();
525
526 let content = "This has multiple issues.";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
531 }
532
533 #[test]
534 fn test_fix_collapses_spaces() {
535 let rule = MD064NoMultipleConsecutiveSpaces::new();
536
537 let content = "This is a sentence with extra spaces.";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let fixed = rule.fix(&ctx).unwrap();
540 assert_eq!(fixed, "This is a sentence with extra spaces.");
541 }
542
543 #[test]
544 fn test_fix_preserves_inline_code() {
545 let rule = MD064NoMultipleConsecutiveSpaces::new();
546
547 let content = "Text here `code inside` and more.";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let fixed = rule.fix(&ctx).unwrap();
550 assert_eq!(fixed, "Text here `code inside` and more.");
551 }
552
553 #[test]
554 fn test_fix_preserves_trailing_spaces() {
555 let rule = MD064NoMultipleConsecutiveSpaces::new();
556
557 let content = "Line with extra and trailing \nNext line.";
559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560 let fixed = rule.fix(&ctx).unwrap();
561 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
563 }
564
565 #[test]
566 fn test_list_items_with_extra_spaces() {
567 let rule = MD064NoMultipleConsecutiveSpaces::new();
568
569 let content = "- Item one\n- Item two\n";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let result = rule.check(&ctx).unwrap();
572 assert_eq!(result.len(), 2, "Should flag spaces in list items");
573 }
574
575 #[test]
576 fn test_blockquote_with_extra_spaces_in_content() {
577 let rule = MD064NoMultipleConsecutiveSpaces::new();
578
579 let content = "> Quote with extra spaces\n";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
584 }
585
586 #[test]
587 fn test_skip_blockquote_marker_spaces() {
588 let rule = MD064NoMultipleConsecutiveSpaces::new();
589
590 let content = "> Text with extra space after marker\n";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert!(result.is_empty());
595
596 let content = "> Text with three spaces after marker\n";
598 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599 let result = rule.check(&ctx).unwrap();
600 assert!(result.is_empty());
601
602 let content = ">> Nested blockquote\n";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert!(result.is_empty());
607 }
608
609 #[test]
610 fn test_mixed_content() {
611 let rule = MD064NoMultipleConsecutiveSpaces::new();
612
613 let content = r#"# Heading
614
615This has extra spaces.
616
617```
618code here is fine
619```
620
621- List item
622
623> Quote text
624
625Normal paragraph.
626"#;
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
631 }
632
633 #[test]
634 fn test_multibyte_utf8() {
635 let rule = MD064NoMultipleConsecutiveSpaces::new();
636
637 let content = "日本語 テスト 文字列";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640 let result = rule.check(&ctx);
641 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
642
643 let warnings = result.unwrap();
644 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
645 }
646
647 #[test]
648 fn test_table_rows_skipped() {
649 let rule = MD064NoMultipleConsecutiveSpaces::new();
650
651 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert!(result.is_empty());
657 }
658
659 #[test]
660 fn test_link_text_with_extra_spaces() {
661 let rule = MD064NoMultipleConsecutiveSpaces::new();
662
663 let content = "[Link text](https://example.com)";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
668 }
669
670 #[test]
671 fn test_image_alt_with_extra_spaces() {
672 let rule = MD064NoMultipleConsecutiveSpaces::new();
673
674 let content = "";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
679 }
680
681 #[test]
682 fn test_skip_list_marker_spaces() {
683 let rule = MD064NoMultipleConsecutiveSpaces::new();
684
685 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert!(result.is_empty());
690
691 let content = "1. Item one\n2. Item two\n10. Item ten\n";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let result = rule.check(&ctx).unwrap();
695 assert!(result.is_empty());
696
697 let content = " * Indented item\n 1. Nested numbered item\n";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(result.is_empty());
702 }
703
704 #[test]
705 fn test_flag_spaces_in_list_content() {
706 let rule = MD064NoMultipleConsecutiveSpaces::new();
707
708 let content = "* Item with extra spaces in content\n";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let result = rule.check(&ctx).unwrap();
712 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
713 }
714
715 #[test]
716 fn test_skip_reference_link_definition_spaces() {
717 let rule = MD064NoMultipleConsecutiveSpaces::new();
718
719 let content = "[ref]: https://example.com\n";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723 assert!(result.is_empty());
724
725 let content = "[reference-link]: https://example.com \"Title\"\n";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729 assert!(result.is_empty());
730 }
731
732 #[test]
733 fn test_skip_footnote_marker_spaces() {
734 let rule = MD064NoMultipleConsecutiveSpaces::new();
735
736 let content = "[^1]: Footnote with extra space\n";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert!(result.is_empty());
741
742 let content = "[^footnote-label]: This is the footnote text.\n";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert!(result.is_empty());
747 }
748
749 #[test]
750 fn test_skip_definition_list_marker_spaces() {
751 let rule = MD064NoMultipleConsecutiveSpaces::new();
752
753 let content = "Term\n: Definition with extra spaces\n";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(result.is_empty());
758
759 let content = ": Another definition\n";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(result.is_empty());
764 }
765
766 #[test]
767 fn test_skip_task_list_checkbox_spaces() {
768 let rule = MD064NoMultipleConsecutiveSpaces::new();
769
770 let content = "- [ ] Task with extra space\n";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(result.is_empty());
775
776 let content = "- [x] Completed task\n";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert!(result.is_empty());
781
782 let content = "* [ ] Task with asterisk marker\n";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert!(result.is_empty());
787 }
788
789 #[test]
790 fn test_skip_table_without_outer_pipes() {
791 let rule = MD064NoMultipleConsecutiveSpaces::new();
792
793 let content = "Col1 | Col2 | Col3\n";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797 assert!(result.is_empty());
798
799 let content = "--------- | --------- | ---------\n";
801 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802 let result = rule.check(&ctx).unwrap();
803 assert!(result.is_empty());
804
805 let content = "Data1 | Data2 | Data3\n";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert!(result.is_empty());
810 }
811
812 #[test]
813 fn test_flag_spaces_in_footnote_content() {
814 let rule = MD064NoMultipleConsecutiveSpaces::new();
815
816 let content = "[^1]: Footnote with extra spaces in content.\n";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
821 }
822
823 #[test]
824 fn test_flag_spaces_in_reference_content() {
825 let rule = MD064NoMultipleConsecutiveSpaces::new();
826
827 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let result = rule.check(&ctx).unwrap();
831 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
832 }
833}