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;
36
37use regex::Regex;
39use std::sync::LazyLock;
40
41static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
42 Regex::new(r" {2,}").unwrap()
44});
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48#[serde(rename_all = "kebab-case")]
49pub struct MD064Config {
50 #[serde(
74 default = "default_allow_sentence_double_space",
75 alias = "allow_sentence_double_space"
76 )]
77 pub allow_sentence_double_space: bool,
78}
79
80fn default_allow_sentence_double_space() -> bool {
81 false
82}
83
84impl Default for MD064Config {
85 fn default() -> Self {
86 Self {
87 allow_sentence_double_space: default_allow_sentence_double_space(),
88 }
89 }
90}
91
92impl RuleConfig for MD064Config {
93 const RULE_NAME: &'static str = "MD064";
94}
95
96#[derive(Debug, Clone)]
97pub struct MD064NoMultipleConsecutiveSpaces {
98 config: MD064Config,
99}
100
101impl Default for MD064NoMultipleConsecutiveSpaces {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl MD064NoMultipleConsecutiveSpaces {
108 pub fn new() -> Self {
109 Self {
110 config: MD064Config::default(),
111 }
112 }
113
114 pub fn from_config_struct(config: MD064Config) -> Self {
115 Self { config }
116 }
117
118 fn is_in_code_span(&self, code_spans: &[crate::lint_context::SourceCodeSpan], byte_pos: usize) -> bool {
120 code_spans
121 .iter()
122 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
123 }
124
125 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
128 let remaining = &line[match_end..];
130 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
131 }
132
133 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
135 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
137 }
138
139 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
141 let before_text = if let Some(parsed) = parse_blockquote_prefix(line) {
143 let prefix_len = parsed.prefix.len();
144 if match_start <= prefix_len {
145 return false;
146 }
147 line[prefix_len..match_start].trim_start()
148 } else {
149 line[..match_start].trim_start()
150 };
151
152 if before_text == "*" || before_text == "-" || before_text == "+" {
154 return true;
155 }
156
157 if before_text.len() >= 2 {
160 let last_char = before_text.chars().last().unwrap();
161 if last_char == '.' || last_char == ')' {
162 let prefix = &before_text[..before_text.len() - 1];
163 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
164 return true;
165 }
166 }
167 }
168
169 false
170 }
171
172 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
175 let before = line[..match_start].trim_start();
176
177 if before.is_empty() {
179 return false;
180 }
181
182 let trimmed = before.trim_end();
184 if trimmed.chars().all(|c| c == '>') {
185 return true;
186 }
187
188 if trimmed.ends_with('>') {
190 let inner = trimmed.trim_end_matches('>').trim();
191 if inner.is_empty() || inner.chars().all(|c| c == '>') {
192 return true;
193 }
194 }
195
196 false
197 }
198
199 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
203 space_count >= 4 && space_count.is_multiple_of(4)
204 }
205
206 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
209 let trimmed = line.trim_start();
210 let leading_spaces = line.len() - trimmed.len();
211
212 if trimmed.starts_with('[')
214 && let Some(bracket_end) = trimmed.find("]:")
215 {
216 let colon_pos = leading_spaces + bracket_end + 2;
217 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
219 return true;
220 }
221 }
222
223 false
224 }
225
226 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
229 let trimmed = line.trim_start();
230
231 if trimmed.starts_with("[^")
233 && let Some(bracket_end) = trimmed.find("]:")
234 {
235 let leading_spaces = line.len() - trimmed.len();
236 let colon_pos = leading_spaces + bracket_end + 2;
237 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
239 return true;
240 }
241 }
242
243 false
244 }
245
246 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
249 let before = line[..match_start].trim_start();
250
251 before == ":"
253 }
254
255 fn is_after_task_checkbox(&self, line: &str, match_start: usize, flavor: crate::config::MarkdownFlavor) -> bool {
260 let before = line[..match_start].trim_start();
261
262 let mut chars = before.chars();
264 let pattern = (
265 chars.next(),
266 chars.next(),
267 chars.next(),
268 chars.next(),
269 chars.next(),
270 chars.next(),
271 );
272
273 match pattern {
274 (Some('*' | '-' | '+'), Some(' '), Some('['), Some(c), Some(']'), None) => {
275 if flavor == crate::config::MarkdownFlavor::Obsidian {
276 true
278 } else {
279 matches!(c, ' ' | 'x' | 'X')
281 }
282 }
283 _ => false,
284 }
285 }
286
287 fn aligned_list_item_lines(&self, ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
301 let mut aligned = HashSet::new();
302 for block in &ctx.list_blocks {
303 for items in ctx.list_block_item_groups(block) {
304 if items.len() < 2 {
305 continue;
306 }
307 let all_aligned = items
308 .iter()
309 .all(|&line_num| self.item_line_has_internal_alignment(ctx, line_num));
310 if all_aligned {
311 aligned.extend(items);
312 }
313 }
314 }
315 aligned
316 }
317
318 fn item_line_has_internal_alignment(&self, ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
321 let Some(line_info) = ctx.line_info(line_num) else {
322 return false;
323 };
324 let Some(item) = &line_info.list_item else {
325 return false;
326 };
327 let content = line_info.content(ctx.content);
328 if item.content_column >= content.len() {
329 return false;
330 }
331 content[item.content_column..].trim_end().contains(" ")
332 }
333
334 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
337 let trimmed = line.trim();
338
339 if !trimmed.contains('|') {
341 return false;
342 }
343
344 if trimmed.starts_with('|') || trimmed.ends_with('|') {
346 return false;
347 }
348
349 let parts: Vec<&str> = trimmed.split('|').collect();
353 if parts.len() >= 2 {
354 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
357 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
358 if first_has_content || last_has_content {
359 return true;
360 }
361 }
362
363 false
364 }
365
366 fn apply_native_fixes(content: &str, mut fixes: Vec<(std::ops::Range<usize>, String)>) -> String {
370 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
371 let mut result = content.to_string();
372 for (range, replacement) in fixes {
373 if range.start < result.len() && range.end <= result.len() {
374 result.replace_range(range, &replacement);
375 }
376 }
377 result
378 }
379}
380
381impl Rule for MD064NoMultipleConsecutiveSpaces {
382 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
383 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(64))
384 }
385
386 fn name(&self) -> &'static str {
387 "MD064"
388 }
389
390 fn description(&self) -> &'static str {
391 "Multiple consecutive spaces"
392 }
393
394 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
395 let content = ctx.content;
396
397 if !content.contains(" ") {
399 return Ok(vec![]);
400 }
401
402 let mut warnings = Vec::new();
404 let code_spans = ctx.source_code_spans();
405
406 let aligned_lines = self.aligned_list_item_lines(ctx);
410
411 for line in ctx
413 .filtered_lines()
414 .skip_front_matter()
415 .skip_code_blocks()
416 .skip_html_blocks()
417 .skip_html_comments()
418 .skip_mkdocstrings()
419 .skip_esm_blocks()
420 .skip_jsx_expressions()
421 .skip_mdx_comments()
422 .skip_pymdown_blocks()
423 .skip_obsidian_comments()
424 {
425 if !line.content.contains(" ") {
427 continue;
428 }
429
430 if aligned_lines.contains(&line.line_num) {
434 continue;
435 }
436
437 if is_table_line(line.content) {
439 continue;
440 }
441
442 if self.is_table_without_outer_pipes(line.content) {
444 continue;
445 }
446
447 let line_start_byte = ctx.line_start_byte(line.line_num).unwrap_or(0);
448
449 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
451 let match_start = mat.start();
452 let match_end = mat.end();
453 let space_count = match_end - match_start;
454
455 if self.is_leading_indentation(line.content, match_start) {
457 continue;
458 }
459
460 if self.is_trailing_whitespace(line.content, match_end) {
462 continue;
463 }
464
465 if self.is_tab_replacement_pattern(space_count) {
468 continue;
469 }
470
471 if self.is_after_list_marker(line.content, match_start) {
473 continue;
474 }
475
476 if self.is_after_blockquote_marker(line.content, match_start) {
478 continue;
479 }
480
481 if self.is_after_footnote_marker(line.content, match_start) {
483 continue;
484 }
485
486 if self.is_reference_link_definition(line.content, match_start) {
488 continue;
489 }
490
491 if self.is_after_definition_marker(line.content, match_start) {
493 continue;
494 }
495
496 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
498 continue;
499 }
500
501 if self.config.allow_sentence_double_space
504 && space_count == 2
505 && is_after_sentence_ending(line.content, match_start)
506 {
507 continue;
508 }
509
510 let abs_byte_start = line_start_byte + match_start;
512
513 if self.is_in_code_span(code_spans, abs_byte_start) {
515 continue;
516 }
517
518 if ctx.is_in_html_comment(abs_byte_start) || ctx.is_in_html_tag(abs_byte_start) {
521 continue;
522 }
523
524 let abs_byte_end = line_start_byte + match_end;
526
527 let replacement =
530 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
531 " ".to_string() } else {
533 " ".to_string() };
535
536 warnings.push(LintWarning {
537 rule_name: Some(self.name().to_string()),
538 message: format!("Multiple consecutive spaces ({space_count}) found"),
539 line: line.line_num,
540 column: match_start + 1, end_line: line.line_num,
542 end_column: match_end + 1, severity: Severity::Warning,
544 fix: Some(Fix::new(abs_byte_start..abs_byte_end, replacement)),
545 });
546 }
547 }
548
549 Ok(warnings)
550 }
551
552 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
553 let content = ctx.content;
554
555 if !content.contains(" ") {
557 return Ok(content.to_string());
558 }
559
560 let warnings = self.check(ctx)?;
562 let warnings =
563 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
564 if warnings.is_empty() {
565 return Ok(content.to_string());
566 }
567
568 let fixes: Vec<(std::ops::Range<usize>, String)> = warnings
570 .into_iter()
571 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
572 .collect();
573
574 Ok(Self::apply_native_fixes(content, fixes))
575 }
576
577 fn fix_with_diagnostics(
578 &self,
579 ctx: &crate::lint_context::LintContext,
580 warnings: &[LintWarning],
581 ) -> Result<String, LintError> {
582 if !ctx.content.contains(" ") {
583 return Ok(ctx.content.to_owned());
584 }
585 let fixes = warnings
586 .iter()
587 .filter(|warning| crate::utils::fix_utils::warning_is_enabled(warning, ctx.inline_config(), self.name()))
588 .filter_map(|warning| warning.fix.as_ref())
589 .map(|fix| (fix.range.clone(), fix.replacement.clone()))
590 .collect();
591 Ok(Self::apply_native_fixes(ctx.content, fixes))
592 }
593
594 fn category(&self) -> RuleCategory {
596 RuleCategory::Whitespace
597 }
598
599 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
601 ctx.content.is_empty() || !ctx.content.contains(" ")
602 }
603
604 fn as_any(&self) -> &dyn std::any::Any {
605 self
606 }
607
608 crate::impl_rule_config_methods!(MD064Config);
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::lint_context::LintContext;
615
616 #[test]
617 fn test_basic_multiple_spaces() {
618 let rule = MD064NoMultipleConsecutiveSpaces::new();
619
620 let content = "This is a sentence with extra spaces.";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624 assert_eq!(result.len(), 1);
625 assert_eq!(result[0].line, 1);
626 assert_eq!(result[0].column, 8); }
628
629 #[test]
630 fn test_no_issues_single_spaces() {
631 let rule = MD064NoMultipleConsecutiveSpaces::new();
632
633 let content = "This is a normal sentence with single spaces.";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert!(result.is_empty());
638 }
639
640 #[test]
641 fn test_skip_inline_code() {
642 let rule = MD064NoMultipleConsecutiveSpaces::new();
643
644 let content = "Use `code with spaces` for formatting.";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648 assert!(result.is_empty());
649 }
650
651 #[test]
652 fn test_skip_code_blocks() {
653 let rule = MD064NoMultipleConsecutiveSpaces::new();
654
655 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 let result = rule.check(&ctx).unwrap();
659 assert!(result.is_empty());
660 }
661
662 #[test]
663 fn test_skip_leading_indentation() {
664 let rule = MD064NoMultipleConsecutiveSpaces::new();
665
666 let content = " This is indented text.";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert!(result.is_empty());
671 }
672
673 #[test]
674 fn test_skip_trailing_spaces() {
675 let rule = MD064NoMultipleConsecutiveSpaces::new();
676
677 let content = "Line with trailing spaces \nNext line.";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(result.is_empty());
682 }
683
684 #[test]
685 fn test_skip_all_trailing_spaces() {
686 let rule = MD064NoMultipleConsecutiveSpaces::new();
687
688 let content = "Two spaces \nThree spaces \nFour spaces \n";
690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
691 let result = rule.check(&ctx).unwrap();
692 assert!(result.is_empty());
693 }
694
695 #[test]
696 fn test_skip_front_matter() {
697 let rule = MD064NoMultipleConsecutiveSpaces::new();
698
699 let content = "---\ntitle: Test Title\n---\n\nContent here.";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703 assert!(result.is_empty());
704 }
705
706 #[test]
707 fn test_skip_html_comments() {
708 let rule = MD064NoMultipleConsecutiveSpaces::new();
709
710 let content = "<!-- comment with spaces -->\n\nContent here.";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let result = rule.check(&ctx).unwrap();
714 assert!(result.is_empty());
715 }
716
717 #[test]
718 fn test_skip_inline_html_comments_and_tags() {
719 let rule = MD064NoMultipleConsecutiveSpaces::new();
720 let content = "Text <!-- a b --> and <span title=\"c d\">e</span> end.\n";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 assert!(rule.check(&ctx).unwrap().is_empty());
723 assert_eq!(rule.fix(&ctx).unwrap(), content);
724
725 let content = "Text <!-- a --> <b>x</b> end.\n";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let columns: Vec<usize> = rule.check(&ctx).unwrap().iter().map(|w| w.column).collect();
729 assert_eq!(columns, vec![5, 17, 27]);
730 }
731
732 #[test]
733 fn test_multiple_issues_one_line() {
734 let rule = MD064NoMultipleConsecutiveSpaces::new();
735
736 let content = "This has multiple issues.";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
739 let result = rule.check(&ctx).unwrap();
740 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
741 }
742
743 #[test]
744 fn test_fix_collapses_spaces() {
745 let rule = MD064NoMultipleConsecutiveSpaces::new();
746
747 let content = "This is a sentence with extra spaces.";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let fixed = rule.fix(&ctx).unwrap();
750 assert_eq!(fixed, "This is a sentence with extra spaces.");
751 }
752
753 #[test]
754 fn test_fix_preserves_inline_code() {
755 let rule = MD064NoMultipleConsecutiveSpaces::new();
756
757 let content = "Text here `code inside` and more.";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759 let fixed = rule.fix(&ctx).unwrap();
760 assert_eq!(fixed, "Text here `code inside` and more.");
761 }
762
763 #[test]
764 fn test_fix_preserves_trailing_spaces() {
765 let rule = MD064NoMultipleConsecutiveSpaces::new();
766
767 let content = "Line with extra and trailing \nNext line.";
769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let fixed = rule.fix(&ctx).unwrap();
771 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
773 }
774
775 #[test]
776 fn test_list_items_with_extra_spaces() {
777 let rule = MD064NoMultipleConsecutiveSpaces::new();
778
779 let content = "- Item one\n- Item two\n- Item three\n";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert_eq!(result.len(), 2, "Should flag spaces in list items");
787 }
788
789 #[test]
790 fn test_blockquote_with_extra_spaces_in_content() {
791 let rule = MD064NoMultipleConsecutiveSpaces::new();
792
793 let content = "> Quote with extra spaces\n";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let result = rule.check(&ctx).unwrap();
797 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
798 }
799
800 #[test]
801 fn test_skip_blockquote_marker_spaces() {
802 let rule = MD064NoMultipleConsecutiveSpaces::new();
803
804 let content = "> Text with extra space after marker\n";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let result = rule.check(&ctx).unwrap();
808 assert!(result.is_empty());
809
810 let content = "> Text with three spaces after marker\n";
812 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert!(result.is_empty());
815
816 let content = ">> Nested blockquote\n";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820 assert!(result.is_empty());
821 }
822
823 #[test]
824 fn test_mixed_content() {
825 let rule = MD064NoMultipleConsecutiveSpaces::new();
826
827 let content = r#"# Heading
828
829This has extra spaces.
830
831```
832code here is fine
833```
834
835- List item
836
837> Quote text
838
839Normal paragraph.
840"#;
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
845 }
846
847 #[test]
848 fn test_multibyte_utf8() {
849 let rule = MD064NoMultipleConsecutiveSpaces::new();
850
851 let content = "日本語 テスト 文字列";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx);
855 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
856
857 let warnings = result.unwrap();
858 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
859 }
860
861 #[test]
862 fn test_table_rows_skipped() {
863 let rule = MD064NoMultipleConsecutiveSpaces::new();
864
865 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(result.is_empty());
871 }
872
873 #[test]
874 fn test_link_text_with_extra_spaces() {
875 let rule = MD064NoMultipleConsecutiveSpaces::new();
876
877 let content = "[Link text](https://example.com)";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
882 }
883
884 #[test]
885 fn test_image_alt_with_extra_spaces() {
886 let rule = MD064NoMultipleConsecutiveSpaces::new();
887
888 let content = "";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
893 }
894
895 #[test]
896 fn test_skip_list_marker_spaces() {
897 let rule = MD064NoMultipleConsecutiveSpaces::new();
898
899 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(result.is_empty());
904
905 let content = "1. Item one\n2. Item two\n10. Item ten\n";
907 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
908 let result = rule.check(&ctx).unwrap();
909 assert!(result.is_empty());
910
911 let content = " * Indented item\n 1. Nested numbered item\n";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
914 let result = rule.check(&ctx).unwrap();
915 assert!(result.is_empty());
916 }
917
918 #[test]
919 fn test_skip_blockquoted_list_marker_spaces() {
920 let rule = MD064NoMultipleConsecutiveSpaces::new();
921
922 let content = "# Title\n\n> 1. Hello.\n> This is a list item.\n> 2. This is another list 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 list markers in blockquotes"
929 );
930
931 let content = "> * Item one\n> - Item two\n> + Item three\n";
933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert!(
936 result.is_empty(),
937 "Should not flag spaces after unordered list markers in blockquotes"
938 );
939
940 let content = "> > 1. Nested blockquote list item\n";
942 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let result = rule.check(&ctx).unwrap();
944 assert!(
945 result.is_empty(),
946 "Should not flag spaces after list markers in nested blockquotes"
947 );
948
949 let content = "> 1) First item\n> 2) Second item\n";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert!(
954 result.is_empty(),
955 "Should not flag spaces after parenthesis-style ordered markers in blockquotes"
956 );
957
958 let content = "> 1. Item with extra space after >\n";
960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let result = rule.check(&ctx).unwrap();
962 assert!(
965 result.is_empty(),
966 "Should not flag list marker spaces even with extra space after blockquote marker"
967 );
968
969 let content = "> 1. Item with extra spaces in content\n";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973 assert_eq!(
974 result.len(),
975 1,
976 "Should still flag extra spaces in blockquoted list content"
977 );
978 }
979
980 #[test]
981 fn test_flag_spaces_in_list_content() {
982 let rule = MD064NoMultipleConsecutiveSpaces::new();
983
984 let content = "* Item with extra spaces in content\n";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
989 }
990
991 #[test]
992 fn test_skip_reference_link_definition_spaces() {
993 let rule = MD064NoMultipleConsecutiveSpaces::new();
994
995 let content = "[ref]: https://example.com\n";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let result = rule.check(&ctx).unwrap();
999 assert!(result.is_empty());
1000
1001 let content = "[reference-link]: https://example.com \"Title\"\n";
1003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1004 let result = rule.check(&ctx).unwrap();
1005 assert!(result.is_empty());
1006 }
1007
1008 #[test]
1009 fn test_skip_footnote_marker_spaces() {
1010 let rule = MD064NoMultipleConsecutiveSpaces::new();
1011
1012 let content = "[^1]: Footnote with extra space\n";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(result.is_empty());
1017
1018 let content = "[^footnote-label]: This is the footnote text.\n";
1020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(result.is_empty());
1023 }
1024
1025 #[test]
1026 fn test_skip_definition_list_marker_spaces() {
1027 let rule = MD064NoMultipleConsecutiveSpaces::new();
1028
1029 let content = "Term\n: Definition with extra spaces\n";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033 assert!(result.is_empty());
1034
1035 let content = ": Another definition\n";
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(result.is_empty());
1040 }
1041
1042 #[test]
1043 fn test_skip_task_list_checkbox_spaces() {
1044 let rule = MD064NoMultipleConsecutiveSpaces::new();
1045
1046 let content = "- [ ] Task with extra space\n";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert!(result.is_empty());
1051
1052 let content = "- [x] Completed task\n";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert!(result.is_empty());
1057
1058 let content = "* [ ] Task with asterisk marker\n";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert!(result.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_skip_extended_task_checkbox_spaces_obsidian() {
1067 let rule = MD064NoMultipleConsecutiveSpaces::new();
1069
1070 let content = "- [/] In progress 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 = "- [-] Cancelled 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 = "- [>] Deferred 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 = "- [<] Scheduled task\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 [<] checkbox in Obsidian");
1093
1094 let content = "- [?] Question task\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 [?] checkbox in Obsidian");
1099
1100 let content = "- [!] Important task\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 [!] checkbox in Obsidian");
1105
1106 let content = "- [*] Starred task\n";
1108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1109 let result = rule.check(&ctx).unwrap();
1110 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
1111
1112 let content = "* [/] In progress with asterisk\n";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1115 let result = rule.check(&ctx).unwrap();
1116 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
1117
1118 let content = "+ [-] Cancelled with plus\n";
1120 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1121 let result = rule.check(&ctx).unwrap();
1122 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
1123
1124 let content = "- [✓] Completed with checkmark\n";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1127 let result = rule.check(&ctx).unwrap();
1128 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
1129
1130 let content = "- [✗] Failed with X mark\n";
1131 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1132 let result = rule.check(&ctx).unwrap();
1133 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
1134
1135 let content = "- [→] Forwarded with arrow\n";
1136 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1137 let result = rule.check(&ctx).unwrap();
1138 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
1139 }
1140
1141 #[test]
1142 fn test_flag_extended_checkboxes_in_standard_flavor() {
1143 let rule = MD064NoMultipleConsecutiveSpaces::new();
1145
1146 let content = "- [/] In progress task\n";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
1150
1151 let content = "- [-] Cancelled task\n";
1152 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153 let result = rule.check(&ctx).unwrap();
1154 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1155
1156 let content = "- [✓] Unicode checkbox\n";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1160 }
1161
1162 #[test]
1163 fn test_extended_checkboxes_with_indentation() {
1164 let rule = MD064NoMultipleConsecutiveSpaces::new();
1165
1166 let content = " - [/] In progress task\n";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(
1172 result.is_empty(),
1173 "Should skip space-indented extended checkbox in Obsidian"
1174 );
1175
1176 let content = " - [-] Cancelled task\n";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1179 let result = rule.check(&ctx).unwrap();
1180 assert!(
1181 result.is_empty(),
1182 "Should skip 3-space indented extended checkbox in Obsidian"
1183 );
1184
1185 let content = "- Parent item\n\t- [/] In progress task\n";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1189 let result = rule.check(&ctx).unwrap();
1190 assert!(
1191 result.is_empty(),
1192 "Should skip tab-indented nested extended checkbox in Obsidian"
1193 );
1194
1195 let content = " - [/] In progress task\n";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1200
1201 let content = " - [-] Cancelled task\n";
1203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1204 let result = rule.check(&ctx).unwrap();
1205 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1206
1207 let content = "- Parent item\n\t- [-] Cancelled task\n";
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert_eq!(
1212 result.len(),
1213 1,
1214 "Should flag tab-indented nested [-] in Standard flavor"
1215 );
1216
1217 let content = " - [x] Completed task\n";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221 assert!(
1222 result.is_empty(),
1223 "Should skip indented standard [x] checkbox in Standard flavor"
1224 );
1225
1226 let content = "- Parent\n\t- [ ] Pending task\n";
1228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230 assert!(
1231 result.is_empty(),
1232 "Should skip tab-indented nested standard [ ] checkbox"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_skip_table_without_outer_pipes() {
1238 let rule = MD064NoMultipleConsecutiveSpaces::new();
1239
1240 let content = "Col1 | Col2 | Col3\n";
1242 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1243 let result = rule.check(&ctx).unwrap();
1244 assert!(result.is_empty());
1245
1246 let content = "--------- | --------- | ---------\n";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250 assert!(result.is_empty());
1251
1252 let content = "Data1 | Data2 | Data3\n";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256 assert!(result.is_empty());
1257 }
1258
1259 #[test]
1260 fn test_flag_spaces_in_footnote_content() {
1261 let rule = MD064NoMultipleConsecutiveSpaces::new();
1262
1263 let content = "[^1]: Footnote with extra spaces in content.\n";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1268 }
1269
1270 #[test]
1271 fn test_flag_spaces_in_reference_content() {
1272 let rule = MD064NoMultipleConsecutiveSpaces::new();
1273
1274 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1279 }
1280
1281 #[test]
1284 fn test_sentence_double_space_disabled_by_default() {
1285 let rule = MD064NoMultipleConsecutiveSpaces::new();
1287 let content = "First sentence. Second sentence.";
1288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289 let result = rule.check(&ctx).unwrap();
1290 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1291 }
1292
1293 #[test]
1294 fn test_sentence_double_space_enabled_allows_period() {
1295 let config = MD064Config {
1297 allow_sentence_double_space: true,
1298 };
1299 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1300
1301 let content = "First sentence. Second sentence.";
1302 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303 let result = rule.check(&ctx).unwrap();
1304 assert!(result.is_empty(), "Should allow 2 spaces after period");
1305 }
1306
1307 #[test]
1308 fn test_sentence_double_space_enabled_allows_exclamation() {
1309 let config = MD064Config {
1310 allow_sentence_double_space: true,
1311 };
1312 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1313
1314 let content = "Wow! That was great.";
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 let result = rule.check(&ctx).unwrap();
1317 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1318 }
1319
1320 #[test]
1321 fn test_sentence_double_space_enabled_allows_question() {
1322 let config = MD064Config {
1323 allow_sentence_double_space: true,
1324 };
1325 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1326
1327 let content = "Is this OK? Yes it is.";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1331 }
1332
1333 #[test]
1334 fn test_sentence_double_space_flags_mid_sentence() {
1335 let config = MD064Config {
1337 allow_sentence_double_space: true,
1338 };
1339 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1340
1341 let content = "Word word in the middle.";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343 let result = rule.check(&ctx).unwrap();
1344 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1345 }
1346
1347 #[test]
1348 fn test_sentence_double_space_flags_triple_after_period() {
1349 let config = MD064Config {
1351 allow_sentence_double_space: true,
1352 };
1353 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1354
1355 let content = "First sentence. Three spaces here.";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1359 }
1360
1361 #[test]
1362 fn test_sentence_double_space_with_closing_quote() {
1363 let config = MD064Config {
1365 allow_sentence_double_space: true,
1366 };
1367 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1368
1369 let content = r#"He said "Hello." Then he left."#;
1370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1371 let result = rule.check(&ctx).unwrap();
1372 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1373
1374 let content = "She said 'Goodbye.' And she was gone.";
1376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1377 let result = rule.check(&ctx).unwrap();
1378 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1379 }
1380
1381 #[test]
1382 fn test_sentence_double_space_with_curly_quotes() {
1383 let config = MD064Config {
1384 allow_sentence_double_space: true,
1385 };
1386 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1387
1388 let content = format!(
1391 "He said {}Hello.{} Then left.",
1392 '\u{201C}', '\u{201D}' );
1395 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1398
1399 let content = format!(
1401 "She said {}Hi.{} And left.",
1402 '\u{2018}', '\u{2019}' );
1405 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1406 let result = rule.check(&ctx).unwrap();
1407 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1408 }
1409
1410 #[test]
1411 fn test_sentence_double_space_with_closing_paren() {
1412 let config = MD064Config {
1413 allow_sentence_double_space: true,
1414 };
1415 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1416
1417 let content = "(See reference.) The next point is.";
1418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419 let result = rule.check(&ctx).unwrap();
1420 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1421 }
1422
1423 #[test]
1424 fn test_sentence_double_space_with_closing_bracket() {
1425 let config = MD064Config {
1426 allow_sentence_double_space: true,
1427 };
1428 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1429
1430 let content = "[Citation needed.] More text here.";
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_with_ellipsis() {
1438 let config = MD064Config {
1439 allow_sentence_double_space: true,
1440 };
1441 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1442
1443 let content = "He paused... Then continued.";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let result = rule.check(&ctx).unwrap();
1446 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1447 }
1448
1449 #[test]
1450 fn test_sentence_double_space_complex_ending() {
1451 let config = MD064Config {
1453 allow_sentence_double_space: true,
1454 };
1455 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1456
1457 let content = r#"(He said "Yes.") Then they agreed."#;
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1461 }
1462
1463 #[test]
1464 fn test_sentence_double_space_mixed_content() {
1465 let config = MD064Config {
1467 allow_sentence_double_space: true,
1468 };
1469 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1470
1471 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let result = rule.check(&ctx).unwrap();
1474 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1475 assert!(
1476 result[0].column > 15 && result[0].column < 25,
1477 "Should flag the 'Bad mid' double space"
1478 );
1479 }
1480
1481 #[test]
1482 fn test_sentence_double_space_fix_collapses_to_two() {
1483 let config = MD064Config {
1485 allow_sentence_double_space: true,
1486 };
1487 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1488
1489 let content = "Sentence. Three spaces here.";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let fixed = rule.fix(&ctx).unwrap();
1492 assert_eq!(
1493 fixed, "Sentence. Three spaces here.",
1494 "Should collapse to 2 spaces after sentence"
1495 );
1496 }
1497
1498 #[test]
1499 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1500 let config = MD064Config {
1502 allow_sentence_double_space: true,
1503 };
1504 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1505
1506 let content = "Word word here.";
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 let fixed = rule.fix(&ctx).unwrap();
1509 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1510 }
1511
1512 #[test]
1513 fn test_sentence_double_space_config_kebab_case() {
1514 let toml_str = r#"
1515 allow-sentence-double-space = true
1516 "#;
1517 let config: MD064Config = toml::from_str(toml_str).unwrap();
1518 assert!(config.allow_sentence_double_space);
1519 }
1520
1521 #[test]
1522 fn test_sentence_double_space_config_snake_case() {
1523 let toml_str = r#"
1524 allow_sentence_double_space = true
1525 "#;
1526 let config: MD064Config = toml::from_str(toml_str).unwrap();
1527 assert!(config.allow_sentence_double_space);
1528 }
1529
1530 #[test]
1531 fn test_sentence_double_space_at_line_start() {
1532 let config = MD064Config {
1534 allow_sentence_double_space: true,
1535 };
1536 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1537
1538 let content = ". Text after period at start.";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let _result = rule.check(&ctx).unwrap();
1543 }
1544
1545 #[test]
1546 fn test_sentence_double_space_guillemets() {
1547 let config = MD064Config {
1549 allow_sentence_double_space: true,
1550 };
1551 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1552
1553 let content = "Il a dit «Oui.» Puis il est parti.";
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let result = rule.check(&ctx).unwrap();
1556 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1557 }
1558
1559 #[test]
1560 fn test_sentence_double_space_multiple_sentences() {
1561 let config = MD064Config {
1563 allow_sentence_double_space: true,
1564 };
1565 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1566
1567 let content = "First. Second. Third. Fourth.";
1568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1569 let result = rule.check(&ctx).unwrap();
1570 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1571 }
1572
1573 #[test]
1574 fn test_sentence_double_space_abbreviation_detection() {
1575 let config = MD064Config {
1577 allow_sentence_double_space: true,
1578 };
1579 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1580
1581 let content = "Dr. Smith arrived.";
1583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1584 let result = rule.check(&ctx).unwrap();
1585 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1586
1587 let content = "Prof. Williams teaches.";
1589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1590 let result = rule.check(&ctx).unwrap();
1591 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1592
1593 let content = "Use e.g. this example.";
1595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1596 let result = rule.check(&ctx).unwrap();
1597 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1598
1599 let content = "Acme Inc. Next company.";
1602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1603 let result = rule.check(&ctx).unwrap();
1604 assert!(
1605 result.is_empty(),
1606 "Inc. not in abbreviation list, treated as sentence end"
1607 );
1608 }
1609
1610 #[test]
1611 fn test_sentence_double_space_default_config_has_correct_defaults() {
1612 let config = MD064Config::default();
1613 assert!(
1614 !config.allow_sentence_double_space,
1615 "Default allow_sentence_double_space should be false"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_sentence_double_space_from_config_integration() {
1621 use crate::config::Config;
1622 use std::collections::BTreeMap;
1623
1624 let mut config = Config::default();
1625 let mut values = BTreeMap::new();
1626 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1627 config.rules.insert(
1628 "MD064".to_string(),
1629 crate::config::RuleConfig { severity: None, values },
1630 );
1631
1632 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1633
1634 let content = "Sentence. Two spaces OK. But three is not.";
1636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1637 let result = rule.check(&ctx).unwrap();
1638 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1639 }
1640
1641 #[test]
1642 fn test_sentence_double_space_after_inline_code() {
1643 let config = MD064Config {
1645 allow_sentence_double_space: true,
1646 };
1647 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1648
1649 let content = "Hello from `backticks`. How's it going?";
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let result = rule.check(&ctx).unwrap();
1653 assert!(
1654 result.is_empty(),
1655 "Should allow 2 spaces after inline code ending with period"
1656 );
1657
1658 let content = "Use `foo` and `bar`. Next sentence.";
1660 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661 let result = rule.check(&ctx).unwrap();
1662 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1663
1664 let content = "The `code` worked! Celebrate.";
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 with exclamation");
1669
1670 let content = "Is `null` falsy? Yes.";
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 with question mark");
1675
1676 let content = "The `code` is here.";
1678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let result = rule.check(&ctx).unwrap();
1680 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1681 }
1682
1683 #[test]
1684 fn test_sentence_double_space_code_with_closing_punctuation() {
1685 let config = MD064Config {
1687 allow_sentence_double_space: true,
1688 };
1689 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1690
1691 let content = "(see `example`). 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 code in parentheses");
1696
1697 let content = "He said \"use `code`\". Then left.";
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 code in quotes");
1702 }
1703
1704 #[test]
1705 fn test_sentence_double_space_after_emphasis() {
1706 let config = MD064Config {
1708 allow_sentence_double_space: true,
1709 };
1710 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1711
1712 let content = "The word is *important*. Next sentence.";
1714 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1715 let result = rule.check(&ctx).unwrap();
1716 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1717
1718 let content = "The word is _important_. Next sentence.";
1720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1721 let result = rule.check(&ctx).unwrap();
1722 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1723
1724 let content = "The word is **critical**. Next sentence.";
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 let result = rule.check(&ctx).unwrap();
1728 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1729
1730 let content = "The word is __critical__. Next sentence.";
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 let result = rule.check(&ctx).unwrap();
1734 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1735 }
1736
1737 #[test]
1738 fn test_sentence_double_space_after_strikethrough() {
1739 let config = MD064Config {
1741 allow_sentence_double_space: true,
1742 };
1743 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1744
1745 let content = "This is ~~wrong~~. 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 strikethrough");
1749
1750 let content = "That was ~~bad~~! Learn from it.";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753 let result = rule.check(&ctx).unwrap();
1754 assert!(
1755 result.is_empty(),
1756 "Should allow 2 spaces after strikethrough with exclamation"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_sentence_double_space_after_extended_markdown() {
1762 let config = MD064Config {
1764 allow_sentence_double_space: true,
1765 };
1766 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1767
1768 let content = "This is ==highlighted==. Next sentence.";
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 let result = rule.check(&ctx).unwrap();
1772 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1773
1774 let content = "E equals mc^2^. Einstein said.";
1776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let result = rule.check(&ctx).unwrap();
1778 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1779 }
1780
1781 #[test]
1782 fn test_inline_config_allow_sentence_double_space() {
1783 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1791 let result = rule.check(&ctx).unwrap();
1792 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1793
1794 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1797
1798`<svg>`. Fortunately"#;
1799 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1800 let base_config = crate::config::Config::default();
1801 let merged_config = base_config.merge_with_inline_config(&inline_config);
1802 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1804 let result = effective_rule.check(&ctx).unwrap();
1805 assert!(
1806 result.is_empty(),
1807 "Inline config should allow double spaces after sentence"
1808 );
1809
1810 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1812
1813**scalable**. Pick"#;
1814 let inline_config = crate::inline_config::InlineConfig::from_content(content);
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!(result.is_empty(), "Inline config with markdownlint prefix should work");
1820 }
1821
1822 #[test]
1823 fn test_inline_config_allow_sentence_double_space_issue_364() {
1824 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1828
1829# Title
1830
1831what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1832
1833And here is where I want to say, SVG documents are **scalable**. Pick
1834
1835That's right, no `width`, no `height`, no `viewBox`. There is no easy
1836
1837**SVG documents are scalable**. That's their whole reason for being!"#;
1838
1839 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1841 let base_config = crate::config::Config::default();
1842 let merged_config = base_config.merge_with_inline_config(&inline_config);
1843 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1845 let result = effective_rule.check(&ctx).unwrap();
1846 assert!(
1847 result.is_empty(),
1848 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1849 result.len()
1850 );
1851 }
1852
1853 #[test]
1854 fn test_indented_reference_link_not_flagged() {
1855 let rule = MD064NoMultipleConsecutiveSpaces::default();
1858
1859 let content = " [label]: https://example.com";
1861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862 let result = rule.check(&ctx).unwrap();
1863 assert!(
1864 result.is_empty(),
1865 "Indented reference link definitions should not be flagged, got: {:?}",
1866 result
1867 .iter()
1868 .map(|w| format!("col={}: {}", w.column, &w.message))
1869 .collect::<Vec<_>>()
1870 );
1871
1872 let content = "[label]: https://example.com";
1874 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1875 let result = rule.check(&ctx).unwrap();
1876 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1877 }
1878
1879 #[test]
1880 fn test_pre_block_with_blank_line_not_flagged() {
1881 let rule = MD064NoMultipleConsecutiveSpaces::default();
1884
1885 let content = "# Heading\n\n<pre>\n\nhello world\n</pre>\n";
1886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1887 let result = rule.check(&ctx).unwrap();
1888 assert!(
1889 result.is_empty(),
1890 "MD064 must not fire inside <pre> when a blank line precedes the content, got: {result:?}"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_textarea_block_with_blank_line_not_flagged() {
1896 let rule = MD064NoMultipleConsecutiveSpaces::default();
1899
1900 let content = "<textarea>\n\ninner content\n</textarea>\n";
1901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902 let result = rule.check(&ctx).unwrap();
1903 assert!(
1904 result.is_empty(),
1905 "MD064 must not fire inside <textarea> when a blank line precedes the content, got: {result:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_div_with_blank_line_content_is_flagged() {
1911 let rule = MD064NoMultipleConsecutiveSpaces::default();
1915
1916 let content = "<div>\ninner\n\nafter blank\n</div>\n";
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919 assert!(
1920 !result.is_empty(),
1921 "MD064 must fire on content after a blank line inside a <div> block"
1922 );
1923 }
1924
1925 #[test]
1926 fn test_column_aligned_list_not_flagged_cdk_template() {
1927 let rule = MD064NoMultipleConsecutiveSpaces::default();
1932
1933 let content = "# Useful commands\n\n\
1934 - `cdk ls` list all stacks in the app\n\
1935 - `cdk synth` emits the synthesized CloudFormation template\n\
1936 - `cdk deploy` deploy this stack to your default AWS account/region\n\
1937 - `cdk diff` compare deployed stack with current state\n\
1938 - `cdk docs` open CDK documentation\n";
1939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1940 let result = rule.check(&ctx).unwrap();
1941 assert!(
1942 result.is_empty(),
1943 "Column-aligned list (cdk init template) must not be flagged, got: {:?}",
1944 result
1945 .iter()
1946 .map(|w| format!("L{}C{}: {}", w.line, w.column, w.message))
1947 .collect::<Vec<_>>()
1948 );
1949
1950 let fixed = rule.fix(&ctx).unwrap();
1952 assert_eq!(fixed, content, "fix must not collapse intentional alignment");
1953 }
1954
1955 #[test]
1956 fn test_column_aligned_two_item_list_not_flagged() {
1957 let rule = MD064NoMultipleConsecutiveSpaces::default();
1959
1960 let content = "- `a` alpha\n- `b` beta\n";
1961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1962 let result = rule.check(&ctx).unwrap();
1963 assert!(
1964 result.is_empty(),
1965 "Two-item aligned list must not be flagged, got: {result:?}"
1966 );
1967 }
1968
1969 #[test]
1970 fn test_partially_aligned_list_is_flagged_normally() {
1971 let rule = MD064NoMultipleConsecutiveSpaces::default();
1975
1976 let content = "- `a` alpha\n- short\n- `c` gamma\n";
1977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978 let result = rule.check(&ctx).unwrap();
1979 assert_eq!(
1980 result.len(),
1981 2,
1982 "Items with multi-space must be flagged when the surrounding list is not uniformly aligned, got: {result:?}"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_single_item_list_with_multi_space_still_flagged() {
1988 let rule = MD064NoMultipleConsecutiveSpaces::default();
1991
1992 let content = "- `a` alpha\n";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 let result = rule.check(&ctx).unwrap();
1995 assert_eq!(
1996 result.len(),
1997 1,
1998 "Single-item list must still be flagged, got: {result:?}"
1999 );
2000 }
2001
2002 #[test]
2003 fn test_nested_aligned_lists_evaluated_independently() {
2004 let rule = MD064NoMultipleConsecutiveSpaces::default();
2007
2008 let content = "- `cmd1` outer one\n\
2009 \x20\x20- `sub-a` inner one\n\
2010 \x20\x20- `sub-b` inner two\n\
2011 - `cmd2` outer two\n";
2012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2013 let result = rule.check(&ctx).unwrap();
2014 assert!(
2015 result.is_empty(),
2016 "Nested column-aligned lists must not be flagged, got: {result:?}"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_siblings_at_a_different_indent_share_the_alignment_exemption() {
2022 let rule = MD064NoMultipleConsecutiveSpaces::default();
2026 for content in [
2027 " - `cmd1` one\n - `cmd2` two\n",
2028 " - `cmd1` one\n - sub\n - `cmd2` two\n",
2029 ] {
2030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2031 let result = rule.check(&ctx).unwrap();
2032 assert!(result.is_empty(), "{content:?}: {result:?}");
2033 assert_eq!(
2034 rule.fix(&ctx).unwrap(),
2035 content,
2036 "{content:?}: fix must keep the alignment"
2037 );
2038 }
2039
2040 for (content, line) in [
2045 (" - `cmd1` one\n - `cmd2` two\n", 1),
2046 (" - `cmd1` one\n - `sub` padded\n - `cmd2` two\n", 2),
2047 ] {
2048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2049 let result = rule.check(&ctx).unwrap();
2050 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2051 assert_eq!(result[0].line, line, "{content:?}");
2052 }
2053
2054 let content = " > - `a` parent\n> - `b` child\n";
2058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2059 let result = rule.check(&ctx).unwrap();
2060 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2061 assert_eq!(result[0].line, 1, "{content:?}");
2062 assert_eq!(result[1].line, 2, "{content:?}");
2063 let content = "> - `a` parent\n> - `b` child\n";
2065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2066 let result = rule.check(&ctx).unwrap();
2067 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2068
2069 let content = "- p\n >- `b` x\n>- `c` x\n";
2073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2074 let result = rule.check(&ctx).unwrap();
2075 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2076 assert_eq!(result[0].line, 2, "{content:?}");
2077 assert_eq!(result[1].line, 3, "{content:?}");
2078
2079 let content = "- p\n```\n```\n > - `b` x\n lazy\n> - `c` x\n";
2084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2085 let result = rule.check(&ctx).unwrap();
2086 assert!(result.is_empty(), "{content:?}: {result:?}");
2087 assert_eq!(
2088 rule.fix(&ctx).unwrap(),
2089 content,
2090 "{content:?}: fix must keep the alignment"
2091 );
2092 let content = "- p\n > - `b` x\n lazy\n> - `c` x\n";
2093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2094 let result = rule.check(&ctx).unwrap();
2095 assert_eq!(result.len(), 2, "{content:?}: {result:?}");
2096 assert_eq!(result[0].line, 2, "{content:?}");
2097 assert_eq!(result[1].line, 4, "{content:?}");
2098 }
2099
2100 #[test]
2101 fn test_unaligned_nested_item_does_not_break_parent_alignment() {
2102 let rule = MD064NoMultipleConsecutiveSpaces::default();
2107 for (label, content) in [
2108 (
2109 "spaces",
2110 "- first aligned\n- second aligned\n- third aligned\n 1. child\n",
2111 ),
2112 (
2113 "tab",
2114 "- first aligned\n- second aligned\n- third aligned\n\t1. child\n",
2115 ),
2116 ] {
2117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2118 let result = rule.check(&ctx).unwrap();
2119 assert!(result.is_empty(), "{label}: {result:?}");
2120 assert_eq!(rule.fix(&ctx).unwrap(), content, "{label}: fix must keep the alignment");
2121 }
2122
2123 let content = "- first aligned\n- second aligned\n 1. child padded\n";
2126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2127 let result = rule.check(&ctx).unwrap();
2128 assert_eq!(result.len(), 1, "{result:?}");
2129 assert_eq!(result[0].line, 3);
2130
2131 for (content, line) in [
2136 (
2137 "- parent\n - a aligned\n - b aligned\n <!-- parent block -->\n - only padded\n- next\n",
2138 5,
2139 ),
2140 (
2141 "- parent\n - a aligned\n - b aligned\n >\n - only padded\n- next\n",
2142 5,
2143 ),
2144 (
2145 "- parent\n - a aligned\n - b aligned\n >\n parent\n - only padded\n- next\n",
2146 6,
2147 ),
2148 ] {
2149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2150 let result = rule.check(&ctx).unwrap();
2151 assert_eq!(result.len(), 1, "{content:?}: {result:?}");
2152 assert_eq!(result[0].line, line, "{content:?}");
2153 }
2154 }
2155}