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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
575 let default_config = MD064Config::default();
576 let json_value = serde_json::to_value(&default_config).ok()?;
577 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
578
579 if let toml::Value::Table(table) = toml_value {
580 if !table.is_empty() {
581 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
582 } else {
583 None
584 }
585 } else {
586 None
587 }
588 }
589
590 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
591 where
592 Self: Sized,
593 {
594 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
595 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602 use crate::lint_context::LintContext;
603
604 #[test]
605 fn test_basic_multiple_spaces() {
606 let rule = MD064NoMultipleConsecutiveSpaces::new();
607
608 let content = "This is a sentence with extra spaces.";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612 assert_eq!(result.len(), 1);
613 assert_eq!(result[0].line, 1);
614 assert_eq!(result[0].column, 8); }
616
617 #[test]
618 fn test_no_issues_single_spaces() {
619 let rule = MD064NoMultipleConsecutiveSpaces::new();
620
621 let content = "This is a normal sentence with single spaces.";
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_inline_code() {
630 let rule = MD064NoMultipleConsecutiveSpaces::new();
631
632 let content = "Use `code with spaces` for formatting.";
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_code_blocks() {
641 let rule = MD064NoMultipleConsecutiveSpaces::new();
642
643 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
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_leading_indentation() {
652 let rule = MD064NoMultipleConsecutiveSpaces::new();
653
654 let content = " This is indented text.";
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_trailing_spaces() {
663 let rule = MD064NoMultipleConsecutiveSpaces::new();
664
665 let content = "Line with trailing spaces \nNext line.";
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_all_trailing_spaces() {
674 let rule = MD064NoMultipleConsecutiveSpaces::new();
675
676 let content = "Two spaces \nThree spaces \nFour spaces \n";
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_skip_front_matter() {
685 let rule = MD064NoMultipleConsecutiveSpaces::new();
686
687 let content = "---\ntitle: Test Title\n---\n\nContent here.";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691 assert!(result.is_empty());
692 }
693
694 #[test]
695 fn test_skip_html_comments() {
696 let rule = MD064NoMultipleConsecutiveSpaces::new();
697
698 let content = "<!-- comment with spaces -->\n\nContent here.";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert!(result.is_empty());
703 }
704
705 #[test]
706 fn test_multiple_issues_one_line() {
707 let rule = MD064NoMultipleConsecutiveSpaces::new();
708
709 let content = "This has multiple issues.";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
714 }
715
716 #[test]
717 fn test_fix_collapses_spaces() {
718 let rule = MD064NoMultipleConsecutiveSpaces::new();
719
720 let content = "This is a sentence with extra spaces.";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let fixed = rule.fix(&ctx).unwrap();
723 assert_eq!(fixed, "This is a sentence with extra spaces.");
724 }
725
726 #[test]
727 fn test_fix_preserves_inline_code() {
728 let rule = MD064NoMultipleConsecutiveSpaces::new();
729
730 let content = "Text here `code inside` and more.";
731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let fixed = rule.fix(&ctx).unwrap();
733 assert_eq!(fixed, "Text here `code inside` and more.");
734 }
735
736 #[test]
737 fn test_fix_preserves_trailing_spaces() {
738 let rule = MD064NoMultipleConsecutiveSpaces::new();
739
740 let content = "Line with extra and trailing \nNext line.";
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let fixed = rule.fix(&ctx).unwrap();
744 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
746 }
747
748 #[test]
749 fn test_list_items_with_extra_spaces() {
750 let rule = MD064NoMultipleConsecutiveSpaces::new();
751
752 let content = "- Item one\n- Item two\n- Item three\n";
757 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 2, "Should flag spaces in list items");
760 }
761
762 #[test]
763 fn test_blockquote_with_extra_spaces_in_content() {
764 let rule = MD064NoMultipleConsecutiveSpaces::new();
765
766 let content = "> Quote with extra spaces\n";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
771 }
772
773 #[test]
774 fn test_skip_blockquote_marker_spaces() {
775 let rule = MD064NoMultipleConsecutiveSpaces::new();
776
777 let content = "> Text with extra space after marker\n";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781 assert!(result.is_empty());
782
783 let content = "> Text with three spaces after marker\n";
785 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(result.is_empty());
788
789 let content = ">> Nested blockquote\n";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result = rule.check(&ctx).unwrap();
793 assert!(result.is_empty());
794 }
795
796 #[test]
797 fn test_mixed_content() {
798 let rule = MD064NoMultipleConsecutiveSpaces::new();
799
800 let content = r#"# Heading
801
802This has extra spaces.
803
804```
805code here is fine
806```
807
808- List item
809
810> Quote text
811
812Normal paragraph.
813"#;
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
818 }
819
820 #[test]
821 fn test_multibyte_utf8() {
822 let rule = MD064NoMultipleConsecutiveSpaces::new();
823
824 let content = "日本語 テスト 文字列";
826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx);
828 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
829
830 let warnings = result.unwrap();
831 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
832 }
833
834 #[test]
835 fn test_table_rows_skipped() {
836 let rule = MD064NoMultipleConsecutiveSpaces::new();
837
838 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
840 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert!(result.is_empty());
844 }
845
846 #[test]
847 fn test_link_text_with_extra_spaces() {
848 let rule = MD064NoMultipleConsecutiveSpaces::new();
849
850 let content = "[Link text](https://example.com)";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
855 }
856
857 #[test]
858 fn test_image_alt_with_extra_spaces() {
859 let rule = MD064NoMultipleConsecutiveSpaces::new();
860
861 let content = "";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
866 }
867
868 #[test]
869 fn test_skip_list_marker_spaces() {
870 let rule = MD064NoMultipleConsecutiveSpaces::new();
871
872 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 let result = rule.check(&ctx).unwrap();
876 assert!(result.is_empty());
877
878 let content = "1. Item one\n2. Item two\n10. Item ten\n";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882 assert!(result.is_empty());
883
884 let content = " * Indented item\n 1. Nested numbered item\n";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888 assert!(result.is_empty());
889 }
890
891 #[test]
892 fn test_skip_blockquoted_list_marker_spaces() {
893 let rule = MD064NoMultipleConsecutiveSpaces::new();
894
895 let content = "# Title\n\n> 1. Hello.\n> This is a list item.\n> 2. This is another 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 blockquotes"
902 );
903
904 let content = "> * Item one\n> - Item two\n> + Item three\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 unordered list markers in blockquotes"
911 );
912
913 let content = "> > 1. Nested blockquote list item\n";
915 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
916 let result = rule.check(&ctx).unwrap();
917 assert!(
918 result.is_empty(),
919 "Should not flag spaces after list markers in nested blockquotes"
920 );
921
922 let content = "> 1) First item\n> 2) Second item\n";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926 assert!(
927 result.is_empty(),
928 "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
929 );
930
931 let content = "> 1. Item with extra space after >\n";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert!(
938 result.is_empty(),
939 "Should not flag list marker spaces even with extra space after blockquote marker"
940 );
941
942 let content = "> 1. Item with extra spaces in content\n";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946 assert_eq!(
947 result.len(),
948 1,
949 "Should still flag extra spaces in blockquoted list content"
950 );
951 }
952
953 #[test]
954 fn test_flag_spaces_in_list_content() {
955 let rule = MD064NoMultipleConsecutiveSpaces::new();
956
957 let content = "* Item with extra spaces in content\n";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
962 }
963
964 #[test]
965 fn test_skip_reference_link_definition_spaces() {
966 let rule = MD064NoMultipleConsecutiveSpaces::new();
967
968 let content = "[ref]: https://example.com\n";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(result.is_empty());
973
974 let content = "[reference-link]: https://example.com \"Title\"\n";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978 assert!(result.is_empty());
979 }
980
981 #[test]
982 fn test_skip_footnote_marker_spaces() {
983 let rule = MD064NoMultipleConsecutiveSpaces::new();
984
985 let content = "[^1]: Footnote with extra space\n";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989 assert!(result.is_empty());
990
991 let content = "[^footnote-label]: This is the footnote text.\n";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995 assert!(result.is_empty());
996 }
997
998 #[test]
999 fn test_skip_definition_list_marker_spaces() {
1000 let rule = MD064NoMultipleConsecutiveSpaces::new();
1001
1002 let content = "Term\n: Definition with extra spaces\n";
1004 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(result.is_empty());
1007
1008 let content = ": Another definition\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(result.is_empty());
1013 }
1014
1015 #[test]
1016 fn test_skip_task_list_checkbox_spaces() {
1017 let rule = MD064NoMultipleConsecutiveSpaces::new();
1018
1019 let content = "- [ ] Task with extra space\n";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert!(result.is_empty());
1024
1025 let content = "- [x] Completed task\n";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(result.is_empty());
1030
1031 let content = "* [ ] Task with asterisk marker\n";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert!(result.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_skip_extended_task_checkbox_spaces_obsidian() {
1040 let rule = MD064NoMultipleConsecutiveSpaces::new();
1042
1043 let content = "- [/] In progress 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 = "- [-] Cancelled 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 = "- [>] Deferred 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 = "- [<] Scheduled 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 = "- [?] Question task\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 [?] checkbox in Obsidian");
1072
1073 let content = "- [!] Important task\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 [!] checkbox in Obsidian");
1078
1079 let content = "- [*] Starred task\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 [*] checkbox in Obsidian");
1084
1085 let content = "* [/] In progress with asterisk\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 extended checkbox with * marker");
1090
1091 let content = "+ [-] Cancelled with plus\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1096
1097 let content = "- [✓] Completed with checkmark\n";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
1102
1103 let content = "- [✗] Failed with X mark\n";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
1107
1108 let content = "- [→] Forwarded with arrow\n";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1110 let result = rule.check(&ctx).unwrap();
1111 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1112 }
1113
1114 #[test]
1115 fn test_flag_extended_checkboxes_in_standard_flavor() {
1116 let rule = MD064NoMultipleConsecutiveSpaces::new();
1118
1119 let content = "- [/] In progress task\n";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
1123
1124 let content = "- [-] Cancelled task\n";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126 let result = rule.check(&ctx).unwrap();
1127 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1128
1129 let content = "- [✓] Unicode checkbox\n";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1133 }
1134
1135 #[test]
1136 fn test_extended_checkboxes_with_indentation() {
1137 let rule = MD064NoMultipleConsecutiveSpaces::new();
1138
1139 let content = " - [/] In progress task\n";
1142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1143 let result = rule.check(&ctx).unwrap();
1144 assert!(
1145 result.is_empty(),
1146 "Should skip space-indented extended checkbox in Obsidian"
1147 );
1148
1149 let content = " - [-] Cancelled task\n";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1152 let result = rule.check(&ctx).unwrap();
1153 assert!(
1154 result.is_empty(),
1155 "Should skip 3-space indented extended checkbox in Obsidian"
1156 );
1157
1158 let content = "- Parent item\n\t- [/] In progress task\n";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1162 let result = rule.check(&ctx).unwrap();
1163 assert!(
1164 result.is_empty(),
1165 "Should skip tab-indented nested extended checkbox in Obsidian"
1166 );
1167
1168 let content = " - [/] In progress task\n";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let result = rule.check(&ctx).unwrap();
1172 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1173
1174 let content = " - [-] Cancelled task\n";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1179
1180 let content = "- Parent item\n\t- [-] Cancelled task\n";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184 assert_eq!(
1185 result.len(),
1186 1,
1187 "Should flag tab-indented nested [-] in Standard flavor"
1188 );
1189
1190 let content = " - [x] Completed task\n";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194 assert!(
1195 result.is_empty(),
1196 "Should skip indented standard [x] checkbox in Standard flavor"
1197 );
1198
1199 let content = "- Parent\n\t- [ ] Pending task\n";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203 assert!(
1204 result.is_empty(),
1205 "Should skip tab-indented nested standard [ ] checkbox"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_skip_table_without_outer_pipes() {
1211 let rule = MD064NoMultipleConsecutiveSpaces::new();
1212
1213 let content = "Col1 | Col2 | Col3\n";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert!(result.is_empty());
1218
1219 let content = "--------- | --------- | ---------\n";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert!(result.is_empty());
1224
1225 let content = "Data1 | Data2 | Data3\n";
1227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1228 let result = rule.check(&ctx).unwrap();
1229 assert!(result.is_empty());
1230 }
1231
1232 #[test]
1233 fn test_flag_spaces_in_footnote_content() {
1234 let rule = MD064NoMultipleConsecutiveSpaces::new();
1235
1236 let content = "[^1]: Footnote with extra spaces in content.\n";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1241 }
1242
1243 #[test]
1244 fn test_flag_spaces_in_reference_content() {
1245 let rule = MD064NoMultipleConsecutiveSpaces::new();
1246
1247 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1252 }
1253
1254 #[test]
1257 fn test_sentence_double_space_disabled_by_default() {
1258 let rule = MD064NoMultipleConsecutiveSpaces::new();
1260 let content = "First sentence. Second sentence.";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let result = rule.check(&ctx).unwrap();
1263 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1264 }
1265
1266 #[test]
1267 fn test_sentence_double_space_enabled_allows_period() {
1268 let config = MD064Config {
1270 allow_sentence_double_space: true,
1271 };
1272 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1273
1274 let content = "First sentence. Second sentence.";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let result = rule.check(&ctx).unwrap();
1277 assert!(result.is_empty(), "Should allow 2 spaces after period");
1278 }
1279
1280 #[test]
1281 fn test_sentence_double_space_enabled_allows_exclamation() {
1282 let config = MD064Config {
1283 allow_sentence_double_space: true,
1284 };
1285 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1286
1287 let content = "Wow! That was great.";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 let result = rule.check(&ctx).unwrap();
1290 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1291 }
1292
1293 #[test]
1294 fn test_sentence_double_space_enabled_allows_question() {
1295 let config = MD064Config {
1296 allow_sentence_double_space: true,
1297 };
1298 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1299
1300 let content = "Is this OK? Yes it is.";
1301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1302 let result = rule.check(&ctx).unwrap();
1303 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1304 }
1305
1306 #[test]
1307 fn test_sentence_double_space_flags_mid_sentence() {
1308 let config = MD064Config {
1310 allow_sentence_double_space: true,
1311 };
1312 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1313
1314 let content = "Word word in the middle.";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let result = rule.check(&ctx).unwrap();
1317 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1318 }
1319
1320 #[test]
1321 fn test_sentence_double_space_flags_triple_after_period() {
1322 let config = MD064Config {
1324 allow_sentence_double_space: true,
1325 };
1326 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1327
1328 let content = "First sentence. Three spaces here.";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let result = rule.check(&ctx).unwrap();
1331 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1332 }
1333
1334 #[test]
1335 fn test_sentence_double_space_with_closing_quote() {
1336 let config = MD064Config {
1338 allow_sentence_double_space: true,
1339 };
1340 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1341
1342 let content = r#"He said "Hello." Then he left."#;
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1346
1347 let content = "She said 'Goodbye.' And she was gone.";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1352 }
1353
1354 #[test]
1355 fn test_sentence_double_space_with_curly_quotes() {
1356 let config = MD064Config {
1357 allow_sentence_double_space: true,
1358 };
1359 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1360
1361 let content = format!(
1364 "He said {}Hello.{} Then left.",
1365 '\u{201C}', '\u{201D}' );
1368 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1369 let result = rule.check(&ctx).unwrap();
1370 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1371
1372 let content = format!(
1374 "She said {}Hi.{} And left.",
1375 '\u{2018}', '\u{2019}' );
1378 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1379 let result = rule.check(&ctx).unwrap();
1380 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1381 }
1382
1383 #[test]
1384 fn test_sentence_double_space_with_closing_paren() {
1385 let config = MD064Config {
1386 allow_sentence_double_space: true,
1387 };
1388 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1389
1390 let content = "(See reference.) The next point is.";
1391 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1392 let result = rule.check(&ctx).unwrap();
1393 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1394 }
1395
1396 #[test]
1397 fn test_sentence_double_space_with_closing_bracket() {
1398 let config = MD064Config {
1399 allow_sentence_double_space: true,
1400 };
1401 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1402
1403 let content = "[Citation needed.] More text here.";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let result = rule.check(&ctx).unwrap();
1406 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1407 }
1408
1409 #[test]
1410 fn test_sentence_double_space_with_ellipsis() {
1411 let config = MD064Config {
1412 allow_sentence_double_space: true,
1413 };
1414 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1415
1416 let content = "He paused... Then continued.";
1417 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1418 let result = rule.check(&ctx).unwrap();
1419 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1420 }
1421
1422 #[test]
1423 fn test_sentence_double_space_complex_ending() {
1424 let config = MD064Config {
1426 allow_sentence_double_space: true,
1427 };
1428 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1429
1430 let content = r#"(He said "Yes.") Then they agreed."#;
1431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432 let result = rule.check(&ctx).unwrap();
1433 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1434 }
1435
1436 #[test]
1437 fn test_sentence_double_space_mixed_content() {
1438 let config = MD064Config {
1440 allow_sentence_double_space: true,
1441 };
1442 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1443
1444 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1446 let result = rule.check(&ctx).unwrap();
1447 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1448 assert!(
1449 result[0].column > 15 && result[0].column < 25,
1450 "Should flag the 'Bad mid' double space"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_sentence_double_space_fix_collapses_to_two() {
1456 let config = MD064Config {
1458 allow_sentence_double_space: true,
1459 };
1460 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1461
1462 let content = "Sentence. Three spaces here.";
1463 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1464 let fixed = rule.fix(&ctx).unwrap();
1465 assert_eq!(
1466 fixed, "Sentence. Three spaces here.",
1467 "Should collapse to 2 spaces after sentence"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1473 let config = MD064Config {
1475 allow_sentence_double_space: true,
1476 };
1477 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1478
1479 let content = "Word word here.";
1480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1481 let fixed = rule.fix(&ctx).unwrap();
1482 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1483 }
1484
1485 #[test]
1486 fn test_sentence_double_space_config_kebab_case() {
1487 let toml_str = r#"
1488 allow-sentence-double-space = true
1489 "#;
1490 let config: MD064Config = toml::from_str(toml_str).unwrap();
1491 assert!(config.allow_sentence_double_space);
1492 }
1493
1494 #[test]
1495 fn test_sentence_double_space_config_snake_case() {
1496 let toml_str = r#"
1497 allow_sentence_double_space = true
1498 "#;
1499 let config: MD064Config = toml::from_str(toml_str).unwrap();
1500 assert!(config.allow_sentence_double_space);
1501 }
1502
1503 #[test]
1504 fn test_sentence_double_space_at_line_start() {
1505 let config = MD064Config {
1507 allow_sentence_double_space: true,
1508 };
1509 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1510
1511 let content = ". Text after period at start.";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let _result = rule.check(&ctx).unwrap();
1516 }
1517
1518 #[test]
1519 fn test_sentence_double_space_guillemets() {
1520 let config = MD064Config {
1522 allow_sentence_double_space: true,
1523 };
1524 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1525
1526 let content = "Il a dit «Oui.» Puis il est parti.";
1527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1528 let result = rule.check(&ctx).unwrap();
1529 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1530 }
1531
1532 #[test]
1533 fn test_sentence_double_space_multiple_sentences() {
1534 let config = MD064Config {
1536 allow_sentence_double_space: true,
1537 };
1538 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1539
1540 let content = "First. Second. Third. Fourth.";
1541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1542 let result = rule.check(&ctx).unwrap();
1543 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1544 }
1545
1546 #[test]
1547 fn test_sentence_double_space_abbreviation_detection() {
1548 let config = MD064Config {
1550 allow_sentence_double_space: true,
1551 };
1552 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1553
1554 let content = "Dr. Smith arrived.";
1556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1557 let result = rule.check(&ctx).unwrap();
1558 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1559
1560 let content = "Prof. Williams teaches.";
1562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563 let result = rule.check(&ctx).unwrap();
1564 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1565
1566 let content = "Use e.g. this example.";
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569 let result = rule.check(&ctx).unwrap();
1570 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1571
1572 let content = "Acme Inc. Next company.";
1575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1576 let result = rule.check(&ctx).unwrap();
1577 assert!(
1578 result.is_empty(),
1579 "Inc. not in abbreviation list, treated as sentence end"
1580 );
1581 }
1582
1583 #[test]
1584 fn test_sentence_double_space_default_config_has_correct_defaults() {
1585 let config = MD064Config::default();
1586 assert!(
1587 !config.allow_sentence_double_space,
1588 "Default allow_sentence_double_space should be false"
1589 );
1590 }
1591
1592 #[test]
1593 fn test_sentence_double_space_from_config_integration() {
1594 use crate::config::Config;
1595 use std::collections::BTreeMap;
1596
1597 let mut config = Config::default();
1598 let mut values = BTreeMap::new();
1599 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1600 config.rules.insert(
1601 "MD064".to_string(),
1602 crate::config::RuleConfig { severity: None, values },
1603 );
1604
1605 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1606
1607 let content = "Sentence. Two spaces OK. But three is not.";
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1610 let result = rule.check(&ctx).unwrap();
1611 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1612 }
1613
1614 #[test]
1615 fn test_sentence_double_space_after_inline_code() {
1616 let config = MD064Config {
1618 allow_sentence_double_space: true,
1619 };
1620 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1621
1622 let content = "Hello from `backticks`. How's it going?";
1624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625 let result = rule.check(&ctx).unwrap();
1626 assert!(
1627 result.is_empty(),
1628 "Should allow 2 spaces after inline code ending with period"
1629 );
1630
1631 let content = "Use `foo` and `bar`. Next sentence.";
1633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1634 let result = rule.check(&ctx).unwrap();
1635 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1636
1637 let content = "The `code` worked! Celebrate.";
1639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1640 let result = rule.check(&ctx).unwrap();
1641 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1642
1643 let content = "Is `null` falsy? Yes.";
1645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1646 let result = rule.check(&ctx).unwrap();
1647 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1648
1649 let content = "The `code` is here.";
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let result = rule.check(&ctx).unwrap();
1653 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1654 }
1655
1656 #[test]
1657 fn test_sentence_double_space_code_with_closing_punctuation() {
1658 let config = MD064Config {
1660 allow_sentence_double_space: true,
1661 };
1662 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1663
1664 let content = "(see `example`). Next sentence.";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let result = rule.check(&ctx).unwrap();
1668 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1669
1670 let content = "He said \"use `code`\". Then left.";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let result = rule.check(&ctx).unwrap();
1674 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1675 }
1676
1677 #[test]
1678 fn test_sentence_double_space_after_emphasis() {
1679 let config = MD064Config {
1681 allow_sentence_double_space: true,
1682 };
1683 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1684
1685 let content = "The word is *important*. 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 emphasis");
1690
1691 let content = "The word is _important_. Next sentence.";
1693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let result = rule.check(&ctx).unwrap();
1695 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1696
1697 let content = "The word is **critical**. Next sentence.";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1702
1703 let content = "The word is __critical__. Next sentence.";
1705 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706 let result = rule.check(&ctx).unwrap();
1707 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1708 }
1709
1710 #[test]
1711 fn test_sentence_double_space_after_strikethrough() {
1712 let config = MD064Config {
1714 allow_sentence_double_space: true,
1715 };
1716 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1717
1718 let content = "This is ~~wrong~~. Next sentence.";
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let result = rule.check(&ctx).unwrap();
1721 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1722
1723 let content = "That was ~~bad~~! Learn from it.";
1725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727 assert!(
1728 result.is_empty(),
1729 "Should allow 2 spaces after strikethrough with exclamation"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_sentence_double_space_after_extended_markdown() {
1735 let config = MD064Config {
1737 allow_sentence_double_space: true,
1738 };
1739 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1740
1741 let content = "This is ==highlighted==. Next sentence.";
1743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1744 let result = rule.check(&ctx).unwrap();
1745 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1746
1747 let content = "E equals mc^2^. Einstein said.";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750 let result = rule.check(&ctx).unwrap();
1751 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1752 }
1753
1754 #[test]
1755 fn test_inline_config_allow_sentence_double_space() {
1756 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let result = rule.check(&ctx).unwrap();
1765 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1766
1767 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1770
1771`<svg>`. Fortunately"#;
1772 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1773 let base_config = crate::config::Config::default();
1774 let merged_config = base_config.merge_with_inline_config(&inline_config);
1775 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let result = effective_rule.check(&ctx).unwrap();
1778 assert!(
1779 result.is_empty(),
1780 "Inline config should allow double spaces after sentence"
1781 );
1782
1783 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1785
1786**scalable**. Pick"#;
1787 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1788 let merged_config = base_config.merge_with_inline_config(&inline_config);
1789 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 let result = effective_rule.check(&ctx).unwrap();
1792 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1793 }
1794
1795 #[test]
1796 fn test_inline_config_allow_sentence_double_space_issue_364() {
1797 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1801
1802# Title
1803
1804what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1805
1806And here is where I want to say, SVG documents are **scalable**. Pick
1807
1808That's right, no `width`, no `height`, no `viewBox`. There is no easy
1809
1810**SVG documents are scalable**. That's their whole reason for being!"#;
1811
1812 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1814 let base_config = crate::config::Config::default();
1815 let merged_config = base_config.merge_with_inline_config(&inline_config);
1816 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let result = effective_rule.check(&ctx).unwrap();
1819 assert!(
1820 result.is_empty(),
1821 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1822 result.len()
1823 );
1824 }
1825
1826 #[test]
1827 fn test_indented_reference_link_not_flagged() {
1828 let rule = MD064NoMultipleConsecutiveSpaces::default();
1831
1832 let content = " [label]: https://example.com";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let result = rule.check(&ctx).unwrap();
1836 assert!(
1837 result.is_empty(),
1838 "Indented reference link definitions should not be flagged, got: {:?}",
1839 result
1840 .iter()
1841 .map(|w| format!("col={}: {}", w.column, &w.message))
1842 .collect::<Vec<_>>()
1843 );
1844
1845 let content = "[label]: https://example.com";
1847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1848 let result = rule.check(&ctx).unwrap();
1849 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1850 }
1851
1852 #[test]
1853 fn test_pre_block_with_blank_line_not_flagged() {
1854 let rule = MD064NoMultipleConsecutiveSpaces::default();
1857
1858 let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
1859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861 assert!(
1862 result.is_empty(),
1863 "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_textarea_block_with_blank_line_not_flagged() {
1869 let rule = MD064NoMultipleConsecutiveSpaces::default();
1872
1873 let content = "<textarea>\n\ninner content\n</textarea>\n";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 let result = rule.check(&ctx).unwrap();
1876 assert!(
1877 result.is_empty(),
1878 "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_div_with_blank_line_content_is_flagged() {
1884 let rule = MD064NoMultipleConsecutiveSpaces::default();
1888
1889 let content = "<div>\ninner\n\nafter blank\n</div>\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 "MD064 must fire on content after a blank line inside a <div> block"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_column_aligned_list_not_flagged_cdk_template() {
1900 let rule = MD064NoMultipleConsecutiveSpaces::default();
1905
1906 let content = "# Useful commands\n\n\
1907 - `cdk ls` list all stacks in the app\n\
1908 - `cdk synth` emits the synthesized CloudFormation template\n\
1909 - `cdk deploy` deploy this stack to your default AWS account/region\n\
1910 - `cdk diff` compare deployed stack with current state\n\
1911 - `cdk docs` open CDK documentation\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 "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1917 result
1918 .iter()
1919 .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1920 .collect::<Vec<_>>()
1921 );
1922
1923 let fixed = rule.fix(&ctx).unwrap();
1925 assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1926 }
1927
1928 #[test]
1929 fn test_column_aligned_two_item_list_not_flagged() {
1930 let rule = MD064NoMultipleConsecutiveSpaces::default();
1932
1933 let content = "- `a` alpha\n- `b` beta\n";
1934 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1935 let result = rule.check(&ctx).unwrap();
1936 assert!(
1937 result.is_empty(),
1938 "Two-item aligned list must not be flagged, got: {result:?}"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_partially_aligned_list_is_flagged_normally() {
1944 let rule = MD064NoMultipleConsecutiveSpaces::default();
1948
1949 let content = "- `a` alpha\n- short\n- `c` gamma\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let result = rule.check(&ctx).unwrap();
1952 assert_eq!(
1953 result.len(),
1954 2,
1955 "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1956 );
1957 }
1958
1959 #[test]
1960 fn test_single_item_list_with_multi_space_still_flagged() {
1961 let rule = MD064NoMultipleConsecutiveSpaces::default();
1964
1965 let content = "- `a` alpha\n";
1966 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1967 let result = rule.check(&ctx).unwrap();
1968 assert_eq!(
1969 result.len(),
1970 1,
1971 "Single-item list must still be flagged, got: {result:?}"
1972 );
1973 }
1974
1975 #[test]
1976 fn test_nested_aligned_lists_evaluated_independently() {
1977 let rule = MD064NoMultipleConsecutiveSpaces::default();
1980
1981 let content = "- `cmd1` outer one\n\
1982 \x20\x20- `sub-a` inner one\n\
1983 \x20\x20- `sub-b` inner two\n\
1984 - `cmd2` outer two\n";
1985 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1986 let result = rule.check(&ctx).unwrap();
1987 assert!(
1988 result.is_empty(),
1989 "Nested column-aligned lists must not be flagged, got: {result:?}"
1990 );
1991 }
1992}