1use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::rule_config_serde::RuleConfig;
31use crate::utils::sentence_utils::is_after_sentence_ending;
32use crate::utils::skip_context::is_table_line;
33use serde::{Deserialize, Serialize};
34use std::sync::Arc;
35
36use regex::Regex;
38use std::sync::LazyLock;
39
40static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
41 Regex::new(r" {2,}").unwrap()
43});
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47#[serde(rename_all = "kebab-case")]
48pub struct MD064Config {
49 #[serde(
73 default = "default_allow_sentence_double_space",
74 alias = "allow_sentence_double_space"
75 )]
76 pub allow_sentence_double_space: bool,
77}
78
79fn default_allow_sentence_double_space() -> bool {
80 false
81}
82
83impl Default for MD064Config {
84 fn default() -> Self {
85 Self {
86 allow_sentence_double_space: default_allow_sentence_double_space(),
87 }
88 }
89}
90
91impl RuleConfig for MD064Config {
92 const RULE_NAME: &'static str = "MD064";
93}
94
95#[derive(Debug, Clone)]
96pub struct MD064NoMultipleConsecutiveSpaces {
97 config: MD064Config,
98}
99
100impl Default for MD064NoMultipleConsecutiveSpaces {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl MD064NoMultipleConsecutiveSpaces {
107 pub fn new() -> Self {
108 Self {
109 config: MD064Config::default(),
110 }
111 }
112
113 pub fn from_config_struct(config: MD064Config) -> Self {
114 Self { config }
115 }
116
117 fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
119 code_spans
120 .iter()
121 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
122 }
123
124 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
127 let remaining = &line[match_end..];
129 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
130 }
131
132 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
134 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
136 }
137
138 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
140 let before = line[..match_start].trim_start();
141
142 if before == "*" || before == "-" || before == "+" {
144 return true;
145 }
146
147 if before.len() >= 2 {
150 let last_char = before.chars().last().unwrap();
151 if last_char == '.' || last_char == ')' {
152 let prefix = &before[..before.len() - 1];
153 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
154 return true;
155 }
156 }
157 }
158
159 false
160 }
161
162 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
165 let before = line[..match_start].trim_start();
166
167 if before.is_empty() {
169 return false;
170 }
171
172 let trimmed = before.trim_end();
174 if trimmed.chars().all(|c| c == '>') {
175 return true;
176 }
177
178 if trimmed.ends_with('>') {
180 let inner = trimmed.trim_end_matches('>').trim();
181 if inner.is_empty() || inner.chars().all(|c| c == '>') {
182 return true;
183 }
184 }
185
186 false
187 }
188
189 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
193 space_count >= 4 && space_count.is_multiple_of(4)
194 }
195
196 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
199 let trimmed = line.trim_start();
200
201 if trimmed.starts_with('[')
203 && let Some(bracket_end) = trimmed.find("]:")
204 {
205 let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
206 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
208 return true;
209 }
210 }
211
212 false
213 }
214
215 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
218 let trimmed = line.trim_start();
219
220 if trimmed.starts_with("[^")
222 && let Some(bracket_end) = trimmed.find("]:")
223 {
224 let leading_spaces = line.len() - trimmed.len();
225 let colon_pos = leading_spaces + bracket_end + 2;
226 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
228 return true;
229 }
230 }
231
232 false
233 }
234
235 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
238 let before = line[..match_start].trim_start();
239
240 before == ":"
242 }
243
244 fn is_after_task_checkbox(&self, line: &str, match_start: usize, flavor: crate::config::MarkdownFlavor) -> bool {
249 let before = line[..match_start].trim_start();
250
251 let mut chars = before.chars();
253 let pattern = (
254 chars.next(),
255 chars.next(),
256 chars.next(),
257 chars.next(),
258 chars.next(),
259 chars.next(),
260 );
261
262 match pattern {
263 (Some('*' | '-' | '+'), Some(' '), Some('['), Some(c), Some(']'), None) => {
264 if flavor == crate::config::MarkdownFlavor::Obsidian {
265 true
267 } else {
268 matches!(c, ' ' | 'x' | 'X')
270 }
271 }
272 _ => false,
273 }
274 }
275
276 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
279 let trimmed = line.trim();
280
281 if !trimmed.contains('|') {
283 return false;
284 }
285
286 if trimmed.starts_with('|') || trimmed.ends_with('|') {
288 return false;
289 }
290
291 let parts: Vec<&str> = trimmed.split('|').collect();
295 if parts.len() >= 2 {
296 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
299 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
300 if first_has_content || last_has_content {
301 return true;
302 }
303 }
304
305 false
306 }
307}
308
309impl Rule for MD064NoMultipleConsecutiveSpaces {
310 fn name(&self) -> &'static str {
311 "MD064"
312 }
313
314 fn description(&self) -> &'static str {
315 "Multiple consecutive spaces"
316 }
317
318 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
319 let content = ctx.content;
320
321 if !content.contains(" ") {
323 return Ok(vec![]);
324 }
325
326 let mut warnings = Vec::new();
328 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
329 let line_index = &ctx.line_index;
330
331 for line in ctx
333 .filtered_lines()
334 .skip_front_matter()
335 .skip_code_blocks()
336 .skip_html_blocks()
337 .skip_html_comments()
338 .skip_mkdocstrings()
339 .skip_esm_blocks()
340 .skip_obsidian_comments()
341 {
342 if !line.content.contains(" ") {
344 continue;
345 }
346
347 if is_table_line(line.content) {
349 continue;
350 }
351
352 if self.is_table_without_outer_pipes(line.content) {
354 continue;
355 }
356
357 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
358
359 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
361 let match_start = mat.start();
362 let match_end = mat.end();
363 let space_count = match_end - match_start;
364
365 if self.is_leading_indentation(line.content, match_start) {
367 continue;
368 }
369
370 if self.is_trailing_whitespace(line.content, match_end) {
372 continue;
373 }
374
375 if self.is_tab_replacement_pattern(space_count) {
378 continue;
379 }
380
381 if self.is_after_list_marker(line.content, match_start) {
383 continue;
384 }
385
386 if self.is_after_blockquote_marker(line.content, match_start) {
388 continue;
389 }
390
391 if self.is_after_footnote_marker(line.content, match_start) {
393 continue;
394 }
395
396 if self.is_reference_link_definition(line.content, match_start) {
398 continue;
399 }
400
401 if self.is_after_definition_marker(line.content, match_start) {
403 continue;
404 }
405
406 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
408 continue;
409 }
410
411 if self.config.allow_sentence_double_space
414 && space_count == 2
415 && is_after_sentence_ending(line.content, match_start)
416 {
417 continue;
418 }
419
420 let abs_byte_start = line_start_byte + match_start;
422
423 if self.is_in_code_span(&code_spans, abs_byte_start) {
425 continue;
426 }
427
428 let abs_byte_end = line_start_byte + match_end;
430
431 let replacement =
434 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
435 " ".to_string() } else {
437 " ".to_string() };
439
440 warnings.push(LintWarning {
441 rule_name: Some(self.name().to_string()),
442 message: format!("Multiple consecutive spaces ({space_count}) found"),
443 line: line.line_num,
444 column: match_start + 1, end_line: line.line_num,
446 end_column: match_end + 1, severity: Severity::Warning,
448 fix: Some(Fix {
449 range: abs_byte_start..abs_byte_end,
450 replacement,
451 }),
452 });
453 }
454 }
455
456 Ok(warnings)
457 }
458
459 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
460 let content = ctx.content;
461
462 if !content.contains(" ") {
464 return Ok(content.to_string());
465 }
466
467 let warnings = self.check(ctx)?;
469 if warnings.is_empty() {
470 return Ok(content.to_string());
471 }
472
473 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
475 .into_iter()
476 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
477 .collect();
478
479 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
480
481 let mut result = content.to_string();
483 for (range, replacement) in fixes {
484 if range.start < result.len() && range.end <= result.len() {
485 result.replace_range(range, &replacement);
486 }
487 }
488
489 Ok(result)
490 }
491
492 fn category(&self) -> RuleCategory {
494 RuleCategory::Whitespace
495 }
496
497 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
499 ctx.content.is_empty() || !ctx.content.contains(" ")
500 }
501
502 fn as_any(&self) -> &dyn std::any::Any {
503 self
504 }
505
506 fn default_config_section(&self) -> Option<(String, toml::Value)> {
507 let default_config = MD064Config::default();
508 let json_value = serde_json::to_value(&default_config).ok()?;
509 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
510
511 if let toml::Value::Table(table) = toml_value {
512 if !table.is_empty() {
513 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
514 } else {
515 None
516 }
517 } else {
518 None
519 }
520 }
521
522 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
523 where
524 Self: Sized,
525 {
526 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
527 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use crate::lint_context::LintContext;
535
536 #[test]
537 fn test_basic_multiple_spaces() {
538 let rule = MD064NoMultipleConsecutiveSpaces::new();
539
540 let content = "This is a sentence with extra spaces.";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert_eq!(result.len(), 1);
545 assert_eq!(result[0].line, 1);
546 assert_eq!(result[0].column, 8); }
548
549 #[test]
550 fn test_no_issues_single_spaces() {
551 let rule = MD064NoMultipleConsecutiveSpaces::new();
552
553 let content = "This is a normal sentence with single spaces.";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty());
558 }
559
560 #[test]
561 fn test_skip_inline_code() {
562 let rule = MD064NoMultipleConsecutiveSpaces::new();
563
564 let content = "Use `code with spaces` for formatting.";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert!(result.is_empty());
569 }
570
571 #[test]
572 fn test_skip_code_blocks() {
573 let rule = MD064NoMultipleConsecutiveSpaces::new();
574
575 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(result.is_empty());
580 }
581
582 #[test]
583 fn test_skip_leading_indentation() {
584 let rule = MD064NoMultipleConsecutiveSpaces::new();
585
586 let content = " This is indented text.";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
589 let result = rule.check(&ctx).unwrap();
590 assert!(result.is_empty());
591 }
592
593 #[test]
594 fn test_skip_trailing_spaces() {
595 let rule = MD064NoMultipleConsecutiveSpaces::new();
596
597 let content = "Line with trailing spaces \nNext line.";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
600 let result = rule.check(&ctx).unwrap();
601 assert!(result.is_empty());
602 }
603
604 #[test]
605 fn test_skip_all_trailing_spaces() {
606 let rule = MD064NoMultipleConsecutiveSpaces::new();
607
608 let content = "Two spaces \nThree spaces \nFour spaces \n";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let result = rule.check(&ctx).unwrap();
612 assert!(result.is_empty());
613 }
614
615 #[test]
616 fn test_skip_front_matter() {
617 let rule = MD064NoMultipleConsecutiveSpaces::new();
618
619 let content = "---\ntitle: Test Title\n---\n\nContent here.";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert!(result.is_empty());
624 }
625
626 #[test]
627 fn test_skip_html_comments() {
628 let rule = MD064NoMultipleConsecutiveSpaces::new();
629
630 let content = "<!-- comment with spaces -->\n\nContent here.";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert!(result.is_empty());
635 }
636
637 #[test]
638 fn test_multiple_issues_one_line() {
639 let rule = MD064NoMultipleConsecutiveSpaces::new();
640
641 let content = "This has multiple issues.";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
646 }
647
648 #[test]
649 fn test_fix_collapses_spaces() {
650 let rule = MD064NoMultipleConsecutiveSpaces::new();
651
652 let content = "This is a sentence with extra spaces.";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let fixed = rule.fix(&ctx).unwrap();
655 assert_eq!(fixed, "This is a sentence with extra spaces.");
656 }
657
658 #[test]
659 fn test_fix_preserves_inline_code() {
660 let rule = MD064NoMultipleConsecutiveSpaces::new();
661
662 let content = "Text here `code inside` and more.";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let fixed = rule.fix(&ctx).unwrap();
665 assert_eq!(fixed, "Text here `code inside` and more.");
666 }
667
668 #[test]
669 fn test_fix_preserves_trailing_spaces() {
670 let rule = MD064NoMultipleConsecutiveSpaces::new();
671
672 let content = "Line with extra and trailing \nNext line.";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let fixed = rule.fix(&ctx).unwrap();
676 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
678 }
679
680 #[test]
681 fn test_list_items_with_extra_spaces() {
682 let rule = MD064NoMultipleConsecutiveSpaces::new();
683
684 let content = "- Item one\n- Item two\n";
685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
686 let result = rule.check(&ctx).unwrap();
687 assert_eq!(result.len(), 2, "Should flag spaces in list items");
688 }
689
690 #[test]
691 fn test_blockquote_with_extra_spaces_in_content() {
692 let rule = MD064NoMultipleConsecutiveSpaces::new();
693
694 let content = "> Quote with extra spaces\n";
696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
697 let result = rule.check(&ctx).unwrap();
698 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
699 }
700
701 #[test]
702 fn test_skip_blockquote_marker_spaces() {
703 let rule = MD064NoMultipleConsecutiveSpaces::new();
704
705 let content = "> Text with extra space after marker\n";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let result = rule.check(&ctx).unwrap();
709 assert!(result.is_empty());
710
711 let content = "> Text with three spaces after marker\n";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715 assert!(result.is_empty());
716
717 let content = ">> Nested blockquote\n";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert!(result.is_empty());
722 }
723
724 #[test]
725 fn test_mixed_content() {
726 let rule = MD064NoMultipleConsecutiveSpaces::new();
727
728 let content = r#"# Heading
729
730This has extra spaces.
731
732```
733code here is fine
734```
735
736- List item
737
738> Quote text
739
740Normal paragraph.
741"#;
742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
746 }
747
748 #[test]
749 fn test_multibyte_utf8() {
750 let rule = MD064NoMultipleConsecutiveSpaces::new();
751
752 let content = "日本語 テスト 文字列";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx);
756 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
757
758 let warnings = result.unwrap();
759 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
760 }
761
762 #[test]
763 fn test_table_rows_skipped() {
764 let rule = MD064NoMultipleConsecutiveSpaces::new();
765
766 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770 assert!(result.is_empty());
772 }
773
774 #[test]
775 fn test_link_text_with_extra_spaces() {
776 let rule = MD064NoMultipleConsecutiveSpaces::new();
777
778 let content = "[Link text](https://example.com)";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
783 }
784
785 #[test]
786 fn test_image_alt_with_extra_spaces() {
787 let rule = MD064NoMultipleConsecutiveSpaces::new();
788
789 let content = "";
791 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
792 let result = rule.check(&ctx).unwrap();
793 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
794 }
795
796 #[test]
797 fn test_skip_list_marker_spaces() {
798 let rule = MD064NoMultipleConsecutiveSpaces::new();
799
800 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let result = rule.check(&ctx).unwrap();
804 assert!(result.is_empty());
805
806 let content = "1. Item one\n2. Item two\n10. Item ten\n";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(result.is_empty());
811
812 let content = " * Indented item\n 1. Nested numbered item\n";
814 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert!(result.is_empty());
817 }
818
819 #[test]
820 fn test_flag_spaces_in_list_content() {
821 let rule = MD064NoMultipleConsecutiveSpaces::new();
822
823 let content = "* Item with extra spaces in content\n";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
828 }
829
830 #[test]
831 fn test_skip_reference_link_definition_spaces() {
832 let rule = MD064NoMultipleConsecutiveSpaces::new();
833
834 let content = "[ref]: https://example.com\n";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert!(result.is_empty());
839
840 let content = "[reference-link]: https://example.com \"Title\"\n";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let result = rule.check(&ctx).unwrap();
844 assert!(result.is_empty());
845 }
846
847 #[test]
848 fn test_skip_footnote_marker_spaces() {
849 let rule = MD064NoMultipleConsecutiveSpaces::new();
850
851 let content = "[^1]: Footnote with extra space\n";
853 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
854 let result = rule.check(&ctx).unwrap();
855 assert!(result.is_empty());
856
857 let content = "[^footnote-label]: This is the footnote text.\n";
859 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
860 let result = rule.check(&ctx).unwrap();
861 assert!(result.is_empty());
862 }
863
864 #[test]
865 fn test_skip_definition_list_marker_spaces() {
866 let rule = MD064NoMultipleConsecutiveSpaces::new();
867
868 let content = "Term\n: Definition with extra spaces\n";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872 assert!(result.is_empty());
873
874 let content = ": Another definition\n";
876 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(result.is_empty());
879 }
880
881 #[test]
882 fn test_skip_task_list_checkbox_spaces() {
883 let rule = MD064NoMultipleConsecutiveSpaces::new();
884
885 let content = "- [ ] Task with extra space\n";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889 assert!(result.is_empty());
890
891 let content = "- [x] Completed task\n";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895 assert!(result.is_empty());
896
897 let content = "* [ ] Task with asterisk marker\n";
899 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
900 let result = rule.check(&ctx).unwrap();
901 assert!(result.is_empty());
902 }
903
904 #[test]
905 fn test_skip_extended_task_checkbox_spaces_obsidian() {
906 let rule = MD064NoMultipleConsecutiveSpaces::new();
908
909 let content = "- [/] In progress task\n";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
912 let result = rule.check(&ctx).unwrap();
913 assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
914
915 let content = "- [-] Cancelled task\n";
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
920
921 let content = "- [>] Deferred task\n";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
924 let result = rule.check(&ctx).unwrap();
925 assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
926
927 let content = "- [<] Scheduled task\n";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
930 let result = rule.check(&ctx).unwrap();
931 assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
932
933 let content = "- [?] Question task\n";
935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
936 let result = rule.check(&ctx).unwrap();
937 assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
938
939 let content = "- [!] Important task\n";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
942 let result = rule.check(&ctx).unwrap();
943 assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
944
945 let content = "- [*] Starred task\n";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
948 let result = rule.check(&ctx).unwrap();
949 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
950
951 let content = "* [/] In progress with asterisk\n";
953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
954 let result = rule.check(&ctx).unwrap();
955 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
956
957 let content = "+ [-] Cancelled with plus\n";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
960 let result = rule.check(&ctx).unwrap();
961 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
962
963 let content = "- [✓] Completed with checkmark\n";
965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
966 let result = rule.check(&ctx).unwrap();
967 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
968
969 let content = "- [✗] Failed with X mark\n";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
971 let result = rule.check(&ctx).unwrap();
972 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
973
974 let content = "- [→] Forwarded with arrow\n";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
976 let result = rule.check(&ctx).unwrap();
977 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
978 }
979
980 #[test]
981 fn test_flag_extended_checkboxes_in_standard_flavor() {
982 let rule = MD064NoMultipleConsecutiveSpaces::new();
984
985 let content = "- [/] In progress task\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 [/] in Standard flavor");
989
990 let content = "- [-] Cancelled task\n";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
992 let result = rule.check(&ctx).unwrap();
993 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
994
995 let content = "- [✓] Unicode checkbox\n";
996 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997 let result = rule.check(&ctx).unwrap();
998 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
999 }
1000
1001 #[test]
1002 fn test_extended_checkboxes_with_indentation() {
1003 let rule = MD064NoMultipleConsecutiveSpaces::new();
1004
1005 let content = " - [/] In progress task\n";
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1009 let result = rule.check(&ctx).unwrap();
1010 assert!(
1011 result.is_empty(),
1012 "Should skip space-indented extended checkbox in Obsidian"
1013 );
1014
1015 let content = " - [-] Cancelled task\n";
1017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert!(
1020 result.is_empty(),
1021 "Should skip 3-space indented extended checkbox in Obsidian"
1022 );
1023
1024 let content = "- Parent item\n\t- [/] In progress task\n";
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(
1030 result.is_empty(),
1031 "Should skip tab-indented nested extended checkbox in Obsidian"
1032 );
1033
1034 let content = " - [/] In progress task\n";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1039
1040 let content = " - [-] Cancelled task\n";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1045
1046 let content = "- Parent item\n\t- [-] Cancelled task\n";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert_eq!(
1051 result.len(),
1052 1,
1053 "Should flag tab-indented nested [-] in Standard flavor"
1054 );
1055
1056 let content = " - [x] Completed task\n";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert!(
1061 result.is_empty(),
1062 "Should skip indented standard [x] checkbox in Standard flavor"
1063 );
1064
1065 let content = "- Parent\n\t- [ ] Pending task\n";
1067 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1068 let result = rule.check(&ctx).unwrap();
1069 assert!(
1070 result.is_empty(),
1071 "Should skip tab-indented nested standard [ ] checkbox"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_skip_table_without_outer_pipes() {
1077 let rule = MD064NoMultipleConsecutiveSpaces::new();
1078
1079 let content = "Col1 | Col2 | Col3\n";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083 assert!(result.is_empty());
1084
1085 let content = "--------- | --------- | ---------\n";
1087 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089 assert!(result.is_empty());
1090
1091 let content = "Data1 | Data2 | Data3\n";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095 assert!(result.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_flag_spaces_in_footnote_content() {
1100 let rule = MD064NoMultipleConsecutiveSpaces::new();
1101
1102 let content = "[^1]: Footnote with extra spaces in content.\n";
1104 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105 let result = rule.check(&ctx).unwrap();
1106 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1107 }
1108
1109 #[test]
1110 fn test_flag_spaces_in_reference_content() {
1111 let rule = MD064NoMultipleConsecutiveSpaces::new();
1112
1113 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1115 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116 let result = rule.check(&ctx).unwrap();
1117 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1118 }
1119
1120 #[test]
1123 fn test_sentence_double_space_disabled_by_default() {
1124 let rule = MD064NoMultipleConsecutiveSpaces::new();
1126 let content = "First sentence. Second sentence.";
1127 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128 let result = rule.check(&ctx).unwrap();
1129 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1130 }
1131
1132 #[test]
1133 fn test_sentence_double_space_enabled_allows_period() {
1134 let config = MD064Config {
1136 allow_sentence_double_space: true,
1137 };
1138 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1139
1140 let content = "First sentence. Second sentence.";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143 assert!(result.is_empty(), "Should allow 2 spaces after period");
1144 }
1145
1146 #[test]
1147 fn test_sentence_double_space_enabled_allows_exclamation() {
1148 let config = MD064Config {
1149 allow_sentence_double_space: true,
1150 };
1151 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1152
1153 let content = "Wow! That was great.";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1157 }
1158
1159 #[test]
1160 fn test_sentence_double_space_enabled_allows_question() {
1161 let config = MD064Config {
1162 allow_sentence_double_space: true,
1163 };
1164 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1165
1166 let content = "Is this OK? Yes it is.";
1167 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1168 let result = rule.check(&ctx).unwrap();
1169 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1170 }
1171
1172 #[test]
1173 fn test_sentence_double_space_flags_mid_sentence() {
1174 let config = MD064Config {
1176 allow_sentence_double_space: true,
1177 };
1178 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1179
1180 let content = "Word word in the middle.";
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1184 }
1185
1186 #[test]
1187 fn test_sentence_double_space_flags_triple_after_period() {
1188 let config = MD064Config {
1190 allow_sentence_double_space: true,
1191 };
1192 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1193
1194 let content = "First sentence. Three spaces here.";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1198 }
1199
1200 #[test]
1201 fn test_sentence_double_space_with_closing_quote() {
1202 let config = MD064Config {
1204 allow_sentence_double_space: true,
1205 };
1206 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1207
1208 let content = r#"He said "Hello." Then he left."#;
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1212
1213 let content = "She said 'Goodbye.' And she was gone.";
1215 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216 let result = rule.check(&ctx).unwrap();
1217 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1218 }
1219
1220 #[test]
1221 fn test_sentence_double_space_with_curly_quotes() {
1222 let config = MD064Config {
1223 allow_sentence_double_space: true,
1224 };
1225 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1226
1227 let content = format!(
1230 "He said {}Hello.{} Then left.",
1231 '\u{201C}', '\u{201D}' );
1234 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1235 let result = rule.check(&ctx).unwrap();
1236 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1237
1238 let content = format!(
1240 "She said {}Hi.{} And left.",
1241 '\u{2018}', '\u{2019}' );
1244 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1247 }
1248
1249 #[test]
1250 fn test_sentence_double_space_with_closing_paren() {
1251 let config = MD064Config {
1252 allow_sentence_double_space: true,
1253 };
1254 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1255
1256 let content = "(See reference.) The next point is.";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1260 }
1261
1262 #[test]
1263 fn test_sentence_double_space_with_closing_bracket() {
1264 let config = MD064Config {
1265 allow_sentence_double_space: true,
1266 };
1267 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1268
1269 let content = "[Citation needed.] More text here.";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1273 }
1274
1275 #[test]
1276 fn test_sentence_double_space_with_ellipsis() {
1277 let config = MD064Config {
1278 allow_sentence_double_space: true,
1279 };
1280 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1281
1282 let content = "He paused... Then continued.";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let result = rule.check(&ctx).unwrap();
1285 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1286 }
1287
1288 #[test]
1289 fn test_sentence_double_space_complex_ending() {
1290 let config = MD064Config {
1292 allow_sentence_double_space: true,
1293 };
1294 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1295
1296 let content = r#"(He said "Yes.") Then they agreed."#;
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1300 }
1301
1302 #[test]
1303 fn test_sentence_double_space_mixed_content() {
1304 let config = MD064Config {
1306 allow_sentence_double_space: true,
1307 };
1308 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1309
1310 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let result = rule.check(&ctx).unwrap();
1313 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1314 assert!(
1315 result[0].column > 15 && result[0].column < 25,
1316 "Should flag the 'Bad mid' double space"
1317 );
1318 }
1319
1320 #[test]
1321 fn test_sentence_double_space_fix_collapses_to_two() {
1322 let config = MD064Config {
1324 allow_sentence_double_space: true,
1325 };
1326 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1327
1328 let content = "Sentence. Three spaces here.";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let fixed = rule.fix(&ctx).unwrap();
1331 assert_eq!(
1332 fixed, "Sentence. Three spaces here.",
1333 "Should collapse to 2 spaces after sentence"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1339 let config = MD064Config {
1341 allow_sentence_double_space: true,
1342 };
1343 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1344
1345 let content = "Word word here.";
1346 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let fixed = rule.fix(&ctx).unwrap();
1348 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1349 }
1350
1351 #[test]
1352 fn test_sentence_double_space_config_kebab_case() {
1353 let toml_str = r#"
1354 allow-sentence-double-space = true
1355 "#;
1356 let config: MD064Config = toml::from_str(toml_str).unwrap();
1357 assert!(config.allow_sentence_double_space);
1358 }
1359
1360 #[test]
1361 fn test_sentence_double_space_config_snake_case() {
1362 let toml_str = r#"
1363 allow_sentence_double_space = true
1364 "#;
1365 let config: MD064Config = toml::from_str(toml_str).unwrap();
1366 assert!(config.allow_sentence_double_space);
1367 }
1368
1369 #[test]
1370 fn test_sentence_double_space_at_line_start() {
1371 let config = MD064Config {
1373 allow_sentence_double_space: true,
1374 };
1375 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1376
1377 let content = ". Text after period at start.";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let _result = rule.check(&ctx).unwrap();
1382 }
1383
1384 #[test]
1385 fn test_sentence_double_space_guillemets() {
1386 let config = MD064Config {
1388 allow_sentence_double_space: true,
1389 };
1390 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1391
1392 let content = "Il a dit «Oui.» Puis il est parti.";
1393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394 let result = rule.check(&ctx).unwrap();
1395 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1396 }
1397
1398 #[test]
1399 fn test_sentence_double_space_multiple_sentences() {
1400 let config = MD064Config {
1402 allow_sentence_double_space: true,
1403 };
1404 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1405
1406 let content = "First. Second. Third. Fourth.";
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 all sentence-ending double spaces");
1410 }
1411
1412 #[test]
1413 fn test_sentence_double_space_abbreviation_detection() {
1414 let config = MD064Config {
1416 allow_sentence_double_space: true,
1417 };
1418 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1419
1420 let content = "Dr. Smith arrived.";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let result = rule.check(&ctx).unwrap();
1424 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1425
1426 let content = "Prof. Williams teaches.";
1428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1429 let result = rule.check(&ctx).unwrap();
1430 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1431
1432 let content = "Use e.g. this example.";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let result = rule.check(&ctx).unwrap();
1436 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1437
1438 let content = "Acme Inc. Next company.";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let result = rule.check(&ctx).unwrap();
1443 assert!(
1444 result.is_empty(),
1445 "Inc. not in abbreviation list, treated as sentence end"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_sentence_double_space_default_config_has_correct_defaults() {
1451 let config = MD064Config::default();
1452 assert!(
1453 !config.allow_sentence_double_space,
1454 "Default allow_sentence_double_space should be false"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_sentence_double_space_from_config_integration() {
1460 use crate::config::Config;
1461 use std::collections::BTreeMap;
1462
1463 let mut config = Config::default();
1464 let mut values = BTreeMap::new();
1465 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1466 config.rules.insert(
1467 "MD064".to_string(),
1468 crate::config::RuleConfig { severity: None, values },
1469 );
1470
1471 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1472
1473 let content = "Sentence. Two spaces OK. But three is not.";
1475 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476 let result = rule.check(&ctx).unwrap();
1477 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1478 }
1479
1480 #[test]
1481 fn test_sentence_double_space_after_inline_code() {
1482 let config = MD064Config {
1484 allow_sentence_double_space: true,
1485 };
1486 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1487
1488 let content = "Hello from `backticks`. How's it going?";
1490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491 let result = rule.check(&ctx).unwrap();
1492 assert!(
1493 result.is_empty(),
1494 "Should allow 2 spaces after inline code ending with period"
1495 );
1496
1497 let content = "Use `foo` and `bar`. Next sentence.";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1502
1503 let content = "The `code` worked! Celebrate.";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let result = rule.check(&ctx).unwrap();
1507 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1508
1509 let content = "Is `null` falsy? Yes.";
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1514
1515 let content = "The `code` is here.";
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let result = rule.check(&ctx).unwrap();
1519 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1520 }
1521
1522 #[test]
1523 fn test_sentence_double_space_code_with_closing_punctuation() {
1524 let config = MD064Config {
1526 allow_sentence_double_space: true,
1527 };
1528 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1529
1530 let content = "(see `example`). Next sentence.";
1532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533 let result = rule.check(&ctx).unwrap();
1534 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1535
1536 let content = "He said \"use `code`\". Then left.";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1541 }
1542
1543 #[test]
1544 fn test_sentence_double_space_after_emphasis() {
1545 let config = MD064Config {
1547 allow_sentence_double_space: true,
1548 };
1549 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1550
1551 let content = "The word is *important*. Next sentence.";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1554 let result = rule.check(&ctx).unwrap();
1555 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1556
1557 let content = "The word is _important_. Next sentence.";
1559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560 let result = rule.check(&ctx).unwrap();
1561 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1562
1563 let content = "The word is **critical**. Next sentence.";
1565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1566 let result = rule.check(&ctx).unwrap();
1567 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1568
1569 let content = "The word is __critical__. Next sentence.";
1571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1572 let result = rule.check(&ctx).unwrap();
1573 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1574 }
1575
1576 #[test]
1577 fn test_sentence_double_space_after_strikethrough() {
1578 let config = MD064Config {
1580 allow_sentence_double_space: true,
1581 };
1582 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1583
1584 let content = "This is ~~wrong~~. Next sentence.";
1585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let result = rule.check(&ctx).unwrap();
1587 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1588
1589 let content = "That was ~~bad~~! Learn from it.";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert!(
1594 result.is_empty(),
1595 "Should allow 2 spaces after strikethrough with exclamation"
1596 );
1597 }
1598
1599 #[test]
1600 fn test_sentence_double_space_after_extended_markdown() {
1601 let config = MD064Config {
1603 allow_sentence_double_space: true,
1604 };
1605 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1606
1607 let content = "This is ==highlighted==. Next sentence.";
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1610 let result = rule.check(&ctx).unwrap();
1611 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1612
1613 let content = "E equals mc^2^. Einstein said.";
1615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616 let result = rule.check(&ctx).unwrap();
1617 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1618 }
1619
1620 #[test]
1621 fn test_inline_config_allow_sentence_double_space() {
1622 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1630 let result = rule.check(&ctx).unwrap();
1631 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1632
1633 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1636
1637`<svg>`. Fortunately"#;
1638 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1639 let base_config = crate::config::Config::default();
1640 let merged_config = base_config.merge_with_inline_config(&inline_config);
1641 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1643 let result = effective_rule.check(&ctx).unwrap();
1644 assert!(
1645 result.is_empty(),
1646 "Inline config should allow double spaces after sentence"
1647 );
1648
1649 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1651
1652**scalable**. Pick"#;
1653 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1654 let merged_config = base_config.merge_with_inline_config(&inline_config);
1655 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = effective_rule.check(&ctx).unwrap();
1658 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1659 }
1660
1661 #[test]
1662 fn test_inline_config_allow_sentence_double_space_issue_364() {
1663 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1667
1668# Title
1669
1670what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1671
1672And here is where I want to say, SVG documents are **scalable**. Pick
1673
1674That's right, no `width`, no `height`, no `viewBox`. There is no easy
1675
1676**SVG documents are scalable**. That's their whole reason for being!"#;
1677
1678 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1680 let base_config = crate::config::Config::default();
1681 let merged_config = base_config.merge_with_inline_config(&inline_config);
1682 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684 let result = effective_rule.check(&ctx).unwrap();
1685 assert!(
1686 result.is_empty(),
1687 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1688 result.len()
1689 );
1690 }
1691}