1use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::rule_config_serde::RuleConfig;
31use crate::utils::sentence_utils::is_after_sentence_ending;
32use crate::utils::skip_context::is_table_line;
33use serde::{Deserialize, Serialize};
34use std::sync::Arc;
35
36use regex::Regex;
38use std::sync::LazyLock;
39
40static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r" {2,}").unwrap()
43});
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47#[serde(rename_all = "kebab-case")]
48pub struct MD064Config {
49 #[serde(
73 default = "default_allow_sentence_double_space",
74 alias = "allow_sentence_double_space"
75 )]
76 pub allow_sentence_double_space: bool,
77}
78
79fn default_allow_sentence_double_space() -> bool {
80 false
81}
82
83impl Default for MD064Config {
84 fn default() -> Self {
85 Self {
86 allow_sentence_double_space: default_allow_sentence_double_space(),
87 }
88 }
89}
90
91impl RuleConfig for MD064Config {
92 const RULE_NAME: &'static str = "MD064";
93}
94
95#[derive(Debug, Clone)]
96pub struct MD064NoMultipleConsecutiveSpaces {
97 config: MD064Config,
98}
99
100impl Default for MD064NoMultipleConsecutiveSpaces {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl MD064NoMultipleConsecutiveSpaces {
107 pub fn new() -> Self {
108 Self {
109 config: MD064Config::default(),
110 }
111 }
112
113 pub fn from_config_struct(config: MD064Config) -> Self {
114 Self { config }
115 }
116
117 fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
119 code_spans
120 .iter()
121 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
122 }
123
124 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
127 let remaining = &line[match_end..];
129 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
130 }
131
132 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
134 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
136 }
137
138 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
140 let before = line[..match_start].trim_start();
141
142 if before == "*" || before == "-" || before == "+" {
144 return true;
145 }
146
147 if before.len() >= 2 {
150 let last_char = before.chars().last().unwrap();
151 if last_char == '.' || last_char == ')' {
152 let prefix = &before[..before.len() - 1];
153 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
154 return true;
155 }
156 }
157 }
158
159 false
160 }
161
162 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
165 let before = line[..match_start].trim_start();
166
167 if before.is_empty() {
169 return false;
170 }
171
172 let trimmed = before.trim_end();
174 if trimmed.chars().all(|c| c == '>') {
175 return true;
176 }
177
178 if trimmed.ends_with('>') {
180 let inner = trimmed.trim_end_matches('>').trim();
181 if inner.is_empty() || inner.chars().all(|c| c == '>') {
182 return true;
183 }
184 }
185
186 false
187 }
188
189 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
193 space_count >= 4 && space_count.is_multiple_of(4)
194 }
195
196 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
199 let trimmed = line.trim_start();
200
201 if trimmed.starts_with('[')
203 && let Some(bracket_end) = trimmed.find("]:")
204 {
205 let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
206 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
208 return true;
209 }
210 }
211
212 false
213 }
214
215 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
218 let trimmed = line.trim_start();
219
220 if trimmed.starts_with("[^")
222 && let Some(bracket_end) = trimmed.find("]:")
223 {
224 let leading_spaces = line.len() - trimmed.len();
225 let colon_pos = leading_spaces + bracket_end + 2;
226 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
228 return true;
229 }
230 }
231
232 false
233 }
234
235 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
238 let before = line[..match_start].trim_start();
239
240 before == ":"
242 }
243
244 fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
247 let before = line[..match_start].trim_start();
248
249 if before.len() >= 4 {
252 let patterns = [
253 "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
254 ];
255 for pattern in patterns {
256 if before == pattern {
257 return true;
258 }
259 }
260 }
261
262 false
263 }
264
265 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
268 let trimmed = line.trim();
269
270 if !trimmed.contains('|') {
272 return false;
273 }
274
275 if trimmed.starts_with('|') || trimmed.ends_with('|') {
277 return false;
278 }
279
280 let parts: Vec<&str> = trimmed.split('|').collect();
284 if parts.len() >= 2 {
285 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
288 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
289 if first_has_content || last_has_content {
290 return true;
291 }
292 }
293
294 false
295 }
296}
297
298impl Rule for MD064NoMultipleConsecutiveSpaces {
299 fn name(&self) -> &'static str {
300 "MD064"
301 }
302
303 fn description(&self) -> &'static str {
304 "Multiple consecutive spaces"
305 }
306
307 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
308 let content = ctx.content;
309
310 if !content.contains(" ") {
312 return Ok(vec![]);
313 }
314
315 let mut warnings = Vec::new();
316 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
317 let line_index = &ctx.line_index;
318
319 for line in ctx
321 .filtered_lines()
322 .skip_front_matter()
323 .skip_code_blocks()
324 .skip_html_blocks()
325 .skip_html_comments()
326 .skip_mkdocstrings()
327 .skip_esm_blocks()
328 {
329 if !line.content.contains(" ") {
331 continue;
332 }
333
334 if is_table_line(line.content) {
336 continue;
337 }
338
339 if self.is_table_without_outer_pipes(line.content) {
341 continue;
342 }
343
344 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
345
346 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
348 let match_start = mat.start();
349 let match_end = mat.end();
350 let space_count = match_end - match_start;
351
352 if self.is_leading_indentation(line.content, match_start) {
354 continue;
355 }
356
357 if self.is_trailing_whitespace(line.content, match_end) {
359 continue;
360 }
361
362 if self.is_tab_replacement_pattern(space_count) {
365 continue;
366 }
367
368 if self.is_after_list_marker(line.content, match_start) {
370 continue;
371 }
372
373 if self.is_after_blockquote_marker(line.content, match_start) {
375 continue;
376 }
377
378 if self.is_after_footnote_marker(line.content, match_start) {
380 continue;
381 }
382
383 if self.is_reference_link_definition(line.content, match_start) {
385 continue;
386 }
387
388 if self.is_after_definition_marker(line.content, match_start) {
390 continue;
391 }
392
393 if self.is_after_task_checkbox(line.content, match_start) {
395 continue;
396 }
397
398 if self.config.allow_sentence_double_space
401 && space_count == 2
402 && is_after_sentence_ending(line.content, match_start)
403 {
404 continue;
405 }
406
407 let abs_byte_start = line_start_byte + match_start;
409
410 if self.is_in_code_span(&code_spans, abs_byte_start) {
412 continue;
413 }
414
415 let abs_byte_end = line_start_byte + match_end;
417
418 let replacement =
421 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
422 " ".to_string() } else {
424 " ".to_string() };
426
427 warnings.push(LintWarning {
428 rule_name: Some(self.name().to_string()),
429 message: format!("Multiple consecutive spaces ({space_count}) found"),
430 line: line.line_num,
431 column: match_start + 1, end_line: line.line_num,
433 end_column: match_end + 1, severity: Severity::Warning,
435 fix: Some(Fix {
436 range: abs_byte_start..abs_byte_end,
437 replacement,
438 }),
439 });
440 }
441 }
442
443 Ok(warnings)
444 }
445
446 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
447 let content = ctx.content;
448
449 if !content.contains(" ") {
451 return Ok(content.to_string());
452 }
453
454 let warnings = self.check(ctx)?;
456 if warnings.is_empty() {
457 return Ok(content.to_string());
458 }
459
460 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
462 .into_iter()
463 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
464 .collect();
465
466 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
467
468 let mut result = content.to_string();
470 for (range, replacement) in fixes {
471 if range.start < result.len() && range.end <= result.len() {
472 result.replace_range(range, &replacement);
473 }
474 }
475
476 Ok(result)
477 }
478
479 fn category(&self) -> RuleCategory {
481 RuleCategory::Whitespace
482 }
483
484 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
486 ctx.content.is_empty() || !ctx.content.contains(" ")
487 }
488
489 fn as_any(&self) -> &dyn std::any::Any {
490 self
491 }
492
493 fn default_config_section(&self) -> Option<(String, toml::Value)> {
494 let default_config = MD064Config::default();
495 let json_value = serde_json::to_value(&default_config).ok()?;
496 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
497
498 if let toml::Value::Table(table) = toml_value {
499 if !table.is_empty() {
500 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
501 } else {
502 None
503 }
504 } else {
505 None
506 }
507 }
508
509 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
510 where
511 Self: Sized,
512 {
513 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
514 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use crate::lint_context::LintContext;
522
523 #[test]
524 fn test_basic_multiple_spaces() {
525 let rule = MD064NoMultipleConsecutiveSpaces::new();
526
527 let content = "This is a sentence with extra spaces.";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531 assert_eq!(result.len(), 1);
532 assert_eq!(result[0].line, 1);
533 assert_eq!(result[0].column, 8); }
535
536 #[test]
537 fn test_no_issues_single_spaces() {
538 let rule = MD064NoMultipleConsecutiveSpaces::new();
539
540 let content = "This is a normal sentence with single spaces.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert!(result.is_empty());
545 }
546
547 #[test]
548 fn test_skip_inline_code() {
549 let rule = MD064NoMultipleConsecutiveSpaces::new();
550
551 let content = "Use `code with spaces` for formatting.";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555 assert!(result.is_empty());
556 }
557
558 #[test]
559 fn test_skip_code_blocks() {
560 let rule = MD064NoMultipleConsecutiveSpaces::new();
561
562 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert!(result.is_empty());
567 }
568
569 #[test]
570 fn test_skip_leading_indentation() {
571 let rule = MD064NoMultipleConsecutiveSpaces::new();
572
573 let content = " This is indented text.";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
576 let result = rule.check(&ctx).unwrap();
577 assert!(result.is_empty());
578 }
579
580 #[test]
581 fn test_skip_trailing_spaces() {
582 let rule = MD064NoMultipleConsecutiveSpaces::new();
583
584 let content = "Line with trailing spaces \nNext line.";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert!(result.is_empty());
589 }
590
591 #[test]
592 fn test_skip_all_trailing_spaces() {
593 let rule = MD064NoMultipleConsecutiveSpaces::new();
594
595 let content = "Two spaces \nThree spaces \nFour spaces \n";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598 let result = rule.check(&ctx).unwrap();
599 assert!(result.is_empty());
600 }
601
602 #[test]
603 fn test_skip_front_matter() {
604 let rule = MD064NoMultipleConsecutiveSpaces::new();
605
606 let content = "---\ntitle: Test Title\n---\n\nContent here.";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let result = rule.check(&ctx).unwrap();
610 assert!(result.is_empty());
611 }
612
613 #[test]
614 fn test_skip_html_comments() {
615 let rule = MD064NoMultipleConsecutiveSpaces::new();
616
617 let content = "<!-- comment with spaces -->\n\nContent here.";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let result = rule.check(&ctx).unwrap();
621 assert!(result.is_empty());
622 }
623
624 #[test]
625 fn test_multiple_issues_one_line() {
626 let rule = MD064NoMultipleConsecutiveSpaces::new();
627
628 let content = "This has multiple issues.";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
633 }
634
635 #[test]
636 fn test_fix_collapses_spaces() {
637 let rule = MD064NoMultipleConsecutiveSpaces::new();
638
639 let content = "This is a sentence with extra spaces.";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let fixed = rule.fix(&ctx).unwrap();
642 assert_eq!(fixed, "This is a sentence with extra spaces.");
643 }
644
645 #[test]
646 fn test_fix_preserves_inline_code() {
647 let rule = MD064NoMultipleConsecutiveSpaces::new();
648
649 let content = "Text here `code inside` and more.";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let fixed = rule.fix(&ctx).unwrap();
652 assert_eq!(fixed, "Text here `code inside` and more.");
653 }
654
655 #[test]
656 fn test_fix_preserves_trailing_spaces() {
657 let rule = MD064NoMultipleConsecutiveSpaces::new();
658
659 let content = "Line with extra and trailing \nNext line.";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let fixed = rule.fix(&ctx).unwrap();
663 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
665 }
666
667 #[test]
668 fn test_list_items_with_extra_spaces() {
669 let rule = MD064NoMultipleConsecutiveSpaces::new();
670
671 let content = "- Item one\n- Item two\n";
672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
673 let result = rule.check(&ctx).unwrap();
674 assert_eq!(result.len(), 2, "Should flag spaces in list items");
675 }
676
677 #[test]
678 fn test_blockquote_with_extra_spaces_in_content() {
679 let rule = MD064NoMultipleConsecutiveSpaces::new();
680
681 let content = "> Quote with extra spaces\n";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let result = rule.check(&ctx).unwrap();
685 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
686 }
687
688 #[test]
689 fn test_skip_blockquote_marker_spaces() {
690 let rule = MD064NoMultipleConsecutiveSpaces::new();
691
692 let content = "> Text with extra space after marker\n";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(result.is_empty());
697
698 let content = "> Text with three spaces after marker\n";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert!(result.is_empty());
703
704 let content = ">> Nested blockquote\n";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708 assert!(result.is_empty());
709 }
710
711 #[test]
712 fn test_mixed_content() {
713 let rule = MD064NoMultipleConsecutiveSpaces::new();
714
715 let content = r#"# Heading
716
717This has extra spaces.
718
719```
720code here is fine
721```
722
723- List item
724
725> Quote text
726
727Normal paragraph.
728"#;
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
733 }
734
735 #[test]
736 fn test_multibyte_utf8() {
737 let rule = MD064NoMultipleConsecutiveSpaces::new();
738
739 let content = "日本語 テスト 文字列";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx);
743 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
744
745 let warnings = result.unwrap();
746 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
747 }
748
749 #[test]
750 fn test_table_rows_skipped() {
751 let rule = MD064NoMultipleConsecutiveSpaces::new();
752
753 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(result.is_empty());
759 }
760
761 #[test]
762 fn test_link_text_with_extra_spaces() {
763 let rule = MD064NoMultipleConsecutiveSpaces::new();
764
765 let content = "[Link text](https://example.com)";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
770 }
771
772 #[test]
773 fn test_image_alt_with_extra_spaces() {
774 let rule = MD064NoMultipleConsecutiveSpaces::new();
775
776 let content = "";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
781 }
782
783 #[test]
784 fn test_skip_list_marker_spaces() {
785 let rule = MD064NoMultipleConsecutiveSpaces::new();
786
787 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert!(result.is_empty());
792
793 let content = "1. Item one\n2. Item two\n10. Item ten\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 = " * Indented item\n 1. Nested numbered item\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
806 #[test]
807 fn test_flag_spaces_in_list_content() {
808 let rule = MD064NoMultipleConsecutiveSpaces::new();
809
810 let content = "* Item with extra spaces in content\n";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
815 }
816
817 #[test]
818 fn test_skip_reference_link_definition_spaces() {
819 let rule = MD064NoMultipleConsecutiveSpaces::new();
820
821 let content = "[ref]: https://example.com\n";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert!(result.is_empty());
826
827 let content = "[reference-link]: https://example.com \"Title\"\n";
829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830 let result = rule.check(&ctx).unwrap();
831 assert!(result.is_empty());
832 }
833
834 #[test]
835 fn test_skip_footnote_marker_spaces() {
836 let rule = MD064NoMultipleConsecutiveSpaces::new();
837
838 let content = "[^1]: Footnote with extra space\n";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(result.is_empty());
843
844 let content = "[^footnote-label]: This is the footnote text.\n";
846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
847 let result = rule.check(&ctx).unwrap();
848 assert!(result.is_empty());
849 }
850
851 #[test]
852 fn test_skip_definition_list_marker_spaces() {
853 let rule = MD064NoMultipleConsecutiveSpaces::new();
854
855 let content = "Term\n: Definition with extra spaces\n";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859 assert!(result.is_empty());
860
861 let content = ": Another definition\n";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert!(result.is_empty());
866 }
867
868 #[test]
869 fn test_skip_task_list_checkbox_spaces() {
870 let rule = MD064NoMultipleConsecutiveSpaces::new();
871
872 let content = "- [ ] Task with extra space\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert!(result.is_empty());
877
878 let content = "- [x] Completed task\n";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(result.is_empty());
883
884 let content = "* [ ] Task with asterisk marker\n";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888 assert!(result.is_empty());
889 }
890
891 #[test]
892 fn test_skip_table_without_outer_pipes() {
893 let rule = MD064NoMultipleConsecutiveSpaces::new();
894
895 let content = "Col1 | Col2 | Col3\n";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert!(result.is_empty());
900
901 let content = "--------- | --------- | ---------\n";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(result.is_empty());
906
907 let content = "Data1 | Data2 | Data3\n";
909 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
910 let result = rule.check(&ctx).unwrap();
911 assert!(result.is_empty());
912 }
913
914 #[test]
915 fn test_flag_spaces_in_footnote_content() {
916 let rule = MD064NoMultipleConsecutiveSpaces::new();
917
918 let content = "[^1]: Footnote with extra spaces in content.\n";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
923 }
924
925 #[test]
926 fn test_flag_spaces_in_reference_content() {
927 let rule = MD064NoMultipleConsecutiveSpaces::new();
928
929 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932 let result = rule.check(&ctx).unwrap();
933 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
934 }
935
936 #[test]
939 fn test_sentence_double_space_disabled_by_default() {
940 let rule = MD064NoMultipleConsecutiveSpaces::new();
942 let content = "First sentence. Second sentence.";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
946 }
947
948 #[test]
949 fn test_sentence_double_space_enabled_allows_period() {
950 let config = MD064Config {
952 allow_sentence_double_space: true,
953 };
954 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
955
956 let content = "First sentence. Second sentence.";
957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958 let result = rule.check(&ctx).unwrap();
959 assert!(result.is_empty(), "Should allow 2 spaces after period");
960 }
961
962 #[test]
963 fn test_sentence_double_space_enabled_allows_exclamation() {
964 let config = MD064Config {
965 allow_sentence_double_space: true,
966 };
967 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
968
969 let content = "Wow! That was great.";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
973 }
974
975 #[test]
976 fn test_sentence_double_space_enabled_allows_question() {
977 let config = MD064Config {
978 allow_sentence_double_space: true,
979 };
980 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
981
982 let content = "Is this OK? Yes it is.";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
986 }
987
988 #[test]
989 fn test_sentence_double_space_flags_mid_sentence() {
990 let config = MD064Config {
992 allow_sentence_double_space: true,
993 };
994 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
995
996 let content = "Word word in the middle.";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let result = rule.check(&ctx).unwrap();
999 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1000 }
1001
1002 #[test]
1003 fn test_sentence_double_space_flags_triple_after_period() {
1004 let config = MD064Config {
1006 allow_sentence_double_space: true,
1007 };
1008 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1009
1010 let content = "First sentence. Three spaces here.";
1011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1014 }
1015
1016 #[test]
1017 fn test_sentence_double_space_with_closing_quote() {
1018 let config = MD064Config {
1020 allow_sentence_double_space: true,
1021 };
1022 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1023
1024 let content = r#"He said "Hello." Then he left."#;
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1028
1029 let content = "She said 'Goodbye.' And she was gone.";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1034 }
1035
1036 #[test]
1037 fn test_sentence_double_space_with_curly_quotes() {
1038 let config = MD064Config {
1039 allow_sentence_double_space: true,
1040 };
1041 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1042
1043 let content = format!(
1046 "He said {}Hello.{} Then left.",
1047 '\u{201C}', '\u{201D}' );
1050 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1053
1054 let content = format!(
1056 "She said {}Hi.{} And left.",
1057 '\u{2018}', '\u{2019}' );
1060 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1063 }
1064
1065 #[test]
1066 fn test_sentence_double_space_with_closing_paren() {
1067 let config = MD064Config {
1068 allow_sentence_double_space: true,
1069 };
1070 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1071
1072 let content = "(See reference.) The next point is.";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1076 }
1077
1078 #[test]
1079 fn test_sentence_double_space_with_closing_bracket() {
1080 let config = MD064Config {
1081 allow_sentence_double_space: true,
1082 };
1083 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1084
1085 let content = "[Citation needed.] More text here.";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1089 }
1090
1091 #[test]
1092 fn test_sentence_double_space_with_ellipsis() {
1093 let config = MD064Config {
1094 allow_sentence_double_space: true,
1095 };
1096 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1097
1098 let content = "He paused... Then continued.";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1102 }
1103
1104 #[test]
1105 fn test_sentence_double_space_complex_ending() {
1106 let config = MD064Config {
1108 allow_sentence_double_space: true,
1109 };
1110 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1111
1112 let content = r#"(He said "Yes.") Then they agreed."#;
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114 let result = rule.check(&ctx).unwrap();
1115 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1116 }
1117
1118 #[test]
1119 fn test_sentence_double_space_mixed_content() {
1120 let config = MD064Config {
1122 allow_sentence_double_space: true,
1123 };
1124 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1125
1126 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1130 assert!(
1131 result[0].column > 15 && result[0].column < 25,
1132 "Should flag the 'Bad mid' double space"
1133 );
1134 }
1135
1136 #[test]
1137 fn test_sentence_double_space_fix_collapses_to_two() {
1138 let config = MD064Config {
1140 allow_sentence_double_space: true,
1141 };
1142 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1143
1144 let content = "Sentence. Three spaces here.";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let fixed = rule.fix(&ctx).unwrap();
1147 assert_eq!(
1148 fixed, "Sentence. Three spaces here.",
1149 "Should collapse to 2 spaces after sentence"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1155 let config = MD064Config {
1157 allow_sentence_double_space: true,
1158 };
1159 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1160
1161 let content = "Word word here.";
1162 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1163 let fixed = rule.fix(&ctx).unwrap();
1164 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1165 }
1166
1167 #[test]
1168 fn test_sentence_double_space_config_kebab_case() {
1169 let toml_str = r#"
1170 allow-sentence-double-space = true
1171 "#;
1172 let config: MD064Config = toml::from_str(toml_str).unwrap();
1173 assert!(config.allow_sentence_double_space);
1174 }
1175
1176 #[test]
1177 fn test_sentence_double_space_config_snake_case() {
1178 let toml_str = r#"
1179 allow_sentence_double_space = true
1180 "#;
1181 let config: MD064Config = toml::from_str(toml_str).unwrap();
1182 assert!(config.allow_sentence_double_space);
1183 }
1184
1185 #[test]
1186 fn test_sentence_double_space_at_line_start() {
1187 let config = MD064Config {
1189 allow_sentence_double_space: true,
1190 };
1191 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1192
1193 let content = ". Text after period at start.";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let _result = rule.check(&ctx).unwrap();
1198 }
1199
1200 #[test]
1201 fn test_sentence_double_space_guillemets() {
1202 let config = MD064Config {
1204 allow_sentence_double_space: true,
1205 };
1206 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1207
1208 let content = "Il a dit «Oui.» Puis il est parti.";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1212 }
1213
1214 #[test]
1215 fn test_sentence_double_space_multiple_sentences() {
1216 let config = MD064Config {
1218 allow_sentence_double_space: true,
1219 };
1220 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1221
1222 let content = "First. Second. Third. Fourth.";
1223 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1226 }
1227
1228 #[test]
1229 fn test_sentence_double_space_abbreviation_detection() {
1230 let config = MD064Config {
1232 allow_sentence_double_space: true,
1233 };
1234 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1235
1236 let content = "Dr. Smith arrived.";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1241
1242 let content = "Prof. Williams teaches.";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1247
1248 let content = "Use e.g. this example.";
1250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251 let result = rule.check(&ctx).unwrap();
1252 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1253
1254 let content = "Acme Inc. Next company.";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(
1260 result.is_empty(),
1261 "Inc. not in abbreviation list, treated as sentence end"
1262 );
1263 }
1264
1265 #[test]
1266 fn test_sentence_double_space_default_config_has_correct_defaults() {
1267 let config = MD064Config::default();
1268 assert!(
1269 !config.allow_sentence_double_space,
1270 "Default allow_sentence_double_space should be false"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_sentence_double_space_from_config_integration() {
1276 use crate::config::Config;
1277 use std::collections::BTreeMap;
1278
1279 let mut config = Config::default();
1280 let mut values = BTreeMap::new();
1281 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1282 config.rules.insert(
1283 "MD064".to_string(),
1284 crate::config::RuleConfig { severity: None, values },
1285 );
1286
1287 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1288
1289 let content = "Sentence. Two spaces OK. But three is not.";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let result = rule.check(&ctx).unwrap();
1293 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1294 }
1295}