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 let line_index = &ctx.line_index;
384
385 let aligned_lines = self.aligned_list_item_lines(ctx);
389
390 for line in ctx
392 .filtered_lines()
393 .skip_front_matter()
394 .skip_code_blocks()
395 .skip_html_blocks()
396 .skip_html_comments()
397 .skip_mkdocstrings()
398 .skip_esm_blocks()
399 .skip_jsx_expressions()
400 .skip_mdx_comments()
401 .skip_pymdown_blocks()
402 .skip_obsidian_comments()
403 {
404 if !line.content.contains(" ") {
406 continue;
407 }
408
409 if aligned_lines.contains(&line.line_num) {
413 continue;
414 }
415
416 if is_table_line(line.content) {
418 continue;
419 }
420
421 if self.is_table_without_outer_pipes(line.content) {
423 continue;
424 }
425
426 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
427
428 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
430 let match_start = mat.start();
431 let match_end = mat.end();
432 let space_count = match_end - match_start;
433
434 if self.is_leading_indentation(line.content, match_start) {
436 continue;
437 }
438
439 if self.is_trailing_whitespace(line.content, match_end) {
441 continue;
442 }
443
444 if self.is_tab_replacement_pattern(space_count) {
447 continue;
448 }
449
450 if self.is_after_list_marker(line.content, match_start) {
452 continue;
453 }
454
455 if self.is_after_blockquote_marker(line.content, match_start) {
457 continue;
458 }
459
460 if self.is_after_footnote_marker(line.content, match_start) {
462 continue;
463 }
464
465 if self.is_reference_link_definition(line.content, match_start) {
467 continue;
468 }
469
470 if self.is_after_definition_marker(line.content, match_start) {
472 continue;
473 }
474
475 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
477 continue;
478 }
479
480 if self.config.allow_sentence_double_space
483 && space_count == 2
484 && is_after_sentence_ending(line.content, match_start)
485 {
486 continue;
487 }
488
489 let abs_byte_start = line_start_byte + match_start;
491
492 if self.is_in_code_span(&code_spans, abs_byte_start) {
494 continue;
495 }
496
497 let abs_byte_end = line_start_byte + match_end;
499
500 let replacement =
503 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
504 " ".to_string() } else {
506 " ".to_string() };
508
509 warnings.push(LintWarning {
510 rule_name: Some(self.name().to_string()),
511 message: format!("Multiple consecutive spaces ({space_count}) found"),
512 line: line.line_num,
513 column: match_start + 1, end_line: line.line_num,
515 end_column: match_end + 1, severity: Severity::Warning,
517 fix: Some(Fix::new(abs_byte_start..abs_byte_end, replacement)),
518 });
519 }
520 }
521
522 Ok(warnings)
523 }
524
525 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
526 let content = ctx.content;
527
528 if !content.contains(" ") {
530 return Ok(content.to_string());
531 }
532
533 let warnings = self.check(ctx)?;
535 let warnings =
536 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
537 if warnings.is_empty() {
538 return Ok(content.to_string());
539 }
540
541 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
543 .into_iter()
544 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
545 .collect();
546
547 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
548
549 let mut result = content.to_string();
551 for (range, replacement) in fixes {
552 if range.start < result.len() && range.end <= result.len() {
553 result.replace_range(range, &replacement);
554 }
555 }
556
557 Ok(result)
558 }
559
560 fn category(&self) -> RuleCategory {
562 RuleCategory::Whitespace
563 }
564
565 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
567 ctx.content.is_empty() || !ctx.content.contains(" ")
568 }
569
570 fn as_any(&self) -> &dyn std::any::Any {
571 self
572 }
573
574 crate::impl_rule_config_methods!(MD064Config);
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::lint_context::LintContext;
581
582 #[test]
583 fn test_basic_multiple_spaces() {
584 let rule = MD064NoMultipleConsecutiveSpaces::new();
585
586 let content = "This is a sentence with extra spaces.";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
589 let result = rule.check(&ctx).unwrap();
590 assert_eq!(result.len(), 1);
591 assert_eq!(result[0].line, 1);
592 assert_eq!(result[0].column, 8); }
594
595 #[test]
596 fn test_no_issues_single_spaces() {
597 let rule = MD064NoMultipleConsecutiveSpaces::new();
598
599 let content = "This is a normal sentence with single spaces.";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert!(result.is_empty());
604 }
605
606 #[test]
607 fn test_skip_inline_code() {
608 let rule = MD064NoMultipleConsecutiveSpaces::new();
609
610 let content = "Use `code with spaces` for formatting.";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(result.is_empty());
615 }
616
617 #[test]
618 fn test_skip_code_blocks() {
619 let rule = MD064NoMultipleConsecutiveSpaces::new();
620
621 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert!(result.is_empty());
626 }
627
628 #[test]
629 fn test_skip_leading_indentation() {
630 let rule = MD064NoMultipleConsecutiveSpaces::new();
631
632 let content = " This is indented text.";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty());
637 }
638
639 #[test]
640 fn test_skip_trailing_spaces() {
641 let rule = MD064NoMultipleConsecutiveSpaces::new();
642
643 let content = "Line with trailing spaces \nNext line.";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert!(result.is_empty());
648 }
649
650 #[test]
651 fn test_skip_all_trailing_spaces() {
652 let rule = MD064NoMultipleConsecutiveSpaces::new();
653
654 let content = "Two spaces \nThree spaces \nFour spaces \n";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert!(result.is_empty());
659 }
660
661 #[test]
662 fn test_skip_front_matter() {
663 let rule = MD064NoMultipleConsecutiveSpaces::new();
664
665 let content = "---\ntitle: Test Title\n---\n\nContent here.";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert!(result.is_empty());
670 }
671
672 #[test]
673 fn test_skip_html_comments() {
674 let rule = MD064NoMultipleConsecutiveSpaces::new();
675
676 let content = "<!-- comment with spaces -->\n\nContent here.";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680 assert!(result.is_empty());
681 }
682
683 #[test]
684 fn test_multiple_issues_one_line() {
685 let rule = MD064NoMultipleConsecutiveSpaces::new();
686
687 let content = "This has multiple issues.";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
692 }
693
694 #[test]
695 fn test_fix_collapses_spaces() {
696 let rule = MD064NoMultipleConsecutiveSpaces::new();
697
698 let content = "This is a sentence with extra spaces.";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let fixed = rule.fix(&ctx).unwrap();
701 assert_eq!(fixed, "This is a sentence with extra spaces.");
702 }
703
704 #[test]
705 fn test_fix_preserves_inline_code() {
706 let rule = MD064NoMultipleConsecutiveSpaces::new();
707
708 let content = "Text here `code inside` and more.";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let fixed = rule.fix(&ctx).unwrap();
711 assert_eq!(fixed, "Text here `code inside` and more.");
712 }
713
714 #[test]
715 fn test_fix_preserves_trailing_spaces() {
716 let rule = MD064NoMultipleConsecutiveSpaces::new();
717
718 let content = "Line with extra and trailing \nNext line.";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let fixed = rule.fix(&ctx).unwrap();
722 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
724 }
725
726 #[test]
727 fn test_list_items_with_extra_spaces() {
728 let rule = MD064NoMultipleConsecutiveSpaces::new();
729
730 let content = "- Item one\n- Item two\n- Item three\n";
735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert_eq!(result.len(), 2, "Should flag spaces in list items");
738 }
739
740 #[test]
741 fn test_blockquote_with_extra_spaces_in_content() {
742 let rule = MD064NoMultipleConsecutiveSpaces::new();
743
744 let content = "> Quote with extra spaces\n";
746 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
749 }
750
751 #[test]
752 fn test_skip_blockquote_marker_spaces() {
753 let rule = MD064NoMultipleConsecutiveSpaces::new();
754
755 let content = "> Text with extra space after marker\n";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert!(result.is_empty());
760
761 let content = "> Text with three spaces after marker\n";
763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = rule.check(&ctx).unwrap();
765 assert!(result.is_empty());
766
767 let content = ">> Nested blockquote\n";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(result.is_empty());
772 }
773
774 #[test]
775 fn test_mixed_content() {
776 let rule = MD064NoMultipleConsecutiveSpaces::new();
777
778 let content = r#"# Heading
779
780This has extra spaces.
781
782```
783code here is fine
784```
785
786- List item
787
788> Quote text
789
790Normal paragraph.
791"#;
792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
793 let result = rule.check(&ctx).unwrap();
794 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
796 }
797
798 #[test]
799 fn test_multibyte_utf8() {
800 let rule = MD064NoMultipleConsecutiveSpaces::new();
801
802 let content = "日本語 テスト 文字列";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx);
806 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
807
808 let warnings = result.unwrap();
809 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
810 }
811
812 #[test]
813 fn test_table_rows_skipped() {
814 let rule = MD064NoMultipleConsecutiveSpaces::new();
815
816 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert!(result.is_empty());
822 }
823
824 #[test]
825 fn test_link_text_with_extra_spaces() {
826 let rule = MD064NoMultipleConsecutiveSpaces::new();
827
828 let content = "[Link text](https://example.com)";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
833 }
834
835 #[test]
836 fn test_image_alt_with_extra_spaces() {
837 let rule = MD064NoMultipleConsecutiveSpaces::new();
838
839 let content = "";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
844 }
845
846 #[test]
847 fn test_skip_list_marker_spaces() {
848 let rule = MD064NoMultipleConsecutiveSpaces::new();
849
850 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854 assert!(result.is_empty());
855
856 let content = "1. Item one\n2. Item two\n10. Item ten\n";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860 assert!(result.is_empty());
861
862 let content = " * Indented item\n 1. Nested numbered item\n";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866 assert!(result.is_empty());
867 }
868
869 #[test]
870 fn test_skip_blockquoted_list_marker_spaces() {
871 let rule = MD064NoMultipleConsecutiveSpaces::new();
872
873 let content = "# Title\n\n> 1. Hello.\n> This is a list item.\n> 2. This is another list item\n";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877 assert!(
878 result.is_empty(),
879 "Should not flag spaces after list markers in blockquotes"
880 );
881
882 let content = "> * Item one\n> - Item two\n> + Item three\n";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886 assert!(
887 result.is_empty(),
888 "Should not flag spaces after unordered list markers in blockquotes"
889 );
890
891 let content = "> > 1. Nested blockquote list item\n";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895 assert!(
896 result.is_empty(),
897 "Should not flag spaces after list markers in nested blockquotes"
898 );
899
900 let content = "> 1) First item\n> 2) Second item\n";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904 assert!(
905 result.is_empty(),
906 "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
907 );
908
909 let content = "> 1. Item with extra space after >\n";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913 assert!(
916 result.is_empty(),
917 "Should not flag list marker spaces even with extra space after blockquote marker"
918 );
919
920 let content = "> 1. Item with extra spaces in content\n";
922 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923 let result = rule.check(&ctx).unwrap();
924 assert_eq!(
925 result.len(),
926 1,
927 "Should still flag extra spaces in blockquoted list content"
928 );
929 }
930
931 #[test]
932 fn test_flag_spaces_in_list_content() {
933 let rule = MD064NoMultipleConsecutiveSpaces::new();
934
935 let content = "* Item with extra spaces in content\n";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
940 }
941
942 #[test]
943 fn test_skip_reference_link_definition_spaces() {
944 let rule = MD064NoMultipleConsecutiveSpaces::new();
945
946 let content = "[ref]: https://example.com\n";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950 assert!(result.is_empty());
951
952 let content = "[reference-link]: https://example.com \"Title\"\n";
954 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955 let result = rule.check(&ctx).unwrap();
956 assert!(result.is_empty());
957 }
958
959 #[test]
960 fn test_skip_footnote_marker_spaces() {
961 let rule = MD064NoMultipleConsecutiveSpaces::new();
962
963 let content = "[^1]: Footnote with extra space\n";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967 assert!(result.is_empty());
968
969 let content = "[^footnote-label]: This is the footnote text.\n";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973 assert!(result.is_empty());
974 }
975
976 #[test]
977 fn test_skip_definition_list_marker_spaces() {
978 let rule = MD064NoMultipleConsecutiveSpaces::new();
979
980 let content = "Term\n: Definition with extra spaces\n";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984 assert!(result.is_empty());
985
986 let content = ": Another definition\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert!(result.is_empty());
991 }
992
993 #[test]
994 fn test_skip_task_list_checkbox_spaces() {
995 let rule = MD064NoMultipleConsecutiveSpaces::new();
996
997 let content = "- [ ] Task with extra space\n";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001 assert!(result.is_empty());
1002
1003 let content = "- [x] Completed task\n";
1005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006 let result = rule.check(&ctx).unwrap();
1007 assert!(result.is_empty());
1008
1009 let content = "* [ ] Task with asterisk marker\n";
1011 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert!(result.is_empty());
1014 }
1015
1016 #[test]
1017 fn test_skip_extended_task_checkbox_spaces_obsidian() {
1018 let rule = MD064NoMultipleConsecutiveSpaces::new();
1020
1021 let content = "- [/] In progress task\n";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
1026
1027 let content = "- [-] Cancelled task\n";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1030 let result = rule.check(&ctx).unwrap();
1031 assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
1032
1033 let content = "- [>] Deferred task\n";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1036 let result = rule.check(&ctx).unwrap();
1037 assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
1038
1039 let content = "- [<] Scheduled task\n";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1042 let result = rule.check(&ctx).unwrap();
1043 assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
1044
1045 let content = "- [?] Question task\n";
1047 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1048 let result = rule.check(&ctx).unwrap();
1049 assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
1050
1051 let content = "- [!] Important task\n";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1054 let result = rule.check(&ctx).unwrap();
1055 assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
1056
1057 let content = "- [*] Starred task\n";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1060 let result = rule.check(&ctx).unwrap();
1061 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
1062
1063 let content = "* [/] In progress with asterisk\n";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1066 let result = rule.check(&ctx).unwrap();
1067 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
1068
1069 let content = "+ [-] Cancelled with plus\n";
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1072 let result = rule.check(&ctx).unwrap();
1073 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1074
1075 let content = "- [✓] Completed with checkmark\n";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1078 let result = rule.check(&ctx).unwrap();
1079 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
1080
1081 let content = "- [✗] Failed with X mark\n";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1083 let result = rule.check(&ctx).unwrap();
1084 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
1085
1086 let content = "- [→] Forwarded with arrow\n";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1088 let result = rule.check(&ctx).unwrap();
1089 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1090 }
1091
1092 #[test]
1093 fn test_flag_extended_checkboxes_in_standard_flavor() {
1094 let rule = MD064NoMultipleConsecutiveSpaces::new();
1096
1097 let content = "- [/] In progress task\n";
1098 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1099 let result = rule.check(&ctx).unwrap();
1100 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
1101
1102 let content = "- [-] Cancelled task\n";
1103 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104 let result = rule.check(&ctx).unwrap();
1105 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1106
1107 let content = "- [✓] Unicode checkbox\n";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1111 }
1112
1113 #[test]
1114 fn test_extended_checkboxes_with_indentation() {
1115 let rule = MD064NoMultipleConsecutiveSpaces::new();
1116
1117 let content = " - [/] In progress task\n";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(
1123 result.is_empty(),
1124 "Should skip space-indented extended checkbox in Obsidian"
1125 );
1126
1127 let content = " - [-] Cancelled task\n";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1130 let result = rule.check(&ctx).unwrap();
1131 assert!(
1132 result.is_empty(),
1133 "Should skip 3-space indented extended checkbox in Obsidian"
1134 );
1135
1136 let content = "- Parent item\n\t- [/] In progress task\n";
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1140 let result = rule.check(&ctx).unwrap();
1141 assert!(
1142 result.is_empty(),
1143 "Should skip tab-indented nested extended checkbox in Obsidian"
1144 );
1145
1146 let content = " - [/] In progress task\n";
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 let result = rule.check(&ctx).unwrap();
1150 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1151
1152 let content = " - [-] Cancelled task\n";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1157
1158 let content = "- Parent item\n\t- [-] Cancelled task\n";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert_eq!(
1163 result.len(),
1164 1,
1165 "Should flag tab-indented nested [-] in Standard flavor"
1166 );
1167
1168 let content = " - [x] Completed task\n";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let result = rule.check(&ctx).unwrap();
1172 assert!(
1173 result.is_empty(),
1174 "Should skip indented standard [x] checkbox in Standard flavor"
1175 );
1176
1177 let content = "- Parent\n\t- [ ] Pending task\n";
1179 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1180 let result = rule.check(&ctx).unwrap();
1181 assert!(
1182 result.is_empty(),
1183 "Should skip tab-indented nested standard [ ] checkbox"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_skip_table_without_outer_pipes() {
1189 let rule = MD064NoMultipleConsecutiveSpaces::new();
1190
1191 let content = "Col1 | Col2 | Col3\n";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195 assert!(result.is_empty());
1196
1197 let content = "--------- | --------- | ---------\n";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201 assert!(result.is_empty());
1202
1203 let content = "Data1 | Data2 | Data3\n";
1205 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1206 let result = rule.check(&ctx).unwrap();
1207 assert!(result.is_empty());
1208 }
1209
1210 #[test]
1211 fn test_flag_spaces_in_footnote_content() {
1212 let rule = MD064NoMultipleConsecutiveSpaces::new();
1213
1214 let content = "[^1]: Footnote with extra spaces in content.\n";
1216 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1217 let result = rule.check(&ctx).unwrap();
1218 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1219 }
1220
1221 #[test]
1222 fn test_flag_spaces_in_reference_content() {
1223 let rule = MD064NoMultipleConsecutiveSpaces::new();
1224
1225 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1230 }
1231
1232 #[test]
1235 fn test_sentence_double_space_disabled_by_default() {
1236 let rule = MD064NoMultipleConsecutiveSpaces::new();
1238 let content = "First sentence. Second sentence.";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1242 }
1243
1244 #[test]
1245 fn test_sentence_double_space_enabled_allows_period() {
1246 let config = MD064Config {
1248 allow_sentence_double_space: true,
1249 };
1250 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1251
1252 let content = "First sentence. Second sentence.";
1253 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1254 let result = rule.check(&ctx).unwrap();
1255 assert!(result.is_empty(), "Should allow 2 spaces after period");
1256 }
1257
1258 #[test]
1259 fn test_sentence_double_space_enabled_allows_exclamation() {
1260 let config = MD064Config {
1261 allow_sentence_double_space: true,
1262 };
1263 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1264
1265 let content = "Wow! That was great.";
1266 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267 let result = rule.check(&ctx).unwrap();
1268 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1269 }
1270
1271 #[test]
1272 fn test_sentence_double_space_enabled_allows_question() {
1273 let config = MD064Config {
1274 allow_sentence_double_space: true,
1275 };
1276 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1277
1278 let content = "Is this OK? Yes it is.";
1279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1282 }
1283
1284 #[test]
1285 fn test_sentence_double_space_flags_mid_sentence() {
1286 let config = MD064Config {
1288 allow_sentence_double_space: true,
1289 };
1290 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1291
1292 let content = "Word word in the middle.";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1296 }
1297
1298 #[test]
1299 fn test_sentence_double_space_flags_triple_after_period() {
1300 let config = MD064Config {
1302 allow_sentence_double_space: true,
1303 };
1304 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1305
1306 let content = "First sentence. Three spaces here.";
1307 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1310 }
1311
1312 #[test]
1313 fn test_sentence_double_space_with_closing_quote() {
1314 let config = MD064Config {
1316 allow_sentence_double_space: true,
1317 };
1318 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1319
1320 let content = r#"He said "Hello." Then he left."#;
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1324
1325 let content = "She said 'Goodbye.' And she was gone.";
1327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328 let result = rule.check(&ctx).unwrap();
1329 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1330 }
1331
1332 #[test]
1333 fn test_sentence_double_space_with_curly_quotes() {
1334 let config = MD064Config {
1335 allow_sentence_double_space: true,
1336 };
1337 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1338
1339 let content = format!(
1342 "He said {}Hello.{} Then left.",
1343 '\u{201C}', '\u{201D}' );
1346 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1349
1350 let content = format!(
1352 "She said {}Hi.{} And left.",
1353 '\u{2018}', '\u{2019}' );
1356 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1359 }
1360
1361 #[test]
1362 fn test_sentence_double_space_with_closing_paren() {
1363 let config = MD064Config {
1364 allow_sentence_double_space: true,
1365 };
1366 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1367
1368 let content = "(See reference.) The next point is.";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370 let result = rule.check(&ctx).unwrap();
1371 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1372 }
1373
1374 #[test]
1375 fn test_sentence_double_space_with_closing_bracket() {
1376 let config = MD064Config {
1377 allow_sentence_double_space: true,
1378 };
1379 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1380
1381 let content = "[Citation needed.] More text here.";
1382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1383 let result = rule.check(&ctx).unwrap();
1384 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1385 }
1386
1387 #[test]
1388 fn test_sentence_double_space_with_ellipsis() {
1389 let config = MD064Config {
1390 allow_sentence_double_space: true,
1391 };
1392 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1393
1394 let content = "He paused... Then continued.";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1398 }
1399
1400 #[test]
1401 fn test_sentence_double_space_complex_ending() {
1402 let config = MD064Config {
1404 allow_sentence_double_space: true,
1405 };
1406 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1407
1408 let content = r#"(He said "Yes.") Then they agreed."#;
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1412 }
1413
1414 #[test]
1415 fn test_sentence_double_space_mixed_content() {
1416 let config = MD064Config {
1418 allow_sentence_double_space: true,
1419 };
1420 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1421
1422 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1424 let result = rule.check(&ctx).unwrap();
1425 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1426 assert!(
1427 result[0].column > 15 && result[0].column < 25,
1428 "Should flag the 'Bad mid' double space"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_sentence_double_space_fix_collapses_to_two() {
1434 let config = MD064Config {
1436 allow_sentence_double_space: true,
1437 };
1438 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1439
1440 let content = "Sentence. Three spaces here.";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let fixed = rule.fix(&ctx).unwrap();
1443 assert_eq!(
1444 fixed, "Sentence. Three spaces here.",
1445 "Should collapse to 2 spaces after sentence"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1451 let config = MD064Config {
1453 allow_sentence_double_space: true,
1454 };
1455 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1456
1457 let content = "Word word here.";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let fixed = rule.fix(&ctx).unwrap();
1460 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1461 }
1462
1463 #[test]
1464 fn test_sentence_double_space_config_kebab_case() {
1465 let toml_str = r#"
1466 allow-sentence-double-space = true
1467 "#;
1468 let config: MD064Config = toml::from_str(toml_str).unwrap();
1469 assert!(config.allow_sentence_double_space);
1470 }
1471
1472 #[test]
1473 fn test_sentence_double_space_config_snake_case() {
1474 let toml_str = r#"
1475 allow_sentence_double_space = true
1476 "#;
1477 let config: MD064Config = toml::from_str(toml_str).unwrap();
1478 assert!(config.allow_sentence_double_space);
1479 }
1480
1481 #[test]
1482 fn test_sentence_double_space_at_line_start() {
1483 let config = MD064Config {
1485 allow_sentence_double_space: true,
1486 };
1487 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1488
1489 let content = ". Text after period at start.";
1491 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1492 let _result = rule.check(&ctx).unwrap();
1494 }
1495
1496 #[test]
1497 fn test_sentence_double_space_guillemets() {
1498 let config = MD064Config {
1500 allow_sentence_double_space: true,
1501 };
1502 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1503
1504 let content = "Il a dit «Oui.» Puis il est parti.";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let result = rule.check(&ctx).unwrap();
1507 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1508 }
1509
1510 #[test]
1511 fn test_sentence_double_space_multiple_sentences() {
1512 let config = MD064Config {
1514 allow_sentence_double_space: true,
1515 };
1516 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1517
1518 let content = "First. Second. Third. Fourth.";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1522 }
1523
1524 #[test]
1525 fn test_sentence_double_space_abbreviation_detection() {
1526 let config = MD064Config {
1528 allow_sentence_double_space: true,
1529 };
1530 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1531
1532 let content = "Dr. Smith arrived.";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1537
1538 let content = "Prof. Williams teaches.";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let result = rule.check(&ctx).unwrap();
1542 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1543
1544 let content = "Use e.g. this example.";
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1547 let result = rule.check(&ctx).unwrap();
1548 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1549
1550 let content = "Acme Inc. Next company.";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let result = rule.check(&ctx).unwrap();
1555 assert!(
1556 result.is_empty(),
1557 "Inc. not in abbreviation list, treated as sentence end"
1558 );
1559 }
1560
1561 #[test]
1562 fn test_sentence_double_space_default_config_has_correct_defaults() {
1563 let config = MD064Config::default();
1564 assert!(
1565 !config.allow_sentence_double_space,
1566 "Default allow_sentence_double_space should be false"
1567 );
1568 }
1569
1570 #[test]
1571 fn test_sentence_double_space_from_config_integration() {
1572 use crate::config::Config;
1573 use std::collections::BTreeMap;
1574
1575 let mut config = Config::default();
1576 let mut values = BTreeMap::new();
1577 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1578 config.rules.insert(
1579 "MD064".to_string(),
1580 crate::config::RuleConfig { severity: None, values },
1581 );
1582
1583 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1584
1585 let content = "Sentence. Two spaces OK. But three is not.";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let result = rule.check(&ctx).unwrap();
1589 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1590 }
1591
1592 #[test]
1593 fn test_sentence_double_space_after_inline_code() {
1594 let config = MD064Config {
1596 allow_sentence_double_space: true,
1597 };
1598 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1599
1600 let content = "Hello from `backticks`. How's it going?";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603 let result = rule.check(&ctx).unwrap();
1604 assert!(
1605 result.is_empty(),
1606 "Should allow 2 spaces after inline code ending with period"
1607 );
1608
1609 let content = "Use `foo` and `bar`. Next sentence.";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1614
1615 let content = "The `code` worked! Celebrate.";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1620
1621 let content = "Is `null` falsy? Yes.";
1623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1624 let result = rule.check(&ctx).unwrap();
1625 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1626
1627 let content = "The `code` is here.";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1632 }
1633
1634 #[test]
1635 fn test_sentence_double_space_code_with_closing_punctuation() {
1636 let config = MD064Config {
1638 allow_sentence_double_space: true,
1639 };
1640 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1641
1642 let content = "(see `example`). Next sentence.";
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = rule.check(&ctx).unwrap();
1646 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1647
1648 let content = "He said \"use `code`\". Then left.";
1650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651 let result = rule.check(&ctx).unwrap();
1652 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1653 }
1654
1655 #[test]
1656 fn test_sentence_double_space_after_emphasis() {
1657 let config = MD064Config {
1659 allow_sentence_double_space: true,
1660 };
1661 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1662
1663 let content = "The word is *important*. Next sentence.";
1665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1666 let result = rule.check(&ctx).unwrap();
1667 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1668
1669 let content = "The word is _important_. Next sentence.";
1671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1672 let result = rule.check(&ctx).unwrap();
1673 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1674
1675 let content = "The word is **critical**. Next sentence.";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 let result = rule.check(&ctx).unwrap();
1679 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1680
1681 let content = "The word is __critical__. Next sentence.";
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let result = rule.check(&ctx).unwrap();
1685 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1686 }
1687
1688 #[test]
1689 fn test_sentence_double_space_after_strikethrough() {
1690 let config = MD064Config {
1692 allow_sentence_double_space: true,
1693 };
1694 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1695
1696 let content = "This is ~~wrong~~. Next sentence.";
1697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1700
1701 let content = "That was ~~bad~~! Learn from it.";
1703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704 let result = rule.check(&ctx).unwrap();
1705 assert!(
1706 result.is_empty(),
1707 "Should allow 2 spaces after strikethrough with exclamation"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_sentence_double_space_after_extended_markdown() {
1713 let config = MD064Config {
1715 allow_sentence_double_space: true,
1716 };
1717 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1718
1719 let content = "This is ==highlighted==. Next sentence.";
1721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1722 let result = rule.check(&ctx).unwrap();
1723 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1724
1725 let content = "E equals mc^2^. Einstein said.";
1727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1728 let result = rule.check(&ctx).unwrap();
1729 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1730 }
1731
1732 #[test]
1733 fn test_inline_config_allow_sentence_double_space() {
1734 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1744
1745 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1748
1749`<svg>`. Fortunately"#;
1750 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1751 let base_config = crate::config::Config::default();
1752 let merged_config = base_config.merge_with_inline_config(&inline_config);
1753 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 let result = effective_rule.check(&ctx).unwrap();
1756 assert!(
1757 result.is_empty(),
1758 "Inline config should allow double spaces after sentence"
1759 );
1760
1761 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1763
1764**scalable**. Pick"#;
1765 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1766 let merged_config = base_config.merge_with_inline_config(&inline_config);
1767 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769 let result = effective_rule.check(&ctx).unwrap();
1770 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1771 }
1772
1773 #[test]
1774 fn test_inline_config_allow_sentence_double_space_issue_364() {
1775 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1779
1780# Title
1781
1782what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1783
1784And here is where I want to say, SVG documents are **scalable**. Pick
1785
1786That's right, no `width`, no `height`, no `viewBox`. There is no easy
1787
1788**SVG documents are scalable**. That's their whole reason for being!"#;
1789
1790 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1792 let base_config = crate::config::Config::default();
1793 let merged_config = base_config.merge_with_inline_config(&inline_config);
1794 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let result = effective_rule.check(&ctx).unwrap();
1797 assert!(
1798 result.is_empty(),
1799 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1800 result.len()
1801 );
1802 }
1803
1804 #[test]
1805 fn test_indented_reference_link_not_flagged() {
1806 let rule = MD064NoMultipleConsecutiveSpaces::default();
1809
1810 let content = " [label]: https://example.com";
1812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1813 let result = rule.check(&ctx).unwrap();
1814 assert!(
1815 result.is_empty(),
1816 "Indented reference link definitions should not be flagged, got: {:?}",
1817 result
1818 .iter()
1819 .map(|w| format!("col={}: {}", w.column, &w.message))
1820 .collect::<Vec<_>>()
1821 );
1822
1823 let content = "[label]: https://example.com";
1825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1826 let result = rule.check(&ctx).unwrap();
1827 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1828 }
1829
1830 #[test]
1831 fn test_pre_block_with_blank_line_not_flagged() {
1832 let rule = MD064NoMultipleConsecutiveSpaces::default();
1835
1836 let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
1837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1838 let result = rule.check(&ctx).unwrap();
1839 assert!(
1840 result.is_empty(),
1841 "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_textarea_block_with_blank_line_not_flagged() {
1847 let rule = MD064NoMultipleConsecutiveSpaces::default();
1850
1851 let content = "<textarea>\n\ninner content\n</textarea>\n";
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 let result = rule.check(&ctx).unwrap();
1854 assert!(
1855 result.is_empty(),
1856 "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1857 );
1858 }
1859
1860 #[test]
1861 fn test_div_with_blank_line_content_is_flagged() {
1862 let rule = MD064NoMultipleConsecutiveSpaces::default();
1866
1867 let content = "<div>\ninner\n\nafter blank\n</div>\n";
1868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1869 let result = rule.check(&ctx).unwrap();
1870 assert!(
1871 !result.is_empty(),
1872 "MD064 must fire on content after a blank line inside a <div> block"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_column_aligned_list_not_flagged_cdk_template() {
1878 let rule = MD064NoMultipleConsecutiveSpaces::default();
1883
1884 let content = "# Useful commands\n\n\
1885 - `cdk ls` list all stacks in the app\n\
1886 - `cdk synth` emits the synthesized CloudFormation template\n\
1887 - `cdk deploy` deploy this stack to your default AWS account/region\n\
1888 - `cdk diff` compare deployed stack with current state\n\
1889 - `cdk docs` open CDK documentation\n";
1890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1891 let result = rule.check(&ctx).unwrap();
1892 assert!(
1893 result.is_empty(),
1894 "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1895 result
1896 .iter()
1897 .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1898 .collect::<Vec<_>>()
1899 );
1900
1901 let fixed = rule.fix(&ctx).unwrap();
1903 assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1904 }
1905
1906 #[test]
1907 fn test_column_aligned_two_item_list_not_flagged() {
1908 let rule = MD064NoMultipleConsecutiveSpaces::default();
1910
1911 let content = "- `a` alpha\n- `b` beta\n";
1912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913 let result = rule.check(&ctx).unwrap();
1914 assert!(
1915 result.is_empty(),
1916 "Two-item aligned list must not be flagged, got: {result:?}"
1917 );
1918 }
1919
1920 #[test]
1921 fn test_partially_aligned_list_is_flagged_normally() {
1922 let rule = MD064NoMultipleConsecutiveSpaces::default();
1926
1927 let content = "- `a` alpha\n- short\n- `c` gamma\n";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929 let result = rule.check(&ctx).unwrap();
1930 assert_eq!(
1931 result.len(),
1932 2,
1933 "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_single_item_list_with_multi_space_still_flagged() {
1939 let rule = MD064NoMultipleConsecutiveSpaces::default();
1942
1943 let content = "- `a` alpha\n";
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 let result = rule.check(&ctx).unwrap();
1946 assert_eq!(
1947 result.len(),
1948 1,
1949 "Single-item list must still be flagged, got: {result:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_nested_aligned_lists_evaluated_independently() {
1955 let rule = MD064NoMultipleConsecutiveSpaces::default();
1958
1959 let content = "- `cmd1` outer one\n\
1960 \x20\x20- `sub-a` inner one\n\
1961 \x20\x20- `sub-b` inner two\n\
1962 - `cmd2` outer two\n";
1963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1964 let result = rule.check(&ctx).unwrap();
1965 assert!(
1966 result.is_empty(),
1967 "Nested column-aligned lists must not be flagged, got: {result:?}"
1968 );
1969 }
1970}