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> {
302 let mut aligned = HashSet::new();
303 for block in &ctx.list_blocks {
304 for items in ctx.list_block_item_groups(block) {
305 if items.len() < 2 {
306 continue;
307 }
308 let all_aligned = items
309 .iter()
310 .all(|&line_num| self.item_line_has_internal_alignment(ctx, line_num));
311 if all_aligned {
312 aligned.extend(items);
313 }
314 }
315 }
316 aligned
317 }
318
319 fn item_line_has_internal_alignment(&self, ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
322 let Some(line_info) = ctx.line_info(line_num) else {
323 return false;
324 };
325 let Some(item) = &line_info.list_item else {
326 return false;
327 };
328 let content = line_info.content(ctx.content);
329 if item.content_column >= content.len() {
330 return false;
331 }
332 content[item.content_column..].trim_end().contains(" ")
333 }
334
335 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
338 let trimmed = line.trim();
339
340 if !trimmed.contains('|') {
342 return false;
343 }
344
345 if trimmed.starts_with('|') || trimmed.ends_with('|') {
347 return false;
348 }
349
350 let parts: Vec<&str> = trimmed.split('|').collect();
354 if parts.len() >= 2 {
355 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
358 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
359 if first_has_content || last_has_content {
360 return true;
361 }
362 }
363
364 false
365 }
366}
367
368impl Rule for MD064NoMultipleConsecutiveSpaces {
369 fn name(&self) -> &'static str {
370 "MD064"
371 }
372
373 fn description(&self) -> &'static str {
374 "Multiple consecutive spaces"
375 }
376
377 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
378 let content = ctx.content;
379
380 if !content.contains(" ") {
382 return Ok(vec![]);
383 }
384
385 let mut warnings = Vec::new();
387 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
388
389 let aligned_lines = self.aligned_list_item_lines(ctx);
393
394 for line in ctx
396 .filtered_lines()
397 .skip_front_matter()
398 .skip_code_blocks()
399 .skip_html_blocks()
400 .skip_html_comments()
401 .skip_mkdocstrings()
402 .skip_esm_blocks()
403 .skip_jsx_expressions()
404 .skip_mdx_comments()
405 .skip_pymdown_blocks()
406 .skip_obsidian_comments()
407 {
408 if !line.content.contains(" ") {
410 continue;
411 }
412
413 if aligned_lines.contains(&line.line_num) {
417 continue;
418 }
419
420 if is_table_line(line.content) {
422 continue;
423 }
424
425 if self.is_table_without_outer_pipes(line.content) {
427 continue;
428 }
429
430 let line_start_byte = ctx.line_start_byte(line.line_num).unwrap_or(0);
431
432 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
434 let match_start = mat.start();
435 let match_end = mat.end();
436 let space_count = match_end - match_start;
437
438 if self.is_leading_indentation(line.content, match_start) {
440 continue;
441 }
442
443 if self.is_trailing_whitespace(line.content, match_end) {
445 continue;
446 }
447
448 if self.is_tab_replacement_pattern(space_count) {
451 continue;
452 }
453
454 if self.is_after_list_marker(line.content, match_start) {
456 continue;
457 }
458
459 if self.is_after_blockquote_marker(line.content, match_start) {
461 continue;
462 }
463
464 if self.is_after_footnote_marker(line.content, match_start) {
466 continue;
467 }
468
469 if self.is_reference_link_definition(line.content, match_start) {
471 continue;
472 }
473
474 if self.is_after_definition_marker(line.content, match_start) {
476 continue;
477 }
478
479 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
481 continue;
482 }
483
484 if self.config.allow_sentence_double_space
487 && space_count == 2
488 && is_after_sentence_ending(line.content, match_start)
489 {
490 continue;
491 }
492
493 let abs_byte_start = line_start_byte + match_start;
495
496 if self.is_in_code_span(&code_spans, abs_byte_start) {
498 continue;
499 }
500
501 let abs_byte_end = line_start_byte + match_end;
503
504 let replacement =
507 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
508 " ".to_string() } else {
510 " ".to_string() };
512
513 warnings.push(LintWarning {
514 rule_name: Some(self.name().to_string()),
515 message: format!("Multiple consecutive spaces ({space_count}) found"),
516 line: line.line_num,
517 column: match_start + 1, end_line: line.line_num,
519 end_column: match_end + 1, severity: Severity::Warning,
521 fix: Some(Fix::new(abs_byte_start..abs_byte_end, replacement)),
522 });
523 }
524 }
525
526 Ok(warnings)
527 }
528
529 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
530 let content = ctx.content;
531
532 if !content.contains(" ") {
534 return Ok(content.to_string());
535 }
536
537 let warnings = self.check(ctx)?;
539 let warnings =
540 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
541 if warnings.is_empty() {
542 return Ok(content.to_string());
543 }
544
545 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
547 .into_iter()
548 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
549 .collect();
550
551 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
552
553 let mut result = content.to_string();
555 for (range, replacement) in fixes {
556 if range.start < result.len() && range.end <= result.len() {
557 result.replace_range(range, &replacement);
558 }
559 }
560
561 Ok(result)
562 }
563
564 fn category(&self) -> RuleCategory {
566 RuleCategory::Whitespace
567 }
568
569 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
571 ctx.content.is_empty() || !ctx.content.contains(" ")
572 }
573
574 fn as_any(&self) -> &dyn std::any::Any {
575 self
576 }
577
578 crate::impl_rule_config_methods!(MD064Config);
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::lint_context::LintContext;
585
586 #[test]
587 fn test_basic_multiple_spaces() {
588 let rule = MD064NoMultipleConsecutiveSpaces::new();
589
590 let content = "This is a sentence with extra spaces.";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert_eq!(result.len(), 1);
595 assert_eq!(result[0].line, 1);
596 assert_eq!(result[0].column, 8); }
598
599 #[test]
600 fn test_no_issues_single_spaces() {
601 let rule = MD064NoMultipleConsecutiveSpaces::new();
602
603 let content = "This is a normal sentence with single spaces.";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(result.is_empty());
608 }
609
610 #[test]
611 fn test_skip_inline_code() {
612 let rule = MD064NoMultipleConsecutiveSpaces::new();
613
614 let content = "Use `code with spaces` for formatting.";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(result.is_empty());
619 }
620
621 #[test]
622 fn test_skip_code_blocks() {
623 let rule = MD064NoMultipleConsecutiveSpaces::new();
624
625 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(result.is_empty());
630 }
631
632 #[test]
633 fn test_skip_leading_indentation() {
634 let rule = MD064NoMultipleConsecutiveSpaces::new();
635
636 let content = " This is indented text.";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(result.is_empty());
641 }
642
643 #[test]
644 fn test_skip_trailing_spaces() {
645 let rule = MD064NoMultipleConsecutiveSpaces::new();
646
647 let content = "Line with trailing spaces \nNext line.";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert!(result.is_empty());
652 }
653
654 #[test]
655 fn test_skip_all_trailing_spaces() {
656 let rule = MD064NoMultipleConsecutiveSpaces::new();
657
658 let content = "Two spaces \nThree spaces \nFour spaces \n";
660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(result.is_empty());
663 }
664
665 #[test]
666 fn test_skip_front_matter() {
667 let rule = MD064NoMultipleConsecutiveSpaces::new();
668
669 let content = "---\ntitle: Test Title\n---\n\nContent here.";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(result.is_empty());
674 }
675
676 #[test]
677 fn test_skip_html_comments() {
678 let rule = MD064NoMultipleConsecutiveSpaces::new();
679
680 let content = "<!-- comment with spaces -->\n\nContent here.";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(result.is_empty());
685 }
686
687 #[test]
688 fn test_multiple_issues_one_line() {
689 let rule = MD064NoMultipleConsecutiveSpaces::new();
690
691 let content = "This has multiple issues.";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let result = rule.check(&ctx).unwrap();
695 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
696 }
697
698 #[test]
699 fn test_fix_collapses_spaces() {
700 let rule = MD064NoMultipleConsecutiveSpaces::new();
701
702 let content = "This is a sentence with extra spaces.";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(fixed, "This is a sentence with extra spaces.");
706 }
707
708 #[test]
709 fn test_fix_preserves_inline_code() {
710 let rule = MD064NoMultipleConsecutiveSpaces::new();
711
712 let content = "Text here `code inside` and more.";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let fixed = rule.fix(&ctx).unwrap();
715 assert_eq!(fixed, "Text here `code inside` and more.");
716 }
717
718 #[test]
719 fn test_fix_preserves_trailing_spaces() {
720 let rule = MD064NoMultipleConsecutiveSpaces::new();
721
722 let content = "Line with extra and trailing \nNext line.";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let fixed = rule.fix(&ctx).unwrap();
726 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
728 }
729
730 #[test]
731 fn test_list_items_with_extra_spaces() {
732 let rule = MD064NoMultipleConsecutiveSpaces::new();
733
734 let content = "- Item one\n- Item two\n- Item three\n";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let result = rule.check(&ctx).unwrap();
741 assert_eq!(result.len(), 2, "Should flag spaces in list items");
742 }
743
744 #[test]
745 fn test_blockquote_with_extra_spaces_in_content() {
746 let rule = MD064NoMultipleConsecutiveSpaces::new();
747
748 let content = "> Quote with extra spaces\n";
750 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
751 let result = rule.check(&ctx).unwrap();
752 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
753 }
754
755 #[test]
756 fn test_skip_blockquote_marker_spaces() {
757 let rule = MD064NoMultipleConsecutiveSpaces::new();
758
759 let content = "> Text with extra space after marker\n";
761 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let result = rule.check(&ctx).unwrap();
763 assert!(result.is_empty());
764
765 let content = "> Text with three spaces after marker\n";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert!(result.is_empty());
770
771 let content = ">> Nested blockquote\n";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert!(result.is_empty());
776 }
777
778 #[test]
779 fn test_mixed_content() {
780 let rule = MD064NoMultipleConsecutiveSpaces::new();
781
782 let content = r#"# Heading
783
784This has extra spaces.
785
786```
787code here is fine
788```
789
790- List item
791
792> Quote text
793
794Normal paragraph.
795"#;
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
800 }
801
802 #[test]
803 fn test_multibyte_utf8() {
804 let rule = MD064NoMultipleConsecutiveSpaces::new();
805
806 let content = "日本語 テスト 文字列";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx);
810 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
811
812 let warnings = result.unwrap();
813 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
814 }
815
816 #[test]
817 fn test_table_rows_skipped() {
818 let rule = MD064NoMultipleConsecutiveSpaces::new();
819
820 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert!(result.is_empty());
826 }
827
828 #[test]
829 fn test_link_text_with_extra_spaces() {
830 let rule = MD064NoMultipleConsecutiveSpaces::new();
831
832 let content = "[Link text](https://example.com)";
834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
837 }
838
839 #[test]
840 fn test_image_alt_with_extra_spaces() {
841 let rule = MD064NoMultipleConsecutiveSpaces::new();
842
843 let content = "";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
848 }
849
850 #[test]
851 fn test_skip_list_marker_spaces() {
852 let rule = MD064NoMultipleConsecutiveSpaces::new();
853
854 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(result.is_empty());
859
860 let content = "1. Item one\n2. Item two\n10. Item ten\n";
862 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
863 let result = rule.check(&ctx).unwrap();
864 assert!(result.is_empty());
865
866 let content = " * Indented item\n 1. Nested numbered item\n";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870 assert!(result.is_empty());
871 }
872
873 #[test]
874 fn test_skip_blockquoted_list_marker_spaces() {
875 let rule = MD064NoMultipleConsecutiveSpaces::new();
876
877 let content = "# Title\n\n> 1. Hello.\n> This is a list item.\n> 2. This is another list item\n";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert!(
882 result.is_empty(),
883 "Should not flag spaces after list markers in blockquotes"
884 );
885
886 let content = "> * Item one\n> - Item two\n> + Item three\n";
888 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
889 let result = rule.check(&ctx).unwrap();
890 assert!(
891 result.is_empty(),
892 "Should not flag spaces after unordered list markers in blockquotes"
893 );
894
895 let content = "> > 1. Nested blockquote list item\n";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899 assert!(
900 result.is_empty(),
901 "Should not flag spaces after list markers in nested blockquotes"
902 );
903
904 let content = "> 1) First item\n> 2) Second item\n";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908 assert!(
909 result.is_empty(),
910 "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
911 );
912
913 let content = "> 1. Item with extra space after >\n";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(
920 result.is_empty(),
921 "Should not flag list marker spaces even with extra space after blockquote marker"
922 );
923
924 let content = "> 1. Item with extra spaces in content\n";
926 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let result = rule.check(&ctx).unwrap();
928 assert_eq!(
929 result.len(),
930 1,
931 "Should still flag extra spaces in blockquoted list content"
932 );
933 }
934
935 #[test]
936 fn test_flag_spaces_in_list_content() {
937 let rule = MD064NoMultipleConsecutiveSpaces::new();
938
939 let content = "* Item with extra spaces in content\n";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.check(&ctx).unwrap();
943 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
944 }
945
946 #[test]
947 fn test_skip_reference_link_definition_spaces() {
948 let rule = MD064NoMultipleConsecutiveSpaces::new();
949
950 let content = "[ref]: https://example.com\n";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954 assert!(result.is_empty());
955
956 let content = "[reference-link]: https://example.com \"Title\"\n";
958 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959 let result = rule.check(&ctx).unwrap();
960 assert!(result.is_empty());
961 }
962
963 #[test]
964 fn test_skip_footnote_marker_spaces() {
965 let rule = MD064NoMultipleConsecutiveSpaces::new();
966
967 let content = "[^1]: Footnote with extra space\n";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert!(result.is_empty());
972
973 let content = "[^footnote-label]: This is the footnote text.\n";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(result.is_empty());
978 }
979
980 #[test]
981 fn test_skip_definition_list_marker_spaces() {
982 let rule = MD064NoMultipleConsecutiveSpaces::new();
983
984 let content = "Term\n: Definition with extra spaces\n";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert!(result.is_empty());
989
990 let content = ": Another definition\n";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994 assert!(result.is_empty());
995 }
996
997 #[test]
998 fn test_skip_task_list_checkbox_spaces() {
999 let rule = MD064NoMultipleConsecutiveSpaces::new();
1000
1001 let content = "- [ ] Task with extra space\n";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert!(result.is_empty());
1006
1007 let content = "- [x] Completed task\n";
1009 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert!(result.is_empty());
1012
1013 let content = "* [ ] Task with asterisk marker\n";
1015 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016 let result = rule.check(&ctx).unwrap();
1017 assert!(result.is_empty());
1018 }
1019
1020 #[test]
1021 fn test_skip_extended_task_checkbox_spaces_obsidian() {
1022 let rule = MD064NoMultipleConsecutiveSpaces::new();
1024
1025 let content = "- [/] In progress task\n";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
1030
1031 let content = "- [-] Cancelled task\n";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
1036
1037 let content = "- [>] Deferred task\n";
1039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1040 let result = rule.check(&ctx).unwrap();
1041 assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
1042
1043 let content = "- [<] Scheduled task\n";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1046 let result = rule.check(&ctx).unwrap();
1047 assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
1048
1049 let content = "- [?] Question task\n";
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1052 let result = rule.check(&ctx).unwrap();
1053 assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
1054
1055 let content = "- [!] Important task\n";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1058 let result = rule.check(&ctx).unwrap();
1059 assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
1060
1061 let content = "- [*] Starred task\n";
1063 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1064 let result = rule.check(&ctx).unwrap();
1065 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
1066
1067 let content = "* [/] In progress with asterisk\n";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
1072
1073 let content = "+ [-] Cancelled with plus\n";
1075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1076 let result = rule.check(&ctx).unwrap();
1077 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1078
1079 let content = "- [✓] Completed with checkmark\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 checkmark [✓]");
1084
1085 let content = "- [✗] Failed with X mark\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 X mark [✗]");
1089
1090 let content = "- [→] Forwarded with arrow\n";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1092 let result = rule.check(&ctx).unwrap();
1093 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1094 }
1095
1096 #[test]
1097 fn test_flag_extended_checkboxes_in_standard_flavor() {
1098 let rule = MD064NoMultipleConsecutiveSpaces::new();
1100
1101 let content = "- [/] In progress 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 = "- [-] Cancelled task\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 let content = "- [✓] Unicode checkbox\n";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1115 }
1116
1117 #[test]
1118 fn test_extended_checkboxes_with_indentation() {
1119 let rule = MD064NoMultipleConsecutiveSpaces::new();
1120
1121 let content = " - [/] In progress task\n";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1125 let result = rule.check(&ctx).unwrap();
1126 assert!(
1127 result.is_empty(),
1128 "Should skip space-indented extended checkbox in Obsidian"
1129 );
1130
1131 let content = " - [-] Cancelled task\n";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1134 let result = rule.check(&ctx).unwrap();
1135 assert!(
1136 result.is_empty(),
1137 "Should skip 3-space indented extended checkbox in Obsidian"
1138 );
1139
1140 let content = "- Parent item\n\t- [/] In progress task\n";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1144 let result = rule.check(&ctx).unwrap();
1145 assert!(
1146 result.is_empty(),
1147 "Should skip tab-indented nested extended checkbox in Obsidian"
1148 );
1149
1150 let content = " - [/] In progress task\n";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1155
1156 let content = " - [-] Cancelled task\n";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1161
1162 let content = "- Parent item\n\t- [-] Cancelled task\n";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166 assert_eq!(
1167 result.len(),
1168 1,
1169 "Should flag tab-indented nested [-] in Standard flavor"
1170 );
1171
1172 let content = " - [x] Completed task\n";
1174 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175 let result = rule.check(&ctx).unwrap();
1176 assert!(
1177 result.is_empty(),
1178 "Should skip indented standard [x] checkbox in Standard flavor"
1179 );
1180
1181 let content = "- Parent\n\t- [ ] Pending task\n";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185 assert!(
1186 result.is_empty(),
1187 "Should skip tab-indented nested standard [ ] checkbox"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_skip_table_without_outer_pipes() {
1193 let rule = MD064NoMultipleConsecutiveSpaces::new();
1194
1195 let content = "Col1 | Col2 | Col3\n";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199 assert!(result.is_empty());
1200
1201 let content = "--------- | --------- | ---------\n";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205 assert!(result.is_empty());
1206
1207 let content = "Data1 | Data2 | Data3\n";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(result.is_empty());
1212 }
1213
1214 #[test]
1215 fn test_flag_spaces_in_footnote_content() {
1216 let rule = MD064NoMultipleConsecutiveSpaces::new();
1217
1218 let content = "[^1]: Footnote with extra spaces in content.\n";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1223 }
1224
1225 #[test]
1226 fn test_flag_spaces_in_reference_content() {
1227 let rule = MD064NoMultipleConsecutiveSpaces::new();
1228
1229 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1231 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1232 let result = rule.check(&ctx).unwrap();
1233 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1234 }
1235
1236 #[test]
1239 fn test_sentence_double_space_disabled_by_default() {
1240 let rule = MD064NoMultipleConsecutiveSpaces::new();
1242 let content = "First sentence. Second sentence.";
1243 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244 let result = rule.check(&ctx).unwrap();
1245 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1246 }
1247
1248 #[test]
1249 fn test_sentence_double_space_enabled_allows_period() {
1250 let config = MD064Config {
1252 allow_sentence_double_space: true,
1253 };
1254 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1255
1256 let content = "First sentence. Second sentence.";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(result.is_empty(), "Should allow 2 spaces after period");
1260 }
1261
1262 #[test]
1263 fn test_sentence_double_space_enabled_allows_exclamation() {
1264 let config = MD064Config {
1265 allow_sentence_double_space: true,
1266 };
1267 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1268
1269 let content = "Wow! That was great.";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1273 }
1274
1275 #[test]
1276 fn test_sentence_double_space_enabled_allows_question() {
1277 let config = MD064Config {
1278 allow_sentence_double_space: true,
1279 };
1280 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1281
1282 let content = "Is this OK? Yes it is.";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1286 }
1287
1288 #[test]
1289 fn test_sentence_double_space_flags_mid_sentence() {
1290 let config = MD064Config {
1292 allow_sentence_double_space: true,
1293 };
1294 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1295
1296 let content = "Word word in the middle.";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1300 }
1301
1302 #[test]
1303 fn test_sentence_double_space_flags_triple_after_period() {
1304 let config = MD064Config {
1306 allow_sentence_double_space: true,
1307 };
1308 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1309
1310 let content = "First sentence. Three spaces here.";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1314 }
1315
1316 #[test]
1317 fn test_sentence_double_space_with_closing_quote() {
1318 let config = MD064Config {
1320 allow_sentence_double_space: true,
1321 };
1322 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1323
1324 let content = r#"He said "Hello." Then he left."#;
1325 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326 let result = rule.check(&ctx).unwrap();
1327 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1328
1329 let content = "She said 'Goodbye.' And she was gone.";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let result = rule.check(&ctx).unwrap();
1333 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1334 }
1335
1336 #[test]
1337 fn test_sentence_double_space_with_curly_quotes() {
1338 let config = MD064Config {
1339 allow_sentence_double_space: true,
1340 };
1341 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1342
1343 let content = format!(
1346 "He said {}Hello.{} Then left.",
1347 '\u{201C}', '\u{201D}' );
1350 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1353
1354 let content = format!(
1356 "She said {}Hi.{} And left.",
1357 '\u{2018}', '\u{2019}' );
1360 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1361 let result = rule.check(&ctx).unwrap();
1362 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1363 }
1364
1365 #[test]
1366 fn test_sentence_double_space_with_closing_paren() {
1367 let config = MD064Config {
1368 allow_sentence_double_space: true,
1369 };
1370 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1371
1372 let content = "(See reference.) The next point is.";
1373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1374 let result = rule.check(&ctx).unwrap();
1375 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1376 }
1377
1378 #[test]
1379 fn test_sentence_double_space_with_closing_bracket() {
1380 let config = MD064Config {
1381 allow_sentence_double_space: true,
1382 };
1383 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1384
1385 let content = "[Citation needed.] More text here.";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let result = rule.check(&ctx).unwrap();
1388 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1389 }
1390
1391 #[test]
1392 fn test_sentence_double_space_with_ellipsis() {
1393 let config = MD064Config {
1394 allow_sentence_double_space: true,
1395 };
1396 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1397
1398 let content = "He paused... Then continued.";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let result = rule.check(&ctx).unwrap();
1401 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1402 }
1403
1404 #[test]
1405 fn test_sentence_double_space_complex_ending() {
1406 let config = MD064Config {
1408 allow_sentence_double_space: true,
1409 };
1410 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1411
1412 let content = r#"(He said "Yes.") Then they agreed."#;
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 let result = rule.check(&ctx).unwrap();
1415 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1416 }
1417
1418 #[test]
1419 fn test_sentence_double_space_mixed_content() {
1420 let config = MD064Config {
1422 allow_sentence_double_space: true,
1423 };
1424 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1425
1426 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1428 let result = rule.check(&ctx).unwrap();
1429 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1430 assert!(
1431 result[0].column > 15 && result[0].column < 25,
1432 "Should flag the 'Bad mid' double space"
1433 );
1434 }
1435
1436 #[test]
1437 fn test_sentence_double_space_fix_collapses_to_two() {
1438 let config = MD064Config {
1440 allow_sentence_double_space: true,
1441 };
1442 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1443
1444 let content = "Sentence. Three spaces here.";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let fixed = rule.fix(&ctx).unwrap();
1447 assert_eq!(
1448 fixed, "Sentence. Three spaces here.",
1449 "Should collapse to 2 spaces after sentence"
1450 );
1451 }
1452
1453 #[test]
1454 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1455 let config = MD064Config {
1457 allow_sentence_double_space: true,
1458 };
1459 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1460
1461 let content = "Word word here.";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let fixed = rule.fix(&ctx).unwrap();
1464 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1465 }
1466
1467 #[test]
1468 fn test_sentence_double_space_config_kebab_case() {
1469 let toml_str = r#"
1470 allow-sentence-double-space = true
1471 "#;
1472 let config: MD064Config = toml::from_str(toml_str).unwrap();
1473 assert!(config.allow_sentence_double_space);
1474 }
1475
1476 #[test]
1477 fn test_sentence_double_space_config_snake_case() {
1478 let toml_str = r#"
1479 allow_sentence_double_space = true
1480 "#;
1481 let config: MD064Config = toml::from_str(toml_str).unwrap();
1482 assert!(config.allow_sentence_double_space);
1483 }
1484
1485 #[test]
1486 fn test_sentence_double_space_at_line_start() {
1487 let config = MD064Config {
1489 allow_sentence_double_space: true,
1490 };
1491 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1492
1493 let content = ". Text after period at start.";
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496 let _result = rule.check(&ctx).unwrap();
1498 }
1499
1500 #[test]
1501 fn test_sentence_double_space_guillemets() {
1502 let config = MD064Config {
1504 allow_sentence_double_space: true,
1505 };
1506 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1507
1508 let content = "Il a dit «Oui.» Puis il est parti.";
1509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1510 let result = rule.check(&ctx).unwrap();
1511 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1512 }
1513
1514 #[test]
1515 fn test_sentence_double_space_multiple_sentences() {
1516 let config = MD064Config {
1518 allow_sentence_double_space: true,
1519 };
1520 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1521
1522 let content = "First. Second. Third. Fourth.";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1526 }
1527
1528 #[test]
1529 fn test_sentence_double_space_abbreviation_detection() {
1530 let config = MD064Config {
1532 allow_sentence_double_space: true,
1533 };
1534 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1535
1536 let content = "Dr. Smith arrived.";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1541
1542 let content = "Prof. Williams teaches.";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let result = rule.check(&ctx).unwrap();
1546 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1547
1548 let content = "Use e.g. this example.";
1550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551 let result = rule.check(&ctx).unwrap();
1552 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1553
1554 let content = "Acme Inc. Next company.";
1557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1558 let result = rule.check(&ctx).unwrap();
1559 assert!(
1560 result.is_empty(),
1561 "Inc. not in abbreviation list, treated as sentence end"
1562 );
1563 }
1564
1565 #[test]
1566 fn test_sentence_double_space_default_config_has_correct_defaults() {
1567 let config = MD064Config::default();
1568 assert!(
1569 !config.allow_sentence_double_space,
1570 "Default allow_sentence_double_space should be false"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_sentence_double_space_from_config_integration() {
1576 use crate::config::Config;
1577 use std::collections::BTreeMap;
1578
1579 let mut config = Config::default();
1580 let mut values = BTreeMap::new();
1581 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1582 config.rules.insert(
1583 "MD064".to_string(),
1584 crate::config::RuleConfig { severity: None, values },
1585 );
1586
1587 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1588
1589 let content = "Sentence. Two spaces OK. But three is not.";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1594 }
1595
1596 #[test]
1597 fn test_sentence_double_space_after_inline_code() {
1598 let config = MD064Config {
1600 allow_sentence_double_space: true,
1601 };
1602 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1603
1604 let content = "Hello from `backticks`. How's it going?";
1606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1607 let result = rule.check(&ctx).unwrap();
1608 assert!(
1609 result.is_empty(),
1610 "Should allow 2 spaces after inline code ending with period"
1611 );
1612
1613 let content = "Use `foo` and `bar`. Next sentence.";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let result = rule.check(&ctx).unwrap();
1617 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1618
1619 let content = "The `code` worked! Celebrate.";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1624
1625 let content = "Is `null` falsy? Yes.";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let result = rule.check(&ctx).unwrap();
1629 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1630
1631 let content = "The `code` is here.";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1636 }
1637
1638 #[test]
1639 fn test_sentence_double_space_code_with_closing_punctuation() {
1640 let config = MD064Config {
1642 allow_sentence_double_space: true,
1643 };
1644 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1645
1646 let content = "(see `example`). Next sentence.";
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649 let result = rule.check(&ctx).unwrap();
1650 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1651
1652 let content = "He said \"use `code`\". Then left.";
1654 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1657 }
1658
1659 #[test]
1660 fn test_sentence_double_space_after_emphasis() {
1661 let config = MD064Config {
1663 allow_sentence_double_space: true,
1664 };
1665 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1666
1667 let content = "The word is *important*. Next sentence.";
1669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1670 let result = rule.check(&ctx).unwrap();
1671 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1672
1673 let content = "The word is _important_. Next sentence.";
1675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1676 let result = rule.check(&ctx).unwrap();
1677 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1678
1679 let content = "The word is **critical**. Next sentence.";
1681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1682 let result = rule.check(&ctx).unwrap();
1683 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1684
1685 let content = "The word is __critical__. Next sentence.";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let result = rule.check(&ctx).unwrap();
1689 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1690 }
1691
1692 #[test]
1693 fn test_sentence_double_space_after_strikethrough() {
1694 let config = MD064Config {
1696 allow_sentence_double_space: true,
1697 };
1698 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1699
1700 let content = "This is ~~wrong~~. Next sentence.";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1704
1705 let content = "That was ~~bad~~! Learn from it.";
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 let result = rule.check(&ctx).unwrap();
1709 assert!(
1710 result.is_empty(),
1711 "Should allow 2 spaces after strikethrough with exclamation"
1712 );
1713 }
1714
1715 #[test]
1716 fn test_sentence_double_space_after_extended_markdown() {
1717 let config = MD064Config {
1719 allow_sentence_double_space: true,
1720 };
1721 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1722
1723 let content = "This is ==highlighted==. Next sentence.";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1728
1729 let content = "E equals mc^2^. Einstein said.";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let result = rule.check(&ctx).unwrap();
1733 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1734 }
1735
1736 #[test]
1737 fn test_inline_config_allow_sentence_double_space() {
1738 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1746 let result = rule.check(&ctx).unwrap();
1747 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1748
1749 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1752
1753`<svg>`. Fortunately"#;
1754 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1755 let base_config = crate::config::Config::default();
1756 let merged_config = base_config.merge_with_inline_config(&inline_config);
1757 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759 let result = effective_rule.check(&ctx).unwrap();
1760 assert!(
1761 result.is_empty(),
1762 "Inline config should allow double spaces after sentence"
1763 );
1764
1765 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1767
1768**scalable**. Pick"#;
1769 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1770 let merged_config = base_config.merge_with_inline_config(&inline_config);
1771 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = effective_rule.check(&ctx).unwrap();
1774 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1775 }
1776
1777 #[test]
1778 fn test_inline_config_allow_sentence_double_space_issue_364() {
1779 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1783
1784# Title
1785
1786what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1787
1788And here is where I want to say, SVG documents are **scalable**. Pick
1789
1790That's right, no `width`, no `height`, no `viewBox`. There is no easy
1791
1792**SVG documents are scalable**. That's their whole reason for being!"#;
1793
1794 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1796 let base_config = crate::config::Config::default();
1797 let merged_config = base_config.merge_with_inline_config(&inline_config);
1798 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1800 let result = effective_rule.check(&ctx).unwrap();
1801 assert!(
1802 result.is_empty(),
1803 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1804 result.len()
1805 );
1806 }
1807
1808 #[test]
1809 fn test_indented_reference_link_not_flagged() {
1810 let rule = MD064NoMultipleConsecutiveSpaces::default();
1813
1814 let content = " [label]: https://example.com";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let result = rule.check(&ctx).unwrap();
1818 assert!(
1819 result.is_empty(),
1820 "Indented reference link definitions should not be flagged, got: {:?}",
1821 result
1822 .iter()
1823 .map(|w| format!("col={}: {}", w.column, &w.message))
1824 .collect::<Vec<_>>()
1825 );
1826
1827 let content = "[label]: https://example.com";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let result = rule.check(&ctx).unwrap();
1831 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1832 }
1833
1834 #[test]
1835 fn test_pre_block_with_blank_line_not_flagged() {
1836 let rule = MD064NoMultipleConsecutiveSpaces::default();
1839
1840 let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
1841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let result = rule.check(&ctx).unwrap();
1843 assert!(
1844 result.is_empty(),
1845 "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1846 );
1847 }
1848
1849 #[test]
1850 fn test_textarea_block_with_blank_line_not_flagged() {
1851 let rule = MD064NoMultipleConsecutiveSpaces::default();
1854
1855 let content = "<textarea>\n\ninner content\n</textarea>\n";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let result = rule.check(&ctx).unwrap();
1858 assert!(
1859 result.is_empty(),
1860 "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_div_with_blank_line_content_is_flagged() {
1866 let rule = MD064NoMultipleConsecutiveSpaces::default();
1870
1871 let content = "<div>\ninner\n\nafter blank\n</div>\n";
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873 let result = rule.check(&ctx).unwrap();
1874 assert!(
1875 !result.is_empty(),
1876 "MD064 must fire on content after a blank line inside a <div> block"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_column_aligned_list_not_flagged_cdk_template() {
1882 let rule = MD064NoMultipleConsecutiveSpaces::default();
1887
1888 let content = "# Useful commands\n\n\
1889 - `cdk ls` list all stacks in the app\n\
1890 - `cdk synth` emits the synthesized CloudFormation template\n\
1891 - `cdk deploy` deploy this stack to your default AWS account/region\n\
1892 - `cdk diff` compare deployed stack with current state\n\
1893 - `cdk docs` open CDK documentation\n";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896 assert!(
1897 result.is_empty(),
1898 "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1899 result
1900 .iter()
1901 .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1902 .collect::<Vec<_>>()
1903 );
1904
1905 let fixed = rule.fix(&ctx).unwrap();
1907 assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1908 }
1909
1910 #[test]
1911 fn test_column_aligned_two_item_list_not_flagged() {
1912 let rule = MD064NoMultipleConsecutiveSpaces::default();
1914
1915 let content = "- `a` alpha\n- `b` beta\n";
1916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1917 let result = rule.check(&ctx).unwrap();
1918 assert!(
1919 result.is_empty(),
1920 "Two-item aligned list must not be flagged, got: {result:?}"
1921 );
1922 }
1923
1924 #[test]
1925 fn test_partially_aligned_list_is_flagged_normally() {
1926 let rule = MD064NoMultipleConsecutiveSpaces::default();
1930
1931 let content = "- `a` alpha\n- short\n- `c` gamma\n";
1932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1933 let result = rule.check(&ctx).unwrap();
1934 assert_eq!(
1935 result.len(),
1936 2,
1937 "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn test_single_item_list_with_multi_space_still_flagged() {
1943 let rule = MD064NoMultipleConsecutiveSpaces::default();
1946
1947 let content = "- `a` alpha\n";
1948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1949 let result = rule.check(&ctx).unwrap();
1950 assert_eq!(
1951 result.len(),
1952 1,
1953 "Single-item list must still be flagged, got: {result:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_nested_aligned_lists_evaluated_independently() {
1959 let rule = MD064NoMultipleConsecutiveSpaces::default();
1962
1963 let content = "- `cmd1` outer one\n\
1964 \x20\x20- `sub-a` inner one\n\
1965 \x20\x20- `sub-b` inner two\n\
1966 - `cmd2` outer two\n";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let result = rule.check(&ctx).unwrap();
1969 assert!(
1970 result.is_empty(),
1971 "Nested column-aligned lists must not be flagged, got: {result:?}"
1972 );
1973 }
1974
1975 #[test]
1976 fn test_siblings_at_a_different_indent_share_the_alignment_exemption() {
1977 let rule = MD064NoMultipleConsecutiveSpaces::default();
1981 for content in [
1982 " - `cmd1` one\n - `cmd2` two\n",
1983 " - `cmd1` one\n - sub\n - `cmd2` two\n",
1984 ] {
1985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986 let result = rule.check(&ctx).unwrap();
1987 assert!(result.is_empty(), "{content:?}: {result:?}");
1988 assert_eq!(
1989 rule.fix(&ctx).unwrap(),
1990 content,
1991 "{content:?}: fix must keep the alignment"
1992 );
1993 }
1994
1995 for (content, line) in [
2000 (" - `cmd1` one\n - `cmd2` two\n", 1),
2001 (" - `cmd1` one\n - `sub` padded\n - `cmd2` two\n", 2),
2002 ] {
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let result = rule.check(&ctx).unwrap();
2005 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2006 assert_eq!(result[0].line, line, "{content:?}");
2007 }
2008
2009 let content = " > - `a` parent\n> - `b` child\n";
2013 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let result = rule.check(&ctx).unwrap();
2015 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2016 assert_eq!(result[0].line, 1, "{content:?}");
2017 assert_eq!(result[1].line, 2, "{content:?}");
2018 let content = "> - `a` parent\n> - `b` child\n";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let result = rule.check(&ctx).unwrap();
2022 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2023
2024 let content = "- p\n >- `b` x\n>- `c` x\n";
2028 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2029 let result = rule.check(&ctx).unwrap();
2030 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2031 assert_eq!(result[0].line, 2, "{content:?}");
2032 assert_eq!(result[1].line, 3, "{content:?}");
2033
2034 let content = "- p\n```\n```\n > - `b` x\n lazy\n> - `c` x\n";
2039 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2040 let result = rule.check(&ctx).unwrap();
2041 assert!(result.is_empty(), "{content:?}: {result:?}");
2042 assert_eq!(
2043 rule.fix(&ctx).unwrap(),
2044 content,
2045 "{content:?}: fix must keep the alignment"
2046 );
2047 let content = "- p\n > - `b` x\n lazy\n> - `c` x\n";
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049 let result = rule.check(&ctx).unwrap();
2050 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2051 assert_eq!(result[0].line, 2, "{content:?}");
2052 assert_eq!(result[1].line, 4, "{content:?}");
2053 }
2054
2055 #[test]
2056 fn test_unaligned_nested_item_does_not_break_parent_alignment() {
2057 let rule = MD064NoMultipleConsecutiveSpaces::default();
2062 for (label, content) in [
2063 (
2064 "spaces",
2065 "- first aligned\n- second aligned\n- third aligned\n 1. child\n",
2066 ),
2067 (
2068 "tab",
2069 "- first aligned\n- second aligned\n- third aligned\n\t1. child\n",
2070 ),
2071 ] {
2072 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let result = rule.check(&ctx).unwrap();
2074 assert!(result.is_empty(), "{label}: {result:?}");
2075 assert_eq!(rule.fix(&ctx).unwrap(), content, "{label}: fix must keep the alignment");
2076 }
2077
2078 let content = "- first aligned\n- second aligned\n 1. child padded\n";
2081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2082 let result = rule.check(&ctx).unwrap();
2083 assert_eq!(result.len(), 1, "{result:?}");
2084 assert_eq!(result[0].line, 3);
2085
2086 for (content, line) in [
2091 (
2092 "- parent\n - a aligned\n - b aligned\n <!-- parent block -->\n - only padded\n- next\n",
2093 5,
2094 ),
2095 (
2096 "- parent\n - a aligned\n - b aligned\n >\n - only padded\n- next\n",
2097 5,
2098 ),
2099 (
2100 "- parent\n - a aligned\n - b aligned\n >\n parent\n - only padded\n- next\n",
2101 6,
2102 ),
2103 ] {
2104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2105 let result = rule.check(&ctx).unwrap();
2106 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2107 assert_eq!(result[0].line, line, "{content:?}");
2108 }
2109 }
2110}