1use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::rule_config_serde::RuleConfig;
31use crate::utils::blockquote::parse_blockquote_prefix;
32use crate::utils::sentence_utils::is_after_sentence_ending;
33use crate::utils::skip_context::is_table_line;
34use serde::{Deserialize, Serialize};
35use std::collections::HashSet;
36use std::sync::Arc;
37
38use regex::Regex;
40use std::sync::LazyLock;
41
42static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
43 Regex::new(r" {2,}").unwrap()
45});
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49#[serde(rename_all = "kebab-case")]
50pub struct MD064Config {
51 #[serde(
75 default = "default_allow_sentence_double_space",
76 alias = "allow_sentence_double_space"
77 )]
78 pub allow_sentence_double_space: bool,
79}
80
81fn default_allow_sentence_double_space() -> bool {
82 false
83}
84
85impl Default for MD064Config {
86 fn default() -> Self {
87 Self {
88 allow_sentence_double_space: default_allow_sentence_double_space(),
89 }
90 }
91}
92
93impl RuleConfig for MD064Config {
94 const RULE_NAME: &'static str = "MD064";
95}
96
97#[derive(Debug, Clone)]
98pub struct MD064NoMultipleConsecutiveSpaces {
99 config: MD064Config,
100}
101
102impl Default for MD064NoMultipleConsecutiveSpaces {
103 fn default() -> Self {
104 Self::new()
105 }
106}
107
108impl MD064NoMultipleConsecutiveSpaces {
109 pub fn new() -> Self {
110 Self {
111 config: MD064Config::default(),
112 }
113 }
114
115 pub fn from_config_struct(config: MD064Config) -> Self {
116 Self { config }
117 }
118
119 fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
121 code_spans
122 .iter()
123 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
124 }
125
126 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
129 let remaining = &line[match_end..];
131 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
132 }
133
134 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
136 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
138 }
139
140 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
142 let before_text = if let Some(parsed) = parse_blockquote_prefix(line) {
144 let prefix_len = parsed.prefix.len();
145 if match_start <= prefix_len {
146 return false;
147 }
148 line[prefix_len..match_start].trim_start()
149 } else {
150 line[..match_start].trim_start()
151 };
152
153 if before_text == "*" || before_text == "-" || before_text == "+" {
155 return true;
156 }
157
158 if before_text.len() >= 2 {
161 let last_char = before_text.chars().last().unwrap();
162 if last_char == '.' || last_char == ')' {
163 let prefix = &before_text[..before_text.len() - 1];
164 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
165 return true;
166 }
167 }
168 }
169
170 false
171 }
172
173 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
176 let before = line[..match_start].trim_start();
177
178 if before.is_empty() {
180 return false;
181 }
182
183 let trimmed = before.trim_end();
185 if trimmed.chars().all(|c| c == '>') {
186 return true;
187 }
188
189 if trimmed.ends_with('>') {
191 let inner = trimmed.trim_end_matches('>').trim();
192 if inner.is_empty() || inner.chars().all(|c| c == '>') {
193 return true;
194 }
195 }
196
197 false
198 }
199
200 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
204 space_count >= 4 && space_count.is_multiple_of(4)
205 }
206
207 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
210 let trimmed = line.trim_start();
211 let leading_spaces = line.len() - trimmed.len();
212
213 if trimmed.starts_with('[')
215 && let Some(bracket_end) = trimmed.find("]:")
216 {
217 let colon_pos = leading_spaces + bracket_end + 2;
218 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
220 return true;
221 }
222 }
223
224 false
225 }
226
227 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
230 let trimmed = line.trim_start();
231
232 if trimmed.starts_with("[^")
234 && let Some(bracket_end) = trimmed.find("]:")
235 {
236 let leading_spaces = line.len() - trimmed.len();
237 let colon_pos = leading_spaces + bracket_end + 2;
238 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
240 return true;
241 }
242 }
243
244 false
245 }
246
247 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
250 let before = line[..match_start].trim_start();
251
252 before == ":"
254 }
255
256 fn is_after_task_checkbox(&self, line: &str, match_start: usize, flavor: crate::config::MarkdownFlavor) -> bool {
261 let before = line[..match_start].trim_start();
262
263 let mut chars = before.chars();
265 let pattern = (
266 chars.next(),
267 chars.next(),
268 chars.next(),
269 chars.next(),
270 chars.next(),
271 chars.next(),
272 );
273
274 match pattern {
275 (Some('*' | '-' | '+'), Some(' '), Some('['), Some(c), Some(']'), None) => {
276 if flavor == crate::config::MarkdownFlavor::Obsidian {
277 true
279 } else {
280 matches!(c, ' ' | 'x' | 'X')
282 }
283 }
284 _ => false,
285 }
286 }
287
288 fn aligned_list_item_lines(&self, ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
298 let mut aligned = HashSet::new();
299 for block in &ctx.list_blocks {
300 if block.item_lines.len() < 2 {
301 continue;
302 }
303 let all_aligned = block
304 .item_lines
305 .iter()
306 .all(|&line_num| self.item_line_has_internal_alignment(ctx, line_num));
307 if all_aligned {
308 aligned.extend(block.item_lines.iter().copied());
309 }
310 }
311 aligned
312 }
313
314 fn item_line_has_internal_alignment(&self, ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
317 let Some(line_info) = ctx.line_info(line_num) else {
318 return false;
319 };
320 let Some(item) = &line_info.list_item else {
321 return false;
322 };
323 let content = line_info.content(ctx.content);
324 if item.content_column >= content.len() {
325 return false;
326 }
327 content[item.content_column..].trim_end().contains(" ")
328 }
329
330 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
333 let trimmed = line.trim();
334
335 if !trimmed.contains('|') {
337 return false;
338 }
339
340 if trimmed.starts_with('|') || trimmed.ends_with('|') {
342 return false;
343 }
344
345 let parts: Vec<&str> = trimmed.split('|').collect();
349 if parts.len() >= 2 {
350 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
353 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
354 if first_has_content || last_has_content {
355 return true;
356 }
357 }
358
359 false
360 }
361}
362
363impl Rule for MD064NoMultipleConsecutiveSpaces {
364 fn name(&self) -> &'static str {
365 "MD064"
366 }
367
368 fn description(&self) -> &'static str {
369 "Multiple consecutive spaces"
370 }
371
372 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
373 let content = ctx.content;
374
375 if !content.contains(" ") {
377 return Ok(vec![]);
378 }
379
380 let mut warnings = Vec::new();
382 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
383
384 let aligned_lines = self.aligned_list_item_lines(ctx);
388
389 for line in ctx
391 .filtered_lines()
392 .skip_front_matter()
393 .skip_code_blocks()
394 .skip_html_blocks()
395 .skip_html_comments()
396 .skip_mkdocstrings()
397 .skip_esm_blocks()
398 .skip_jsx_expressions()
399 .skip_mdx_comments()
400 .skip_pymdown_blocks()
401 .skip_obsidian_comments()
402 {
403 if !line.content.contains(" ") {
405 continue;
406 }
407
408 if aligned_lines.contains(&line.line_num) {
412 continue;
413 }
414
415 if is_table_line(line.content) {
417 continue;
418 }
419
420 if self.is_table_without_outer_pipes(line.content) {
422 continue;
423 }
424
425 let line_start_byte = ctx.line_start_byte(line.line_num).unwrap_or(0);
426
427 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
429 let match_start = mat.start();
430 let match_end = mat.end();
431 let space_count = match_end - match_start;
432
433 if self.is_leading_indentation(line.content, match_start) {
435 continue;
436 }
437
438 if self.is_trailing_whitespace(line.content, match_end) {
440 continue;
441 }
442
443 if self.is_tab_replacement_pattern(space_count) {
446 continue;
447 }
448
449 if self.is_after_list_marker(line.content, match_start) {
451 continue;
452 }
453
454 if self.is_after_blockquote_marker(line.content, match_start) {
456 continue;
457 }
458
459 if self.is_after_footnote_marker(line.content, match_start) {
461 continue;
462 }
463
464 if self.is_reference_link_definition(line.content, match_start) {
466 continue;
467 }
468
469 if self.is_after_definition_marker(line.content, match_start) {
471 continue;
472 }
473
474 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
476 continue;
477 }
478
479 if self.config.allow_sentence_double_space
482 && space_count == 2
483 && is_after_sentence_ending(line.content, match_start)
484 {
485 continue;
486 }
487
488 let abs_byte_start = line_start_byte + match_start;
490
491 if self.is_in_code_span(&code_spans, abs_byte_start) {
493 continue;
494 }
495
496 let abs_byte_end = line_start_byte + match_end;
498
499 let replacement =
502 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
503 " ".to_string() } else {
505 " ".to_string() };
507
508 warnings.push(LintWarning {
509 rule_name: Some(self.name().to_string()),
510 message: format!("Multiple consecutive spaces ({space_count}) found"),
511 line: line.line_num,
512 column: match_start + 1, end_line: line.line_num,
514 end_column: match_end + 1, severity: Severity::Warning,
516 fix: Some(Fix::new(abs_byte_start..abs_byte_end, replacement)),
517 });
518 }
519 }
520
521 Ok(warnings)
522 }
523
524 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
525 let content = ctx.content;
526
527 if !content.contains(" ") {
529 return Ok(content.to_string());
530 }
531
532 let warnings = self.check(ctx)?;
534 let warnings =
535 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
536 if warnings.is_empty() {
537 return Ok(content.to_string());
538 }
539
540 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
542 .into_iter()
543 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
544 .collect();
545
546 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
547
548 let mut result = content.to_string();
550 for (range, replacement) in fixes {
551 if range.start < result.len() && range.end <= result.len() {
552 result.replace_range(range, &replacement);
553 }
554 }
555
556 Ok(result)
557 }
558
559 fn category(&self) -> RuleCategory {
561 RuleCategory::Whitespace
562 }
563
564 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
566 ctx.content.is_empty() || !ctx.content.contains(" ")
567 }
568
569 fn as_any(&self) -> &dyn std::any::Any {
570 self
571 }
572
573 crate::impl_rule_config_methods!(MD064Config);
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::lint_context::LintContext;
580
581 #[test]
582 fn test_basic_multiple_spaces() {
583 let rule = MD064NoMultipleConsecutiveSpaces::new();
584
585 let content = "This is a sentence with extra spaces.";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let result = rule.check(&ctx).unwrap();
589 assert_eq!(result.len(), 1);
590 assert_eq!(result[0].line, 1);
591 assert_eq!(result[0].column, 8); }
593
594 #[test]
595 fn test_no_issues_single_spaces() {
596 let rule = MD064NoMultipleConsecutiveSpaces::new();
597
598 let content = "This is a normal sentence with single spaces.";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert!(result.is_empty());
603 }
604
605 #[test]
606 fn test_skip_inline_code() {
607 let rule = MD064NoMultipleConsecutiveSpaces::new();
608
609 let content = "Use `code with spaces` for formatting.";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613 assert!(result.is_empty());
614 }
615
616 #[test]
617 fn test_skip_code_blocks() {
618 let rule = MD064NoMultipleConsecutiveSpaces::new();
619
620 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert!(result.is_empty());
625 }
626
627 #[test]
628 fn test_skip_leading_indentation() {
629 let rule = MD064NoMultipleConsecutiveSpaces::new();
630
631 let content = " This is indented text.";
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
634 let result = rule.check(&ctx).unwrap();
635 assert!(result.is_empty());
636 }
637
638 #[test]
639 fn test_skip_trailing_spaces() {
640 let rule = MD064NoMultipleConsecutiveSpaces::new();
641
642 let content = "Line with trailing spaces \nNext line.";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert!(result.is_empty());
647 }
648
649 #[test]
650 fn test_skip_all_trailing_spaces() {
651 let rule = MD064NoMultipleConsecutiveSpaces::new();
652
653 let content = "Two spaces \nThree spaces \nFour spaces \n";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657 assert!(result.is_empty());
658 }
659
660 #[test]
661 fn test_skip_front_matter() {
662 let rule = MD064NoMultipleConsecutiveSpaces::new();
663
664 let content = "---\ntitle: Test Title\n---\n\nContent here.";
666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667 let result = rule.check(&ctx).unwrap();
668 assert!(result.is_empty());
669 }
670
671 #[test]
672 fn test_skip_html_comments() {
673 let rule = MD064NoMultipleConsecutiveSpaces::new();
674
675 let content = "<!-- comment with spaces -->\n\nContent here.";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679 assert!(result.is_empty());
680 }
681
682 #[test]
683 fn test_multiple_issues_one_line() {
684 let rule = MD064NoMultipleConsecutiveSpaces::new();
685
686 let content = "This has multiple issues.";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
691 }
692
693 #[test]
694 fn test_fix_collapses_spaces() {
695 let rule = MD064NoMultipleConsecutiveSpaces::new();
696
697 let content = "This is a sentence with extra spaces.";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let fixed = rule.fix(&ctx).unwrap();
700 assert_eq!(fixed, "This is a sentence with extra spaces.");
701 }
702
703 #[test]
704 fn test_fix_preserves_inline_code() {
705 let rule = MD064NoMultipleConsecutiveSpaces::new();
706
707 let content = "Text here `code inside` and more.";
708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let fixed = rule.fix(&ctx).unwrap();
710 assert_eq!(fixed, "Text here `code inside` and more.");
711 }
712
713 #[test]
714 fn test_fix_preserves_trailing_spaces() {
715 let rule = MD064NoMultipleConsecutiveSpaces::new();
716
717 let content = "Line with extra and trailing \nNext line.";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let fixed = rule.fix(&ctx).unwrap();
721 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
723 }
724
725 #[test]
726 fn test_list_items_with_extra_spaces() {
727 let rule = MD064NoMultipleConsecutiveSpaces::new();
728
729 let content = "- Item one\n- Item two\n- Item three\n";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736 assert_eq!(result.len(), 2, "Should flag spaces in list items");
737 }
738
739 #[test]
740 fn test_blockquote_with_extra_spaces_in_content() {
741 let rule = MD064NoMultipleConsecutiveSpaces::new();
742
743 let content = "> Quote with extra spaces\n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let result = rule.check(&ctx).unwrap();
747 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
748 }
749
750 #[test]
751 fn test_skip_blockquote_marker_spaces() {
752 let rule = MD064NoMultipleConsecutiveSpaces::new();
753
754 let content = "> Text with extra space after marker\n";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert!(result.is_empty());
759
760 let content = "> Text with three spaces after marker\n";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(result.is_empty());
765
766 let content = ">> Nested blockquote\n";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert!(result.is_empty());
771 }
772
773 #[test]
774 fn test_mixed_content() {
775 let rule = MD064NoMultipleConsecutiveSpaces::new();
776
777 let content = r#"# Heading
778
779This has extra spaces.
780
781```
782code here is fine
783```
784
785- List item
786
787> Quote text
788
789Normal paragraph.
790"#;
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result = rule.check(&ctx).unwrap();
793 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
795 }
796
797 #[test]
798 fn test_multibyte_utf8() {
799 let rule = MD064NoMultipleConsecutiveSpaces::new();
800
801 let content = "日本語 テスト 文字列";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx);
805 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
806
807 let warnings = result.unwrap();
808 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
809 }
810
811 #[test]
812 fn test_table_rows_skipped() {
813 let rule = MD064NoMultipleConsecutiveSpaces::new();
814
815 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert!(result.is_empty());
821 }
822
823 #[test]
824 fn test_link_text_with_extra_spaces() {
825 let rule = MD064NoMultipleConsecutiveSpaces::new();
826
827 let content = "[Link text](https://example.com)";
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 link text");
832 }
833
834 #[test]
835 fn test_image_alt_with_extra_spaces() {
836 let rule = MD064NoMultipleConsecutiveSpaces::new();
837
838 let content = "";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
843 }
844
845 #[test]
846 fn test_skip_list_marker_spaces() {
847 let rule = MD064NoMultipleConsecutiveSpaces::new();
848
849 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert!(result.is_empty());
854
855 let content = "1. Item one\n2. Item two\n10. Item ten\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 = " * Indented item\n 1. Nested numbered item\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_blockquoted_list_marker_spaces() {
870 let rule = MD064NoMultipleConsecutiveSpaces::new();
871
872 let content = "# Title\n\n> 1. Hello.\n> This is a list item.\n> 2. This is another list item\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert!(
877 result.is_empty(),
878 "Should not flag spaces after list markers in blockquotes"
879 );
880
881 let content = "> * Item one\n> - Item two\n> + Item three\n";
883 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885 assert!(
886 result.is_empty(),
887 "Should not flag spaces after unordered list markers in blockquotes"
888 );
889
890 let content = "> > 1. Nested blockquote list item\n";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert!(
895 result.is_empty(),
896 "Should not flag spaces after list markers in nested blockquotes"
897 );
898
899 let content = "> 1) First item\n> 2) Second item\n";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(
904 result.is_empty(),
905 "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
906 );
907
908 let content = "> 1. Item with extra space after >\n";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912 assert!(
915 result.is_empty(),
916 "Should not flag list marker spaces even with extra space after blockquote marker"
917 );
918
919 let content = "> 1. Item with extra spaces in content\n";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let result = rule.check(&ctx).unwrap();
923 assert_eq!(
924 result.len(),
925 1,
926 "Should still flag extra spaces in blockquoted list content"
927 );
928 }
929
930 #[test]
931 fn test_flag_spaces_in_list_content() {
932 let rule = MD064NoMultipleConsecutiveSpaces::new();
933
934 let content = "* Item with extra spaces in content\n";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
939 }
940
941 #[test]
942 fn test_skip_reference_link_definition_spaces() {
943 let rule = MD064NoMultipleConsecutiveSpaces::new();
944
945 let content = "[ref]: https://example.com\n";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949 assert!(result.is_empty());
950
951 let content = "[reference-link]: https://example.com \"Title\"\n";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(result.is_empty());
956 }
957
958 #[test]
959 fn test_skip_footnote_marker_spaces() {
960 let rule = MD064NoMultipleConsecutiveSpaces::new();
961
962 let content = "[^1]: Footnote with extra space\n";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966 assert!(result.is_empty());
967
968 let content = "[^footnote-label]: This is the footnote text.\n";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(result.is_empty());
973 }
974
975 #[test]
976 fn test_skip_definition_list_marker_spaces() {
977 let rule = MD064NoMultipleConsecutiveSpaces::new();
978
979 let content = "Term\n: Definition with extra spaces\n";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(result.is_empty());
984
985 let content = ": Another definition\n";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert!(result.is_empty());
990 }
991
992 #[test]
993 fn test_skip_task_list_checkbox_spaces() {
994 let rule = MD064NoMultipleConsecutiveSpaces::new();
995
996 let content = "- [ ] Task with extra space\n";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert!(result.is_empty());
1001
1002 let content = "- [x] Completed task\n";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(result.is_empty());
1007
1008 let content = "* [ ] Task with asterisk marker\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(result.is_empty());
1013 }
1014
1015 #[test]
1016 fn test_skip_extended_task_checkbox_spaces_obsidian() {
1017 let rule = MD064NoMultipleConsecutiveSpaces::new();
1019
1020 let content = "- [/] In progress task\n";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1023 let result = rule.check(&ctx).unwrap();
1024 assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
1025
1026 let content = "- [-] Cancelled task\n";
1028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1029 let result = rule.check(&ctx).unwrap();
1030 assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
1031
1032 let content = "- [>] Deferred task\n";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1035 let result = rule.check(&ctx).unwrap();
1036 assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
1037
1038 let content = "- [<] Scheduled task\n";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1041 let result = rule.check(&ctx).unwrap();
1042 assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
1043
1044 let content = "- [?] Question task\n";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1047 let result = rule.check(&ctx).unwrap();
1048 assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
1049
1050 let content = "- [!] Important task\n";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
1055
1056 let content = "- [*] Starred task\n";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
1061
1062 let content = "* [/] In progress with asterisk\n";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
1067
1068 let content = "+ [-] Cancelled with plus\n";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1071 let result = rule.check(&ctx).unwrap();
1072 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1073
1074 let content = "- [✓] Completed with checkmark\n";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1077 let result = rule.check(&ctx).unwrap();
1078 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
1079
1080 let content = "- [✗] Failed with X mark\n";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
1084
1085 let content = "- [→] Forwarded with arrow\n";
1086 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1087 let result = rule.check(&ctx).unwrap();
1088 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1089 }
1090
1091 #[test]
1092 fn test_flag_extended_checkboxes_in_standard_flavor() {
1093 let rule = MD064NoMultipleConsecutiveSpaces::new();
1095
1096 let content = "- [/] In progress task\n";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let result = rule.check(&ctx).unwrap();
1099 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
1100
1101 let content = "- [-] Cancelled task\n";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let result = rule.check(&ctx).unwrap();
1104 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1105
1106 let content = "- [✓] Unicode checkbox\n";
1107 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1110 }
1111
1112 #[test]
1113 fn test_extended_checkboxes_with_indentation() {
1114 let rule = MD064NoMultipleConsecutiveSpaces::new();
1115
1116 let content = " - [/] In progress task\n";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1120 let result = rule.check(&ctx).unwrap();
1121 assert!(
1122 result.is_empty(),
1123 "Should skip space-indented extended checkbox in Obsidian"
1124 );
1125
1126 let content = " - [-] Cancelled task\n";
1128 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1129 let result = rule.check(&ctx).unwrap();
1130 assert!(
1131 result.is_empty(),
1132 "Should skip 3-space indented extended checkbox in Obsidian"
1133 );
1134
1135 let content = "- Parent item\n\t- [/] In progress task\n";
1138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1139 let result = rule.check(&ctx).unwrap();
1140 assert!(
1141 result.is_empty(),
1142 "Should skip tab-indented nested extended checkbox in Obsidian"
1143 );
1144
1145 let content = " - [/] In progress task\n";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1150
1151 let content = " - [-] Cancelled task\n";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1156
1157 let content = "- Parent item\n\t- [-] Cancelled task\n";
1159 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160 let result = rule.check(&ctx).unwrap();
1161 assert_eq!(
1162 result.len(),
1163 1,
1164 "Should flag tab-indented nested [-] in Standard flavor"
1165 );
1166
1167 let content = " - [x] Completed task\n";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(
1172 result.is_empty(),
1173 "Should skip indented standard [x] checkbox in Standard flavor"
1174 );
1175
1176 let content = "- Parent\n\t- [ ] Pending task\n";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert!(
1181 result.is_empty(),
1182 "Should skip tab-indented nested standard [ ] checkbox"
1183 );
1184 }
1185
1186 #[test]
1187 fn test_skip_table_without_outer_pipes() {
1188 let rule = MD064NoMultipleConsecutiveSpaces::new();
1189
1190 let content = "Col1 | Col2 | Col3\n";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert!(result.is_empty());
1195
1196 let content = "--------- | --------- | ---------\n";
1198 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199 let result = rule.check(&ctx).unwrap();
1200 assert!(result.is_empty());
1201
1202 let content = "Data1 | Data2 | Data3\n";
1204 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1205 let result = rule.check(&ctx).unwrap();
1206 assert!(result.is_empty());
1207 }
1208
1209 #[test]
1210 fn test_flag_spaces_in_footnote_content() {
1211 let rule = MD064NoMultipleConsecutiveSpaces::new();
1212
1213 let content = "[^1]: Footnote with extra spaces in content.\n";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1218 }
1219
1220 #[test]
1221 fn test_flag_spaces_in_reference_content() {
1222 let rule = MD064NoMultipleConsecutiveSpaces::new();
1223
1224 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1229 }
1230
1231 #[test]
1234 fn test_sentence_double_space_disabled_by_default() {
1235 let rule = MD064NoMultipleConsecutiveSpaces::new();
1237 let content = "First sentence. Second sentence.";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1241 }
1242
1243 #[test]
1244 fn test_sentence_double_space_enabled_allows_period() {
1245 let config = MD064Config {
1247 allow_sentence_double_space: true,
1248 };
1249 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1250
1251 let content = "First sentence. Second sentence.";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254 assert!(result.is_empty(), "Should allow 2 spaces after period");
1255 }
1256
1257 #[test]
1258 fn test_sentence_double_space_enabled_allows_exclamation() {
1259 let config = MD064Config {
1260 allow_sentence_double_space: true,
1261 };
1262 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1263
1264 let content = "Wow! That was great.";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1268 }
1269
1270 #[test]
1271 fn test_sentence_double_space_enabled_allows_question() {
1272 let config = MD064Config {
1273 allow_sentence_double_space: true,
1274 };
1275 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1276
1277 let content = "Is this OK? Yes it is.";
1278 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279 let result = rule.check(&ctx).unwrap();
1280 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1281 }
1282
1283 #[test]
1284 fn test_sentence_double_space_flags_mid_sentence() {
1285 let config = MD064Config {
1287 allow_sentence_double_space: true,
1288 };
1289 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1290
1291 let content = "Word word in the middle.";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1295 }
1296
1297 #[test]
1298 fn test_sentence_double_space_flags_triple_after_period() {
1299 let config = MD064Config {
1301 allow_sentence_double_space: true,
1302 };
1303 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1304
1305 let content = "First sentence. Three spaces here.";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1309 }
1310
1311 #[test]
1312 fn test_sentence_double_space_with_closing_quote() {
1313 let config = MD064Config {
1315 allow_sentence_double_space: true,
1316 };
1317 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1318
1319 let content = r#"He said "Hello." Then he left."#;
1320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321 let result = rule.check(&ctx).unwrap();
1322 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1323
1324 let content = "She said 'Goodbye.' And she was gone.";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1329 }
1330
1331 #[test]
1332 fn test_sentence_double_space_with_curly_quotes() {
1333 let config = MD064Config {
1334 allow_sentence_double_space: true,
1335 };
1336 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1337
1338 let content = format!(
1341 "He said {}Hello.{} Then left.",
1342 '\u{201C}', '\u{201D}' );
1345 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1346 let result = rule.check(&ctx).unwrap();
1347 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1348
1349 let content = format!(
1351 "She said {}Hi.{} And left.",
1352 '\u{2018}', '\u{2019}' );
1355 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1356 let result = rule.check(&ctx).unwrap();
1357 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1358 }
1359
1360 #[test]
1361 fn test_sentence_double_space_with_closing_paren() {
1362 let config = MD064Config {
1363 allow_sentence_double_space: true,
1364 };
1365 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1366
1367 let content = "(See reference.) The next point is.";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1371 }
1372
1373 #[test]
1374 fn test_sentence_double_space_with_closing_bracket() {
1375 let config = MD064Config {
1376 allow_sentence_double_space: true,
1377 };
1378 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1379
1380 let content = "[Citation needed.] More text here.";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let result = rule.check(&ctx).unwrap();
1383 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1384 }
1385
1386 #[test]
1387 fn test_sentence_double_space_with_ellipsis() {
1388 let config = MD064Config {
1389 allow_sentence_double_space: true,
1390 };
1391 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1392
1393 let content = "He paused... Then continued.";
1394 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1395 let result = rule.check(&ctx).unwrap();
1396 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1397 }
1398
1399 #[test]
1400 fn test_sentence_double_space_complex_ending() {
1401 let config = MD064Config {
1403 allow_sentence_double_space: true,
1404 };
1405 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1406
1407 let content = r#"(He said "Yes.") Then they agreed."#;
1408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409 let result = rule.check(&ctx).unwrap();
1410 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1411 }
1412
1413 #[test]
1414 fn test_sentence_double_space_mixed_content() {
1415 let config = MD064Config {
1417 allow_sentence_double_space: true,
1418 };
1419 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1420
1421 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1425 assert!(
1426 result[0].column > 15 && result[0].column < 25,
1427 "Should flag the 'Bad mid' double space"
1428 );
1429 }
1430
1431 #[test]
1432 fn test_sentence_double_space_fix_collapses_to_two() {
1433 let config = MD064Config {
1435 allow_sentence_double_space: true,
1436 };
1437 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1438
1439 let content = "Sentence. Three spaces here.";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let fixed = rule.fix(&ctx).unwrap();
1442 assert_eq!(
1443 fixed, "Sentence. Three spaces here.",
1444 "Should collapse to 2 spaces after sentence"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1450 let config = MD064Config {
1452 allow_sentence_double_space: true,
1453 };
1454 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1455
1456 let content = "Word word here.";
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 let fixed = rule.fix(&ctx).unwrap();
1459 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1460 }
1461
1462 #[test]
1463 fn test_sentence_double_space_config_kebab_case() {
1464 let toml_str = r#"
1465 allow-sentence-double-space = true
1466 "#;
1467 let config: MD064Config = toml::from_str(toml_str).unwrap();
1468 assert!(config.allow_sentence_double_space);
1469 }
1470
1471 #[test]
1472 fn test_sentence_double_space_config_snake_case() {
1473 let toml_str = r#"
1474 allow_sentence_double_space = true
1475 "#;
1476 let config: MD064Config = toml::from_str(toml_str).unwrap();
1477 assert!(config.allow_sentence_double_space);
1478 }
1479
1480 #[test]
1481 fn test_sentence_double_space_at_line_start() {
1482 let config = MD064Config {
1484 allow_sentence_double_space: true,
1485 };
1486 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1487
1488 let content = ". Text after period at start.";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let _result = rule.check(&ctx).unwrap();
1493 }
1494
1495 #[test]
1496 fn test_sentence_double_space_guillemets() {
1497 let config = MD064Config {
1499 allow_sentence_double_space: true,
1500 };
1501 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1502
1503 let content = "Il a dit «Oui.» Puis il est parti.";
1504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1505 let result = rule.check(&ctx).unwrap();
1506 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1507 }
1508
1509 #[test]
1510 fn test_sentence_double_space_multiple_sentences() {
1511 let config = MD064Config {
1513 allow_sentence_double_space: true,
1514 };
1515 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1516
1517 let content = "First. Second. Third. Fourth.";
1518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let result = rule.check(&ctx).unwrap();
1520 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1521 }
1522
1523 #[test]
1524 fn test_sentence_double_space_abbreviation_detection() {
1525 let config = MD064Config {
1527 allow_sentence_double_space: true,
1528 };
1529 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1530
1531 let content = "Dr. Smith arrived.";
1533 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1534 let result = rule.check(&ctx).unwrap();
1535 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1536
1537 let content = "Prof. Williams teaches.";
1539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1540 let result = rule.check(&ctx).unwrap();
1541 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1542
1543 let content = "Use e.g. this example.";
1545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let result = rule.check(&ctx).unwrap();
1547 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1548
1549 let content = "Acme Inc. Next company.";
1552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1553 let result = rule.check(&ctx).unwrap();
1554 assert!(
1555 result.is_empty(),
1556 "Inc. not in abbreviation list, treated as sentence end"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_sentence_double_space_default_config_has_correct_defaults() {
1562 let config = MD064Config::default();
1563 assert!(
1564 !config.allow_sentence_double_space,
1565 "Default allow_sentence_double_space should be false"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_sentence_double_space_from_config_integration() {
1571 use crate::config::Config;
1572 use std::collections::BTreeMap;
1573
1574 let mut config = Config::default();
1575 let mut values = BTreeMap::new();
1576 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1577 config.rules.insert(
1578 "MD064".to_string(),
1579 crate::config::RuleConfig { severity: None, values },
1580 );
1581
1582 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1583
1584 let content = "Sentence. Two spaces OK. But three is not.";
1586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587 let result = rule.check(&ctx).unwrap();
1588 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1589 }
1590
1591 #[test]
1592 fn test_sentence_double_space_after_inline_code() {
1593 let config = MD064Config {
1595 allow_sentence_double_space: true,
1596 };
1597 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1598
1599 let content = "Hello from `backticks`. How's it going?";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let result = rule.check(&ctx).unwrap();
1603 assert!(
1604 result.is_empty(),
1605 "Should allow 2 spaces after inline code ending with period"
1606 );
1607
1608 let content = "Use `foo` and `bar`. Next sentence.";
1610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1611 let result = rule.check(&ctx).unwrap();
1612 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1613
1614 let content = "The `code` worked! Celebrate.";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617 let result = rule.check(&ctx).unwrap();
1618 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1619
1620 let content = "Is `null` falsy? Yes.";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let result = rule.check(&ctx).unwrap();
1624 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1625
1626 let content = "The `code` is here.";
1628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1631 }
1632
1633 #[test]
1634 fn test_sentence_double_space_code_with_closing_punctuation() {
1635 let config = MD064Config {
1637 allow_sentence_double_space: true,
1638 };
1639 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1640
1641 let content = "(see `example`). Next sentence.";
1643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1644 let result = rule.check(&ctx).unwrap();
1645 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1646
1647 let content = "He said \"use `code`\". Then left.";
1649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1650 let result = rule.check(&ctx).unwrap();
1651 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1652 }
1653
1654 #[test]
1655 fn test_sentence_double_space_after_emphasis() {
1656 let config = MD064Config {
1658 allow_sentence_double_space: true,
1659 };
1660 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1661
1662 let content = "The word is *important*. Next sentence.";
1664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let result = rule.check(&ctx).unwrap();
1666 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1667
1668 let content = "The word is _important_. Next sentence.";
1670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671 let result = rule.check(&ctx).unwrap();
1672 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1673
1674 let content = "The word is **critical**. Next sentence.";
1676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1677 let result = rule.check(&ctx).unwrap();
1678 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1679
1680 let content = "The word is __critical__. Next sentence.";
1682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1683 let result = rule.check(&ctx).unwrap();
1684 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1685 }
1686
1687 #[test]
1688 fn test_sentence_double_space_after_strikethrough() {
1689 let config = MD064Config {
1691 allow_sentence_double_space: true,
1692 };
1693 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1694
1695 let content = "This is ~~wrong~~. Next sentence.";
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 let result = rule.check(&ctx).unwrap();
1698 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1699
1700 let content = "That was ~~bad~~! Learn from it.";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704 assert!(
1705 result.is_empty(),
1706 "Should allow 2 spaces after strikethrough with exclamation"
1707 );
1708 }
1709
1710 #[test]
1711 fn test_sentence_double_space_after_extended_markdown() {
1712 let config = MD064Config {
1714 allow_sentence_double_space: true,
1715 };
1716 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1717
1718 let content = "This is ==highlighted==. Next sentence.";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let result = rule.check(&ctx).unwrap();
1722 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1723
1724 let content = "E equals mc^2^. Einstein said.";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let result = rule.check(&ctx).unwrap();
1728 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1729 }
1730
1731 #[test]
1732 fn test_inline_config_allow_sentence_double_space() {
1733 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1741 let result = rule.check(&ctx).unwrap();
1742 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1743
1744 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1747
1748`<svg>`. Fortunately"#;
1749 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1750 let base_config = crate::config::Config::default();
1751 let merged_config = base_config.merge_with_inline_config(&inline_config);
1752 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1754 let result = effective_rule.check(&ctx).unwrap();
1755 assert!(
1756 result.is_empty(),
1757 "Inline config should allow double spaces after sentence"
1758 );
1759
1760 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1762
1763**scalable**. Pick"#;
1764 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1765 let merged_config = base_config.merge_with_inline_config(&inline_config);
1766 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1768 let result = effective_rule.check(&ctx).unwrap();
1769 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1770 }
1771
1772 #[test]
1773 fn test_inline_config_allow_sentence_double_space_issue_364() {
1774 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1778
1779# Title
1780
1781what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1782
1783And here is where I want to say, SVG documents are **scalable**. Pick
1784
1785That's right, no `width`, no `height`, no `viewBox`. There is no easy
1786
1787**SVG documents are scalable**. That's their whole reason for being!"#;
1788
1789 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1791 let base_config = crate::config::Config::default();
1792 let merged_config = base_config.merge_with_inline_config(&inline_config);
1793 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = effective_rule.check(&ctx).unwrap();
1796 assert!(
1797 result.is_empty(),
1798 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1799 result.len()
1800 );
1801 }
1802
1803 #[test]
1804 fn test_indented_reference_link_not_flagged() {
1805 let rule = MD064NoMultipleConsecutiveSpaces::default();
1808
1809 let content = " [label]: https://example.com";
1811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let result = rule.check(&ctx).unwrap();
1813 assert!(
1814 result.is_empty(),
1815 "Indented reference link definitions should not be flagged, got: {:?}",
1816 result
1817 .iter()
1818 .map(|w| format!("col={}: {}", w.column, &w.message))
1819 .collect::<Vec<_>>()
1820 );
1821
1822 let content = "[label]: https://example.com";
1824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let result = rule.check(&ctx).unwrap();
1826 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1827 }
1828
1829 #[test]
1830 fn test_pre_block_with_blank_line_not_flagged() {
1831 let rule = MD064NoMultipleConsecutiveSpaces::default();
1834
1835 let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 let result = rule.check(&ctx).unwrap();
1838 assert!(
1839 result.is_empty(),
1840 "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_textarea_block_with_blank_line_not_flagged() {
1846 let rule = MD064NoMultipleConsecutiveSpaces::default();
1849
1850 let content = "<textarea>\n\ninner content\n</textarea>\n";
1851 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852 let result = rule.check(&ctx).unwrap();
1853 assert!(
1854 result.is_empty(),
1855 "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_div_with_blank_line_content_is_flagged() {
1861 let rule = MD064NoMultipleConsecutiveSpaces::default();
1865
1866 let content = "<div>\ninner\n\nafter blank\n</div>\n";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let result = rule.check(&ctx).unwrap();
1869 assert!(
1870 !result.is_empty(),
1871 "MD064 must fire on content after a blank line inside a <div> block"
1872 );
1873 }
1874
1875 #[test]
1876 fn test_column_aligned_list_not_flagged_cdk_template() {
1877 let rule = MD064NoMultipleConsecutiveSpaces::default();
1882
1883 let content = "# Useful commands\n\n\
1884 - `cdk ls` list all stacks in the app\n\
1885 - `cdk synth` emits the synthesized CloudFormation template\n\
1886 - `cdk deploy` deploy this stack to your default AWS account/region\n\
1887 - `cdk diff` compare deployed stack with current state\n\
1888 - `cdk docs` open CDK documentation\n";
1889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891 assert!(
1892 result.is_empty(),
1893 "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1894 result
1895 .iter()
1896 .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1897 .collect::<Vec<_>>()
1898 );
1899
1900 let fixed = rule.fix(&ctx).unwrap();
1902 assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1903 }
1904
1905 #[test]
1906 fn test_column_aligned_two_item_list_not_flagged() {
1907 let rule = MD064NoMultipleConsecutiveSpaces::default();
1909
1910 let content = "- `a` alpha\n- `b` beta\n";
1911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1912 let result = rule.check(&ctx).unwrap();
1913 assert!(
1914 result.is_empty(),
1915 "Two-item aligned list must not be flagged, got: {result:?}"
1916 );
1917 }
1918
1919 #[test]
1920 fn test_partially_aligned_list_is_flagged_normally() {
1921 let rule = MD064NoMultipleConsecutiveSpaces::default();
1925
1926 let content = "- `a` alpha\n- short\n- `c` gamma\n";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let result = rule.check(&ctx).unwrap();
1929 assert_eq!(
1930 result.len(),
1931 2,
1932 "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1933 );
1934 }
1935
1936 #[test]
1937 fn test_single_item_list_with_multi_space_still_flagged() {
1938 let rule = MD064NoMultipleConsecutiveSpaces::default();
1941
1942 let content = "- `a` alpha\n";
1943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944 let result = rule.check(&ctx).unwrap();
1945 assert_eq!(
1946 result.len(),
1947 1,
1948 "Single-item list must still be flagged, got: {result:?}"
1949 );
1950 }
1951
1952 #[test]
1953 fn test_nested_aligned_lists_evaluated_independently() {
1954 let rule = MD064NoMultipleConsecutiveSpaces::default();
1957
1958 let content = "- `cmd1` outer one\n\
1959 \x20\x20- `sub-a` inner one\n\
1960 \x20\x20- `sub-b` inner two\n\
1961 - `cmd2` outer two\n";
1962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1963 let result = rule.check(&ctx).unwrap();
1964 assert!(
1965 result.is_empty(),
1966 "Nested column-aligned lists must not be flagged, got: {result:?}"
1967 );
1968 }
1969}