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 let leading_spaces = line.len() - trimmed.len();
201
202 if trimmed.starts_with('[')
204 && let Some(bracket_end) = trimmed.find("]:")
205 {
206 let colon_pos = leading_spaces + bracket_end + 2;
207 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
209 return true;
210 }
211 }
212
213 false
214 }
215
216 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
219 let trimmed = line.trim_start();
220
221 if trimmed.starts_with("[^")
223 && let Some(bracket_end) = trimmed.find("]:")
224 {
225 let leading_spaces = line.len() - trimmed.len();
226 let colon_pos = leading_spaces + bracket_end + 2;
227 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
229 return true;
230 }
231 }
232
233 false
234 }
235
236 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
239 let before = line[..match_start].trim_start();
240
241 before == ":"
243 }
244
245 fn is_after_task_checkbox(&self, line: &str, match_start: usize, flavor: crate::config::MarkdownFlavor) -> bool {
250 let before = line[..match_start].trim_start();
251
252 let mut chars = before.chars();
254 let pattern = (
255 chars.next(),
256 chars.next(),
257 chars.next(),
258 chars.next(),
259 chars.next(),
260 chars.next(),
261 );
262
263 match pattern {
264 (Some('*' | '-' | '+'), Some(' '), Some('['), Some(c), Some(']'), None) => {
265 if flavor == crate::config::MarkdownFlavor::Obsidian {
266 true
268 } else {
269 matches!(c, ' ' | 'x' | 'X')
271 }
272 }
273 _ => false,
274 }
275 }
276
277 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
280 let trimmed = line.trim();
281
282 if !trimmed.contains('|') {
284 return false;
285 }
286
287 if trimmed.starts_with('|') || trimmed.ends_with('|') {
289 return false;
290 }
291
292 let parts: Vec<&str> = trimmed.split('|').collect();
296 if parts.len() >= 2 {
297 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
300 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
301 if first_has_content || last_has_content {
302 return true;
303 }
304 }
305
306 false
307 }
308}
309
310impl Rule for MD064NoMultipleConsecutiveSpaces {
311 fn name(&self) -> &'static str {
312 "MD064"
313 }
314
315 fn description(&self) -> &'static str {
316 "Multiple consecutive spaces"
317 }
318
319 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
320 let content = ctx.content;
321
322 if !content.contains(" ") {
324 return Ok(vec![]);
325 }
326
327 let mut warnings = Vec::new();
329 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
330 let line_index = &ctx.line_index;
331
332 for line in ctx
334 .filtered_lines()
335 .skip_front_matter()
336 .skip_code_blocks()
337 .skip_html_blocks()
338 .skip_html_comments()
339 .skip_mkdocstrings()
340 .skip_esm_blocks()
341 .skip_jsx_expressions()
342 .skip_mdx_comments()
343 .skip_pymdown_blocks()
344 .skip_obsidian_comments()
345 {
346 if !line.content.contains(" ") {
348 continue;
349 }
350
351 if is_table_line(line.content) {
353 continue;
354 }
355
356 if self.is_table_without_outer_pipes(line.content) {
358 continue;
359 }
360
361 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
362
363 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
365 let match_start = mat.start();
366 let match_end = mat.end();
367 let space_count = match_end - match_start;
368
369 if self.is_leading_indentation(line.content, match_start) {
371 continue;
372 }
373
374 if self.is_trailing_whitespace(line.content, match_end) {
376 continue;
377 }
378
379 if self.is_tab_replacement_pattern(space_count) {
382 continue;
383 }
384
385 if self.is_after_list_marker(line.content, match_start) {
387 continue;
388 }
389
390 if self.is_after_blockquote_marker(line.content, match_start) {
392 continue;
393 }
394
395 if self.is_after_footnote_marker(line.content, match_start) {
397 continue;
398 }
399
400 if self.is_reference_link_definition(line.content, match_start) {
402 continue;
403 }
404
405 if self.is_after_definition_marker(line.content, match_start) {
407 continue;
408 }
409
410 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
412 continue;
413 }
414
415 if self.config.allow_sentence_double_space
418 && space_count == 2
419 && is_after_sentence_ending(line.content, match_start)
420 {
421 continue;
422 }
423
424 let abs_byte_start = line_start_byte + match_start;
426
427 if self.is_in_code_span(&code_spans, abs_byte_start) {
429 continue;
430 }
431
432 let abs_byte_end = line_start_byte + match_end;
434
435 let replacement =
438 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
439 " ".to_string() } else {
441 " ".to_string() };
443
444 warnings.push(LintWarning {
445 rule_name: Some(self.name().to_string()),
446 message: format!("Multiple consecutive spaces ({space_count}) found"),
447 line: line.line_num,
448 column: match_start + 1, end_line: line.line_num,
450 end_column: match_end + 1, severity: Severity::Warning,
452 fix: Some(Fix {
453 range: abs_byte_start..abs_byte_end,
454 replacement,
455 }),
456 });
457 }
458 }
459
460 Ok(warnings)
461 }
462
463 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
464 let content = ctx.content;
465
466 if !content.contains(" ") {
468 return Ok(content.to_string());
469 }
470
471 let warnings = self.check(ctx)?;
473 let warnings =
474 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
475 if warnings.is_empty() {
476 return Ok(content.to_string());
477 }
478
479 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
481 .into_iter()
482 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
483 .collect();
484
485 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
486
487 let mut result = content.to_string();
489 for (range, replacement) in fixes {
490 if range.start < result.len() && range.end <= result.len() {
491 result.replace_range(range, &replacement);
492 }
493 }
494
495 Ok(result)
496 }
497
498 fn category(&self) -> RuleCategory {
500 RuleCategory::Whitespace
501 }
502
503 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
505 ctx.content.is_empty() || !ctx.content.contains(" ")
506 }
507
508 fn as_any(&self) -> &dyn std::any::Any {
509 self
510 }
511
512 fn default_config_section(&self) -> Option<(String, toml::Value)> {
513 let default_config = MD064Config::default();
514 let json_value = serde_json::to_value(&default_config).ok()?;
515 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
516
517 if let toml::Value::Table(table) = toml_value {
518 if !table.is_empty() {
519 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
520 } else {
521 None
522 }
523 } else {
524 None
525 }
526 }
527
528 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
529 where
530 Self: Sized,
531 {
532 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
533 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::lint_context::LintContext;
541
542 #[test]
543 fn test_basic_multiple_spaces() {
544 let rule = MD064NoMultipleConsecutiveSpaces::new();
545
546 let content = "This is a sentence with extra spaces.";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let result = rule.check(&ctx).unwrap();
550 assert_eq!(result.len(), 1);
551 assert_eq!(result[0].line, 1);
552 assert_eq!(result[0].column, 8); }
554
555 #[test]
556 fn test_no_issues_single_spaces() {
557 let rule = MD064NoMultipleConsecutiveSpaces::new();
558
559 let content = "This is a normal sentence with single spaces.";
561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty());
564 }
565
566 #[test]
567 fn test_skip_inline_code() {
568 let rule = MD064NoMultipleConsecutiveSpaces::new();
569
570 let content = "Use `code with spaces` for formatting.";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert!(result.is_empty());
575 }
576
577 #[test]
578 fn test_skip_code_blocks() {
579 let rule = MD064NoMultipleConsecutiveSpaces::new();
580
581 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert!(result.is_empty());
586 }
587
588 #[test]
589 fn test_skip_leading_indentation() {
590 let rule = MD064NoMultipleConsecutiveSpaces::new();
591
592 let content = " This is indented text.";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert!(result.is_empty());
597 }
598
599 #[test]
600 fn test_skip_trailing_spaces() {
601 let rule = MD064NoMultipleConsecutiveSpaces::new();
602
603 let content = "Line with trailing spaces \nNext line.";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(result.is_empty());
608 }
609
610 #[test]
611 fn test_skip_all_trailing_spaces() {
612 let rule = MD064NoMultipleConsecutiveSpaces::new();
613
614 let content = "Two spaces \nThree spaces \nFour spaces \n";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(result.is_empty());
619 }
620
621 #[test]
622 fn test_skip_front_matter() {
623 let rule = MD064NoMultipleConsecutiveSpaces::new();
624
625 let content = "---\ntitle: Test Title\n---\n\nContent here.";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629 assert!(result.is_empty());
630 }
631
632 #[test]
633 fn test_skip_html_comments() {
634 let rule = MD064NoMultipleConsecutiveSpaces::new();
635
636 let content = "<!-- comment with spaces -->\n\nContent here.";
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(result.is_empty());
641 }
642
643 #[test]
644 fn test_multiple_issues_one_line() {
645 let rule = MD064NoMultipleConsecutiveSpaces::new();
646
647 let content = "This has multiple issues.";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let result = rule.check(&ctx).unwrap();
651 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
652 }
653
654 #[test]
655 fn test_fix_collapses_spaces() {
656 let rule = MD064NoMultipleConsecutiveSpaces::new();
657
658 let content = "This is a sentence with extra spaces.";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let fixed = rule.fix(&ctx).unwrap();
661 assert_eq!(fixed, "This is a sentence with extra spaces.");
662 }
663
664 #[test]
665 fn test_fix_preserves_inline_code() {
666 let rule = MD064NoMultipleConsecutiveSpaces::new();
667
668 let content = "Text here `code inside` and more.";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let fixed = rule.fix(&ctx).unwrap();
671 assert_eq!(fixed, "Text here `code inside` and more.");
672 }
673
674 #[test]
675 fn test_fix_preserves_trailing_spaces() {
676 let rule = MD064NoMultipleConsecutiveSpaces::new();
677
678 let content = "Line with extra and trailing \nNext line.";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let fixed = rule.fix(&ctx).unwrap();
682 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
684 }
685
686 #[test]
687 fn test_list_items_with_extra_spaces() {
688 let rule = MD064NoMultipleConsecutiveSpaces::new();
689
690 let content = "- Item one\n- Item two\n";
691 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert_eq!(result.len(), 2, "Should flag spaces in list items");
694 }
695
696 #[test]
697 fn test_blockquote_with_extra_spaces_in_content() {
698 let rule = MD064NoMultipleConsecutiveSpaces::new();
699
700 let content = "> Quote with extra spaces\n";
702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703 let result = rule.check(&ctx).unwrap();
704 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
705 }
706
707 #[test]
708 fn test_skip_blockquote_marker_spaces() {
709 let rule = MD064NoMultipleConsecutiveSpaces::new();
710
711 let content = "> Text with extra space 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 = "> Text with three spaces after marker\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 let content = ">> Nested blockquote\n";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty());
728 }
729
730 #[test]
731 fn test_mixed_content() {
732 let rule = MD064NoMultipleConsecutiveSpaces::new();
733
734 let content = r#"# Heading
735
736This has extra spaces.
737
738```
739code here is fine
740```
741
742- List item
743
744> Quote text
745
746Normal paragraph.
747"#;
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
752 }
753
754 #[test]
755 fn test_multibyte_utf8() {
756 let rule = MD064NoMultipleConsecutiveSpaces::new();
757
758 let content = "日本語 テスト 文字列";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx);
762 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
763
764 let warnings = result.unwrap();
765 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
766 }
767
768 #[test]
769 fn test_table_rows_skipped() {
770 let rule = MD064NoMultipleConsecutiveSpaces::new();
771
772 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
775 let result = rule.check(&ctx).unwrap();
776 assert!(result.is_empty());
778 }
779
780 #[test]
781 fn test_link_text_with_extra_spaces() {
782 let rule = MD064NoMultipleConsecutiveSpaces::new();
783
784 let content = "[Link text](https://example.com)";
786 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let result = rule.check(&ctx).unwrap();
788 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
789 }
790
791 #[test]
792 fn test_image_alt_with_extra_spaces() {
793 let rule = MD064NoMultipleConsecutiveSpaces::new();
794
795 let content = "";
797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
800 }
801
802 #[test]
803 fn test_skip_list_marker_spaces() {
804 let rule = MD064NoMultipleConsecutiveSpaces::new();
805
806 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\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 = "1. Item one\n2. Item two\n10. Item ten\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 let content = " * Indented item\n 1. Nested numbered item\n";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822 assert!(result.is_empty());
823 }
824
825 #[test]
826 fn test_flag_spaces_in_list_content() {
827 let rule = MD064NoMultipleConsecutiveSpaces::new();
828
829 let content = "* Item with extra spaces in content\n";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule.check(&ctx).unwrap();
833 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
834 }
835
836 #[test]
837 fn test_skip_reference_link_definition_spaces() {
838 let rule = MD064NoMultipleConsecutiveSpaces::new();
839
840 let content = "[ref]: https://example.com\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 let content = "[reference-link]: https://example.com \"Title\"\n";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert!(result.is_empty());
851 }
852
853 #[test]
854 fn test_skip_footnote_marker_spaces() {
855 let rule = MD064NoMultipleConsecutiveSpaces::new();
856
857 let content = "[^1]: Footnote with extra space\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 let content = "[^footnote-label]: This is the footnote text.\n";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty());
868 }
869
870 #[test]
871 fn test_skip_definition_list_marker_spaces() {
872 let rule = MD064NoMultipleConsecutiveSpaces::new();
873
874 let content = "Term\n: Definition with extra spaces\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 let content = ": Another definition\n";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884 assert!(result.is_empty());
885 }
886
887 #[test]
888 fn test_skip_task_list_checkbox_spaces() {
889 let rule = MD064NoMultipleConsecutiveSpaces::new();
890
891 let content = "- [ ] Task with extra space\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 = "- [x] Completed task\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 let content = "* [ ] Task with asterisk marker\n";
905 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
906 let result = rule.check(&ctx).unwrap();
907 assert!(result.is_empty());
908 }
909
910 #[test]
911 fn test_skip_extended_task_checkbox_spaces_obsidian() {
912 let rule = MD064NoMultipleConsecutiveSpaces::new();
914
915 let content = "- [/] In progress 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 = "- [-] Cancelled 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 = "- [>] Deferred 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 = "- [<] Scheduled 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 = "- [?] Question 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 = "- [!] Important 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 = "- [*] Starred task\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 [*] checkbox in Obsidian");
956
957 let content = "* [/] In progress with asterisk\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 = "+ [-] Cancelled with plus\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 extended checkbox with + marker");
968
969 let content = "- [✓] Completed with checkmark\n";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
972 let result = rule.check(&ctx).unwrap();
973 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
974
975 let content = "- [✗] Failed with X mark\n";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
977 let result = rule.check(&ctx).unwrap();
978 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
979
980 let content = "- [→] Forwarded with arrow\n";
981 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
982 let result = rule.check(&ctx).unwrap();
983 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
984 }
985
986 #[test]
987 fn test_flag_extended_checkboxes_in_standard_flavor() {
988 let rule = MD064NoMultipleConsecutiveSpaces::new();
990
991 let content = "- [/] In progress task\n";
992 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993 let result = rule.check(&ctx).unwrap();
994 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
995
996 let content = "- [-] Cancelled task\n";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let result = rule.check(&ctx).unwrap();
999 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
1000
1001 let content = "- [✓] Unicode checkbox\n";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.check(&ctx).unwrap();
1004 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1005 }
1006
1007 #[test]
1008 fn test_extended_checkboxes_with_indentation() {
1009 let rule = MD064NoMultipleConsecutiveSpaces::new();
1010
1011 let content = " - [/] In progress task\n";
1014 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1015 let result = rule.check(&ctx).unwrap();
1016 assert!(
1017 result.is_empty(),
1018 "Should skip space-indented extended checkbox in Obsidian"
1019 );
1020
1021 let content = " - [-] Cancelled task\n";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1024 let result = rule.check(&ctx).unwrap();
1025 assert!(
1026 result.is_empty(),
1027 "Should skip 3-space indented extended checkbox in Obsidian"
1028 );
1029
1030 let content = "- Parent item\n\t- [/] In progress task\n";
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1034 let result = rule.check(&ctx).unwrap();
1035 assert!(
1036 result.is_empty(),
1037 "Should skip tab-indented nested extended checkbox in Obsidian"
1038 );
1039
1040 let content = " - [/] In progress 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 indented [/] in Standard flavor");
1045
1046 let content = " - [-] Cancelled task\n";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1051
1052 let content = "- Parent item\n\t- [-] Cancelled task\n";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056 assert_eq!(
1057 result.len(),
1058 1,
1059 "Should flag tab-indented nested [-] in Standard flavor"
1060 );
1061
1062 let content = " - [x] Completed task\n";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066 assert!(
1067 result.is_empty(),
1068 "Should skip indented standard [x] checkbox in Standard flavor"
1069 );
1070
1071 let content = "- Parent\n\t- [ ] Pending task\n";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert!(
1076 result.is_empty(),
1077 "Should skip tab-indented nested standard [ ] checkbox"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_skip_table_without_outer_pipes() {
1083 let rule = MD064NoMultipleConsecutiveSpaces::new();
1084
1085 let content = "Col1 | Col2 | Col3\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 = "--------- | --------- | ---------\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 let content = "Data1 | Data2 | Data3\n";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101 assert!(result.is_empty());
1102 }
1103
1104 #[test]
1105 fn test_flag_spaces_in_footnote_content() {
1106 let rule = MD064NoMultipleConsecutiveSpaces::new();
1107
1108 let content = "[^1]: Footnote with extra spaces in content.\n";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1113 }
1114
1115 #[test]
1116 fn test_flag_spaces_in_reference_content() {
1117 let rule = MD064NoMultipleConsecutiveSpaces::new();
1118
1119 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1122 let result = rule.check(&ctx).unwrap();
1123 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1124 }
1125
1126 #[test]
1129 fn test_sentence_double_space_disabled_by_default() {
1130 let rule = MD064NoMultipleConsecutiveSpaces::new();
1132 let content = "First sentence. Second sentence.";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1136 }
1137
1138 #[test]
1139 fn test_sentence_double_space_enabled_allows_period() {
1140 let config = MD064Config {
1142 allow_sentence_double_space: true,
1143 };
1144 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1145
1146 let content = "First sentence. Second sentence.";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149 assert!(result.is_empty(), "Should allow 2 spaces after period");
1150 }
1151
1152 #[test]
1153 fn test_sentence_double_space_enabled_allows_exclamation() {
1154 let config = MD064Config {
1155 allow_sentence_double_space: true,
1156 };
1157 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1158
1159 let content = "Wow! That was great.";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1163 }
1164
1165 #[test]
1166 fn test_sentence_double_space_enabled_allows_question() {
1167 let config = MD064Config {
1168 allow_sentence_double_space: true,
1169 };
1170 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1171
1172 let content = "Is this OK? Yes it is.";
1173 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1174 let result = rule.check(&ctx).unwrap();
1175 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1176 }
1177
1178 #[test]
1179 fn test_sentence_double_space_flags_mid_sentence() {
1180 let config = MD064Config {
1182 allow_sentence_double_space: true,
1183 };
1184 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1185
1186 let content = "Word word in the middle.";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1190 }
1191
1192 #[test]
1193 fn test_sentence_double_space_flags_triple_after_period() {
1194 let config = MD064Config {
1196 allow_sentence_double_space: true,
1197 };
1198 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1199
1200 let content = "First sentence. Three spaces here.";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1204 }
1205
1206 #[test]
1207 fn test_sentence_double_space_with_closing_quote() {
1208 let config = MD064Config {
1210 allow_sentence_double_space: true,
1211 };
1212 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1213
1214 let content = r#"He said "Hello." Then he left."#;
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 let content = "She said 'Goodbye.' And she was gone.";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1224 }
1225
1226 #[test]
1227 fn test_sentence_double_space_with_curly_quotes() {
1228 let config = MD064Config {
1229 allow_sentence_double_space: true,
1230 };
1231 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1232
1233 let content = format!(
1236 "He said {}Hello.{} Then left.",
1237 '\u{201C}', '\u{201D}' );
1240 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1243
1244 let content = format!(
1246 "She said {}Hi.{} And left.",
1247 '\u{2018}', '\u{2019}' );
1250 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1251 let result = rule.check(&ctx).unwrap();
1252 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1253 }
1254
1255 #[test]
1256 fn test_sentence_double_space_with_closing_paren() {
1257 let config = MD064Config {
1258 allow_sentence_double_space: true,
1259 };
1260 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1261
1262 let content = "(See reference.) The next point is.";
1263 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1264 let result = rule.check(&ctx).unwrap();
1265 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1266 }
1267
1268 #[test]
1269 fn test_sentence_double_space_with_closing_bracket() {
1270 let config = MD064Config {
1271 allow_sentence_double_space: true,
1272 };
1273 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1274
1275 let content = "[Citation needed.] More text here.";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1279 }
1280
1281 #[test]
1282 fn test_sentence_double_space_with_ellipsis() {
1283 let config = MD064Config {
1284 allow_sentence_double_space: true,
1285 };
1286 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1287
1288 let content = "He paused... Then continued.";
1289 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1290 let result = rule.check(&ctx).unwrap();
1291 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1292 }
1293
1294 #[test]
1295 fn test_sentence_double_space_complex_ending() {
1296 let config = MD064Config {
1298 allow_sentence_double_space: true,
1299 };
1300 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1301
1302 let content = r#"(He said "Yes.") Then they agreed."#;
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let result = rule.check(&ctx).unwrap();
1305 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1306 }
1307
1308 #[test]
1309 fn test_sentence_double_space_mixed_content() {
1310 let config = MD064Config {
1312 allow_sentence_double_space: true,
1313 };
1314 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1315
1316 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318 let result = rule.check(&ctx).unwrap();
1319 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1320 assert!(
1321 result[0].column > 15 && result[0].column < 25,
1322 "Should flag the 'Bad mid' double space"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_sentence_double_space_fix_collapses_to_two() {
1328 let config = MD064Config {
1330 allow_sentence_double_space: true,
1331 };
1332 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1333
1334 let content = "Sentence. Three spaces here.";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let fixed = rule.fix(&ctx).unwrap();
1337 assert_eq!(
1338 fixed, "Sentence. Three spaces here.",
1339 "Should collapse to 2 spaces after sentence"
1340 );
1341 }
1342
1343 #[test]
1344 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1345 let config = MD064Config {
1347 allow_sentence_double_space: true,
1348 };
1349 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1350
1351 let content = "Word word here.";
1352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1353 let fixed = rule.fix(&ctx).unwrap();
1354 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1355 }
1356
1357 #[test]
1358 fn test_sentence_double_space_config_kebab_case() {
1359 let toml_str = r#"
1360 allow-sentence-double-space = true
1361 "#;
1362 let config: MD064Config = toml::from_str(toml_str).unwrap();
1363 assert!(config.allow_sentence_double_space);
1364 }
1365
1366 #[test]
1367 fn test_sentence_double_space_config_snake_case() {
1368 let toml_str = r#"
1369 allow_sentence_double_space = true
1370 "#;
1371 let config: MD064Config = toml::from_str(toml_str).unwrap();
1372 assert!(config.allow_sentence_double_space);
1373 }
1374
1375 #[test]
1376 fn test_sentence_double_space_at_line_start() {
1377 let config = MD064Config {
1379 allow_sentence_double_space: true,
1380 };
1381 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1382
1383 let content = ". Text after period at start.";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1386 let _result = rule.check(&ctx).unwrap();
1388 }
1389
1390 #[test]
1391 fn test_sentence_double_space_guillemets() {
1392 let config = MD064Config {
1394 allow_sentence_double_space: true,
1395 };
1396 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1397
1398 let content = "Il a dit «Oui.» Puis il est parti.";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let result = rule.check(&ctx).unwrap();
1401 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1402 }
1403
1404 #[test]
1405 fn test_sentence_double_space_multiple_sentences() {
1406 let config = MD064Config {
1408 allow_sentence_double_space: true,
1409 };
1410 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1411
1412 let content = "First. Second. Third. Fourth.";
1413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1414 let result = rule.check(&ctx).unwrap();
1415 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1416 }
1417
1418 #[test]
1419 fn test_sentence_double_space_abbreviation_detection() {
1420 let config = MD064Config {
1422 allow_sentence_double_space: true,
1423 };
1424 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1425
1426 let content = "Dr. Smith arrived.";
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 Dr. as abbreviation, not sentence ending");
1431
1432 let content = "Prof. Williams teaches.";
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 Prof. as abbreviation");
1437
1438 let content = "Use e.g. this example.";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let result = rule.check(&ctx).unwrap();
1442 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1443
1444 let content = "Acme Inc. Next company.";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let result = rule.check(&ctx).unwrap();
1449 assert!(
1450 result.is_empty(),
1451 "Inc. not in abbreviation list, treated as sentence end"
1452 );
1453 }
1454
1455 #[test]
1456 fn test_sentence_double_space_default_config_has_correct_defaults() {
1457 let config = MD064Config::default();
1458 assert!(
1459 !config.allow_sentence_double_space,
1460 "Default allow_sentence_double_space should be false"
1461 );
1462 }
1463
1464 #[test]
1465 fn test_sentence_double_space_from_config_integration() {
1466 use crate::config::Config;
1467 use std::collections::BTreeMap;
1468
1469 let mut config = Config::default();
1470 let mut values = BTreeMap::new();
1471 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1472 config.rules.insert(
1473 "MD064".to_string(),
1474 crate::config::RuleConfig { severity: None, values },
1475 );
1476
1477 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1478
1479 let content = "Sentence. Two spaces OK. But three is not.";
1481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1482 let result = rule.check(&ctx).unwrap();
1483 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1484 }
1485
1486 #[test]
1487 fn test_sentence_double_space_after_inline_code() {
1488 let config = MD064Config {
1490 allow_sentence_double_space: true,
1491 };
1492 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1493
1494 let content = "Hello from `backticks`. How's it going?";
1496 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497 let result = rule.check(&ctx).unwrap();
1498 assert!(
1499 result.is_empty(),
1500 "Should allow 2 spaces after inline code ending with period"
1501 );
1502
1503 let content = "Use `foo` and `bar`. Next sentence.";
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 at end of sentence");
1508
1509 let content = "The `code` worked! Celebrate.";
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 exclamation");
1514
1515 let content = "Is `null` falsy? Yes.";
1517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let result = rule.check(&ctx).unwrap();
1519 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1520
1521 let content = "The `code` is here.";
1523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524 let result = rule.check(&ctx).unwrap();
1525 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1526 }
1527
1528 #[test]
1529 fn test_sentence_double_space_code_with_closing_punctuation() {
1530 let config = MD064Config {
1532 allow_sentence_double_space: true,
1533 };
1534 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1535
1536 let content = "(see `example`). Next sentence.";
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 parentheses");
1541
1542 let content = "He said \"use `code`\". Then left.";
1544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1545 let result = rule.check(&ctx).unwrap();
1546 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1547 }
1548
1549 #[test]
1550 fn test_sentence_double_space_after_emphasis() {
1551 let config = MD064Config {
1553 allow_sentence_double_space: true,
1554 };
1555 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
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 emphasis");
1562
1563 let content = "The word is _important_. 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 underscore emphasis");
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 bold");
1574
1575 let content = "The word is __critical__. Next sentence.";
1577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578 let result = rule.check(&ctx).unwrap();
1579 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1580 }
1581
1582 #[test]
1583 fn test_sentence_double_space_after_strikethrough() {
1584 let config = MD064Config {
1586 allow_sentence_double_space: true,
1587 };
1588 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1589
1590 let content = "This is ~~wrong~~. Next sentence.";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1594
1595 let content = "That was ~~bad~~! Learn from it.";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let result = rule.check(&ctx).unwrap();
1599 assert!(
1600 result.is_empty(),
1601 "Should allow 2 spaces after strikethrough with exclamation"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_sentence_double_space_after_extended_markdown() {
1607 let config = MD064Config {
1609 allow_sentence_double_space: true,
1610 };
1611 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1612
1613 let content = "This is ==highlighted==. Next sentence.";
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 highlight");
1618
1619 let content = "E equals mc^2^. Einstein said.";
1621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1624 }
1625
1626 #[test]
1627 fn test_inline_config_allow_sentence_double_space() {
1628 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1638
1639 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1642
1643`<svg>`. Fortunately"#;
1644 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1645 let base_config = crate::config::Config::default();
1646 let merged_config = base_config.merge_with_inline_config(&inline_config);
1647 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1649 let result = effective_rule.check(&ctx).unwrap();
1650 assert!(
1651 result.is_empty(),
1652 "Inline config should allow double spaces after sentence"
1653 );
1654
1655 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1657
1658**scalable**. Pick"#;
1659 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1660 let merged_config = base_config.merge_with_inline_config(&inline_config);
1661 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663 let result = effective_rule.check(&ctx).unwrap();
1664 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1665 }
1666
1667 #[test]
1668 fn test_inline_config_allow_sentence_double_space_issue_364() {
1669 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1673
1674# Title
1675
1676what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1677
1678And here is where I want to say, SVG documents are **scalable**. Pick
1679
1680That's right, no `width`, no `height`, no `viewBox`. There is no easy
1681
1682**SVG documents are scalable**. That's their whole reason for being!"#;
1683
1684 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1686 let base_config = crate::config::Config::default();
1687 let merged_config = base_config.merge_with_inline_config(&inline_config);
1688 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = effective_rule.check(&ctx).unwrap();
1691 assert!(
1692 result.is_empty(),
1693 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1694 result.len()
1695 );
1696 }
1697
1698 #[test]
1699 fn test_indented_reference_link_not_flagged() {
1700 let rule = MD064NoMultipleConsecutiveSpaces::default();
1703
1704 let content = " [label]: https://example.com";
1706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707 let result = rule.check(&ctx).unwrap();
1708 assert!(
1709 result.is_empty(),
1710 "Indented reference link definitions should not be flagged, got: {:?}",
1711 result
1712 .iter()
1713 .map(|w| format!("col={}: {}", w.column, &w.message))
1714 .collect::<Vec<_>>()
1715 );
1716
1717 let content = "[label]: https://example.com";
1719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1720 let result = rule.check(&ctx).unwrap();
1721 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1722 }
1723}