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