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_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
125 let trimmed = line.trim_start();
126
127 if trimmed.starts_with('[')
129 && let Some(bracket_end) = trimmed.find("]:")
130 {
131 let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
132 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
134 return true;
135 }
136 }
137
138 false
139 }
140
141 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
144 let trimmed = line.trim_start();
145
146 if trimmed.starts_with("[^")
148 && let Some(bracket_end) = trimmed.find("]:")
149 {
150 let leading_spaces = line.len() - trimmed.len();
151 let colon_pos = leading_spaces + bracket_end + 2;
152 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
154 return true;
155 }
156 }
157
158 false
159 }
160
161 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
164 let before = line[..match_start].trim_start();
165
166 before == ":"
168 }
169
170 fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
173 let before = line[..match_start].trim_start();
174
175 if before.len() >= 4 {
178 let patterns = [
179 "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
180 ];
181 for pattern in patterns {
182 if before == pattern {
183 return true;
184 }
185 }
186 }
187
188 false
189 }
190
191 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
194 let trimmed = line.trim();
195
196 if !trimmed.contains('|') {
198 return false;
199 }
200
201 if trimmed.starts_with('|') || trimmed.ends_with('|') {
203 return false;
204 }
205
206 let parts: Vec<&str> = trimmed.split('|').collect();
210 if parts.len() >= 2 {
211 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
214 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
215 if first_has_content || last_has_content {
216 return true;
217 }
218 }
219
220 false
221 }
222}
223
224impl Rule for MD064NoMultipleConsecutiveSpaces {
225 fn name(&self) -> &'static str {
226 "MD064"
227 }
228
229 fn description(&self) -> &'static str {
230 "Multiple consecutive spaces"
231 }
232
233 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234 let content = ctx.content;
235
236 if !content.contains(" ") {
238 return Ok(vec![]);
239 }
240
241 let mut warnings = Vec::new();
242 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
243 let line_index = &ctx.line_index;
244
245 for line in ctx
247 .filtered_lines()
248 .skip_front_matter()
249 .skip_code_blocks()
250 .skip_html_blocks()
251 .skip_html_comments()
252 .skip_mkdocstrings()
253 .skip_esm_blocks()
254 {
255 if !line.content.contains(" ") {
257 continue;
258 }
259
260 if is_table_line(line.content) {
262 continue;
263 }
264
265 if self.is_table_without_outer_pipes(line.content) {
267 continue;
268 }
269
270 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
271
272 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
274 let match_start = mat.start();
275 let match_end = mat.end();
276 let space_count = match_end - match_start;
277
278 if self.is_leading_indentation(line.content, match_start) {
280 continue;
281 }
282
283 if self.is_trailing_whitespace(line.content, match_end) {
285 continue;
286 }
287
288 if self.is_after_list_marker(line.content, match_start) {
290 continue;
291 }
292
293 if self.is_after_blockquote_marker(line.content, match_start) {
295 continue;
296 }
297
298 if self.is_after_footnote_marker(line.content, match_start) {
300 continue;
301 }
302
303 if self.is_reference_link_definition(line.content, match_start) {
305 continue;
306 }
307
308 if self.is_after_definition_marker(line.content, match_start) {
310 continue;
311 }
312
313 if self.is_after_task_checkbox(line.content, match_start) {
315 continue;
316 }
317
318 let abs_byte_start = line_start_byte + match_start;
320
321 if self.is_in_code_span(&code_spans, abs_byte_start) {
323 continue;
324 }
325
326 let abs_byte_end = line_start_byte + match_end;
328
329 warnings.push(LintWarning {
330 rule_name: Some(self.name().to_string()),
331 message: format!("Multiple consecutive spaces ({space_count}) found"),
332 line: line.line_num,
333 column: match_start + 1, end_line: line.line_num,
335 end_column: match_end + 1, severity: Severity::Warning,
337 fix: Some(Fix {
338 range: abs_byte_start..abs_byte_end,
339 replacement: " ".to_string(), }),
341 });
342 }
343 }
344
345 Ok(warnings)
346 }
347
348 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
349 let content = ctx.content;
350
351 if !content.contains(" ") {
353 return Ok(content.to_string());
354 }
355
356 let warnings = self.check(ctx)?;
358 if warnings.is_empty() {
359 return Ok(content.to_string());
360 }
361
362 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
364 .into_iter()
365 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
366 .collect();
367
368 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
369
370 let mut result = content.to_string();
372 for (range, replacement) in fixes {
373 if range.start < result.len() && range.end <= result.len() {
374 result.replace_range(range, &replacement);
375 }
376 }
377
378 Ok(result)
379 }
380
381 fn category(&self) -> RuleCategory {
383 RuleCategory::Whitespace
384 }
385
386 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
388 ctx.content.is_empty() || !ctx.content.contains(" ")
389 }
390
391 fn as_any(&self) -> &dyn std::any::Any {
392 self
393 }
394
395 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
396 where
397 Self: Sized,
398 {
399 Box::new(MD064NoMultipleConsecutiveSpaces::new())
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use crate::lint_context::LintContext;
407
408 #[test]
409 fn test_basic_multiple_spaces() {
410 let rule = MD064NoMultipleConsecutiveSpaces::new();
411
412 let content = "This is a sentence with extra spaces.";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
415 let result = rule.check(&ctx).unwrap();
416 assert_eq!(result.len(), 1);
417 assert_eq!(result[0].line, 1);
418 assert_eq!(result[0].column, 8); }
420
421 #[test]
422 fn test_no_issues_single_spaces() {
423 let rule = MD064NoMultipleConsecutiveSpaces::new();
424
425 let content = "This is a normal sentence with single spaces.";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let result = rule.check(&ctx).unwrap();
429 assert!(result.is_empty());
430 }
431
432 #[test]
433 fn test_skip_inline_code() {
434 let rule = MD064NoMultipleConsecutiveSpaces::new();
435
436 let content = "Use `code with spaces` for formatting.";
438 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert!(result.is_empty());
441 }
442
443 #[test]
444 fn test_skip_code_blocks() {
445 let rule = MD064NoMultipleConsecutiveSpaces::new();
446
447 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert!(result.is_empty());
452 }
453
454 #[test]
455 fn test_skip_leading_indentation() {
456 let rule = MD064NoMultipleConsecutiveSpaces::new();
457
458 let content = " This is indented text.";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461 let result = rule.check(&ctx).unwrap();
462 assert!(result.is_empty());
463 }
464
465 #[test]
466 fn test_skip_trailing_spaces() {
467 let rule = MD064NoMultipleConsecutiveSpaces::new();
468
469 let content = "Line with trailing spaces \nNext line.";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert!(result.is_empty());
474 }
475
476 #[test]
477 fn test_skip_all_trailing_spaces() {
478 let rule = MD064NoMultipleConsecutiveSpaces::new();
479
480 let content = "Two spaces \nThree spaces \nFour spaces \n";
482 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
483 let result = rule.check(&ctx).unwrap();
484 assert!(result.is_empty());
485 }
486
487 #[test]
488 fn test_skip_front_matter() {
489 let rule = MD064NoMultipleConsecutiveSpaces::new();
490
491 let content = "---\ntitle: Test Title\n---\n\nContent here.";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert!(result.is_empty());
496 }
497
498 #[test]
499 fn test_skip_html_comments() {
500 let rule = MD064NoMultipleConsecutiveSpaces::new();
501
502 let content = "<!-- comment with spaces -->\n\nContent here.";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let result = rule.check(&ctx).unwrap();
506 assert!(result.is_empty());
507 }
508
509 #[test]
510 fn test_multiple_issues_one_line() {
511 let rule = MD064NoMultipleConsecutiveSpaces::new();
512
513 let content = "This has multiple issues.";
515 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
516 let result = rule.check(&ctx).unwrap();
517 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
518 }
519
520 #[test]
521 fn test_fix_collapses_spaces() {
522 let rule = MD064NoMultipleConsecutiveSpaces::new();
523
524 let content = "This is a sentence with extra spaces.";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let fixed = rule.fix(&ctx).unwrap();
527 assert_eq!(fixed, "This is a sentence with extra spaces.");
528 }
529
530 #[test]
531 fn test_fix_preserves_inline_code() {
532 let rule = MD064NoMultipleConsecutiveSpaces::new();
533
534 let content = "Text here `code inside` and more.";
535 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let fixed = rule.fix(&ctx).unwrap();
537 assert_eq!(fixed, "Text here `code inside` and more.");
538 }
539
540 #[test]
541 fn test_fix_preserves_trailing_spaces() {
542 let rule = MD064NoMultipleConsecutiveSpaces::new();
543
544 let content = "Line with extra and trailing \nNext line.";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let fixed = rule.fix(&ctx).unwrap();
548 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
550 }
551
552 #[test]
553 fn test_list_items_with_extra_spaces() {
554 let rule = MD064NoMultipleConsecutiveSpaces::new();
555
556 let content = "- Item one\n- Item two\n";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert_eq!(result.len(), 2, "Should flag spaces in list items");
560 }
561
562 #[test]
563 fn test_blockquote_with_extra_spaces_in_content() {
564 let rule = MD064NoMultipleConsecutiveSpaces::new();
565
566 let content = "> Quote with extra spaces\n";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
571 }
572
573 #[test]
574 fn test_skip_blockquote_marker_spaces() {
575 let rule = MD064NoMultipleConsecutiveSpaces::new();
576
577 let content = "> Text with extra space after marker\n";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty());
582
583 let content = "> Text with three spaces after marker\n";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert!(result.is_empty());
588
589 let content = ">> Nested blockquote\n";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty());
594 }
595
596 #[test]
597 fn test_mixed_content() {
598 let rule = MD064NoMultipleConsecutiveSpaces::new();
599
600 let content = r#"# Heading
601
602This has extra spaces.
603
604```
605code here is fine
606```
607
608- List item
609
610> Quote text
611
612Normal paragraph.
613"#;
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
618 }
619
620 #[test]
621 fn test_multibyte_utf8() {
622 let rule = MD064NoMultipleConsecutiveSpaces::new();
623
624 let content = "日本語 テスト 文字列";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx);
628 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
629
630 let warnings = result.unwrap();
631 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
632 }
633
634 #[test]
635 fn test_table_rows_skipped() {
636 let rule = MD064NoMultipleConsecutiveSpaces::new();
637
638 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642 assert!(result.is_empty());
644 }
645
646 #[test]
647 fn test_link_text_with_extra_spaces() {
648 let rule = MD064NoMultipleConsecutiveSpaces::new();
649
650 let content = "[Link text](https://example.com)";
652 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
655 }
656
657 #[test]
658 fn test_image_alt_with_extra_spaces() {
659 let rule = MD064NoMultipleConsecutiveSpaces::new();
660
661 let content = "";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
666 }
667
668 #[test]
669 fn test_skip_list_marker_spaces() {
670 let rule = MD064NoMultipleConsecutiveSpaces::new();
671
672 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676 assert!(result.is_empty());
677
678 let content = "1. Item one\n2. Item two\n10. Item ten\n";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682 assert!(result.is_empty());
683
684 let content = " * Indented item\n 1. Nested numbered item\n";
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 let result = rule.check(&ctx).unwrap();
688 assert!(result.is_empty());
689 }
690
691 #[test]
692 fn test_flag_spaces_in_list_content() {
693 let rule = MD064NoMultipleConsecutiveSpaces::new();
694
695 let content = "* Item with extra spaces in content\n";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let result = rule.check(&ctx).unwrap();
699 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
700 }
701
702 #[test]
703 fn test_skip_reference_link_definition_spaces() {
704 let rule = MD064NoMultipleConsecutiveSpaces::new();
705
706 let content = "[ref]: https://example.com\n";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710 assert!(result.is_empty());
711
712 let content = "[reference-link]: https://example.com \"Title\"\n";
714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716 assert!(result.is_empty());
717 }
718
719 #[test]
720 fn test_skip_footnote_marker_spaces() {
721 let rule = MD064NoMultipleConsecutiveSpaces::new();
722
723 let content = "[^1]: Footnote with extra space\n";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty());
728
729 let content = "[^footnote-label]: This is the footnote text.\n";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert!(result.is_empty());
734 }
735
736 #[test]
737 fn test_skip_definition_list_marker_spaces() {
738 let rule = MD064NoMultipleConsecutiveSpaces::new();
739
740 let content = "Term\n: Definition with extra spaces\n";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert!(result.is_empty());
745
746 let content = ": Another definition\n";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty());
751 }
752
753 #[test]
754 fn test_skip_task_list_checkbox_spaces() {
755 let rule = MD064NoMultipleConsecutiveSpaces::new();
756
757 let content = "- [ ] Task with extra space\n";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert!(result.is_empty());
762
763 let content = "- [x] Completed task\n";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.check(&ctx).unwrap();
767 assert!(result.is_empty());
768
769 let content = "* [ ] Task with asterisk marker\n";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert!(result.is_empty());
774 }
775
776 #[test]
777 fn test_skip_table_without_outer_pipes() {
778 let rule = MD064NoMultipleConsecutiveSpaces::new();
779
780 let content = "Col1 | Col2 | Col3\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert!(result.is_empty());
785
786 let content = "--------- | --------- | ---------\n";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790 assert!(result.is_empty());
791
792 let content = "Data1 | Data2 | Data3\n";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796 assert!(result.is_empty());
797 }
798
799 #[test]
800 fn test_flag_spaces_in_footnote_content() {
801 let rule = MD064NoMultipleConsecutiveSpaces::new();
802
803 let content = "[^1]: Footnote with extra spaces in content.\n";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let result = rule.check(&ctx).unwrap();
807 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
808 }
809
810 #[test]
811 fn test_flag_spaces_in_reference_content() {
812 let rule = MD064NoMultipleConsecutiveSpaces::new();
813
814 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
819 }
820}