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_pymdown_blocks()
342 .skip_obsidian_comments()
343 {
344 if !line.content.contains(" ") {
346 continue;
347 }
348
349 if is_table_line(line.content) {
351 continue;
352 }
353
354 if self.is_table_without_outer_pipes(line.content) {
356 continue;
357 }
358
359 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
360
361 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
363 let match_start = mat.start();
364 let match_end = mat.end();
365 let space_count = match_end - match_start;
366
367 if self.is_leading_indentation(line.content, match_start) {
369 continue;
370 }
371
372 if self.is_trailing_whitespace(line.content, match_end) {
374 continue;
375 }
376
377 if self.is_tab_replacement_pattern(space_count) {
380 continue;
381 }
382
383 if self.is_after_list_marker(line.content, match_start) {
385 continue;
386 }
387
388 if self.is_after_blockquote_marker(line.content, match_start) {
390 continue;
391 }
392
393 if self.is_after_footnote_marker(line.content, match_start) {
395 continue;
396 }
397
398 if self.is_reference_link_definition(line.content, match_start) {
400 continue;
401 }
402
403 if self.is_after_definition_marker(line.content, match_start) {
405 continue;
406 }
407
408 if self.is_after_task_checkbox(line.content, match_start, ctx.flavor) {
410 continue;
411 }
412
413 if self.config.allow_sentence_double_space
416 && space_count == 2
417 && is_after_sentence_ending(line.content, match_start)
418 {
419 continue;
420 }
421
422 let abs_byte_start = line_start_byte + match_start;
424
425 if self.is_in_code_span(&code_spans, abs_byte_start) {
427 continue;
428 }
429
430 let abs_byte_end = line_start_byte + match_end;
432
433 let replacement =
436 if self.config.allow_sentence_double_space && is_after_sentence_ending(line.content, match_start) {
437 " ".to_string() } else {
439 " ".to_string() };
441
442 warnings.push(LintWarning {
443 rule_name: Some(self.name().to_string()),
444 message: format!("Multiple consecutive spaces ({space_count}) found"),
445 line: line.line_num,
446 column: match_start + 1, end_line: line.line_num,
448 end_column: match_end + 1, severity: Severity::Warning,
450 fix: Some(Fix {
451 range: abs_byte_start..abs_byte_end,
452 replacement,
453 }),
454 });
455 }
456 }
457
458 Ok(warnings)
459 }
460
461 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
462 let content = ctx.content;
463
464 if !content.contains(" ") {
466 return Ok(content.to_string());
467 }
468
469 let warnings = self.check(ctx)?;
471 if warnings.is_empty() {
472 return Ok(content.to_string());
473 }
474
475 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
477 .into_iter()
478 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
479 .collect();
480
481 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
482
483 let mut result = content.to_string();
485 for (range, replacement) in fixes {
486 if range.start < result.len() && range.end <= result.len() {
487 result.replace_range(range, &replacement);
488 }
489 }
490
491 Ok(result)
492 }
493
494 fn category(&self) -> RuleCategory {
496 RuleCategory::Whitespace
497 }
498
499 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
501 ctx.content.is_empty() || !ctx.content.contains(" ")
502 }
503
504 fn as_any(&self) -> &dyn std::any::Any {
505 self
506 }
507
508 fn default_config_section(&self) -> Option<(String, toml::Value)> {
509 let default_config = MD064Config::default();
510 let json_value = serde_json::to_value(&default_config).ok()?;
511 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
512
513 if let toml::Value::Table(table) = toml_value {
514 if !table.is_empty() {
515 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
516 } else {
517 None
518 }
519 } else {
520 None
521 }
522 }
523
524 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
525 where
526 Self: Sized,
527 {
528 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
529 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::lint_context::LintContext;
537
538 #[test]
539 fn test_basic_multiple_spaces() {
540 let rule = MD064NoMultipleConsecutiveSpaces::new();
541
542 let content = "This is a sentence with extra spaces.";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert_eq!(result.len(), 1);
547 assert_eq!(result[0].line, 1);
548 assert_eq!(result[0].column, 8); }
550
551 #[test]
552 fn test_no_issues_single_spaces() {
553 let rule = MD064NoMultipleConsecutiveSpaces::new();
554
555 let content = "This is a normal sentence with single spaces.";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert!(result.is_empty());
560 }
561
562 #[test]
563 fn test_skip_inline_code() {
564 let rule = MD064NoMultipleConsecutiveSpaces::new();
565
566 let content = "Use `code with spaces` for formatting.";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(result.is_empty());
571 }
572
573 #[test]
574 fn test_skip_code_blocks() {
575 let rule = MD064NoMultipleConsecutiveSpaces::new();
576
577 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581 assert!(result.is_empty());
582 }
583
584 #[test]
585 fn test_skip_leading_indentation() {
586 let rule = MD064NoMultipleConsecutiveSpaces::new();
587
588 let content = " This is indented text.";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert!(result.is_empty());
593 }
594
595 #[test]
596 fn test_skip_trailing_spaces() {
597 let rule = MD064NoMultipleConsecutiveSpaces::new();
598
599 let content = "Line with trailing spaces \nNext line.";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 let result = rule.check(&ctx).unwrap();
603 assert!(result.is_empty());
604 }
605
606 #[test]
607 fn test_skip_all_trailing_spaces() {
608 let rule = MD064NoMultipleConsecutiveSpaces::new();
609
610 let content = "Two spaces \nThree spaces \nFour spaces \n";
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613 let result = rule.check(&ctx).unwrap();
614 assert!(result.is_empty());
615 }
616
617 #[test]
618 fn test_skip_front_matter() {
619 let rule = MD064NoMultipleConsecutiveSpaces::new();
620
621 let content = "---\ntitle: Test Title\n---\n\nContent here.";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let result = rule.check(&ctx).unwrap();
625 assert!(result.is_empty());
626 }
627
628 #[test]
629 fn test_skip_html_comments() {
630 let rule = MD064NoMultipleConsecutiveSpaces::new();
631
632 let content = "<!-- comment with spaces -->\n\nContent here.";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty());
637 }
638
639 #[test]
640 fn test_multiple_issues_one_line() {
641 let rule = MD064NoMultipleConsecutiveSpaces::new();
642
643 let content = "This has multiple issues.";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
648 }
649
650 #[test]
651 fn test_fix_collapses_spaces() {
652 let rule = MD064NoMultipleConsecutiveSpaces::new();
653
654 let content = "This is a sentence with extra spaces.";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let fixed = rule.fix(&ctx).unwrap();
657 assert_eq!(fixed, "This is a sentence with extra spaces.");
658 }
659
660 #[test]
661 fn test_fix_preserves_inline_code() {
662 let rule = MD064NoMultipleConsecutiveSpaces::new();
663
664 let content = "Text here `code inside` and more.";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let fixed = rule.fix(&ctx).unwrap();
667 assert_eq!(fixed, "Text here `code inside` and more.");
668 }
669
670 #[test]
671 fn test_fix_preserves_trailing_spaces() {
672 let rule = MD064NoMultipleConsecutiveSpaces::new();
673
674 let content = "Line with extra and trailing \nNext line.";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let fixed = rule.fix(&ctx).unwrap();
678 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
680 }
681
682 #[test]
683 fn test_list_items_with_extra_spaces() {
684 let rule = MD064NoMultipleConsecutiveSpaces::new();
685
686 let content = "- Item one\n- Item two\n";
687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
688 let result = rule.check(&ctx).unwrap();
689 assert_eq!(result.len(), 2, "Should flag spaces in list items");
690 }
691
692 #[test]
693 fn test_blockquote_with_extra_spaces_in_content() {
694 let rule = MD064NoMultipleConsecutiveSpaces::new();
695
696 let content = "> Quote with extra spaces\n";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let result = rule.check(&ctx).unwrap();
700 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
701 }
702
703 #[test]
704 fn test_skip_blockquote_marker_spaces() {
705 let rule = MD064NoMultipleConsecutiveSpaces::new();
706
707 let content = "> Text with extra space after marker\n";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let result = rule.check(&ctx).unwrap();
711 assert!(result.is_empty());
712
713 let content = "> Text with three spaces after marker\n";
715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
716 let result = rule.check(&ctx).unwrap();
717 assert!(result.is_empty());
718
719 let content = ">> Nested blockquote\n";
721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722 let result = rule.check(&ctx).unwrap();
723 assert!(result.is_empty());
724 }
725
726 #[test]
727 fn test_mixed_content() {
728 let rule = MD064NoMultipleConsecutiveSpaces::new();
729
730 let content = r#"# Heading
731
732This has extra spaces.
733
734```
735code here is fine
736```
737
738- List item
739
740> Quote text
741
742Normal paragraph.
743"#;
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
748 }
749
750 #[test]
751 fn test_multibyte_utf8() {
752 let rule = MD064NoMultipleConsecutiveSpaces::new();
753
754 let content = "日本語 テスト 文字列";
756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx);
758 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
759
760 let warnings = result.unwrap();
761 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
762 }
763
764 #[test]
765 fn test_table_rows_skipped() {
766 let rule = MD064NoMultipleConsecutiveSpaces::new();
767
768 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert!(result.is_empty());
774 }
775
776 #[test]
777 fn test_link_text_with_extra_spaces() {
778 let rule = MD064NoMultipleConsecutiveSpaces::new();
779
780 let content = "[Link text](https://example.com)";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
785 }
786
787 #[test]
788 fn test_image_alt_with_extra_spaces() {
789 let rule = MD064NoMultipleConsecutiveSpaces::new();
790
791 let content = "";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
796 }
797
798 #[test]
799 fn test_skip_list_marker_spaces() {
800 let rule = MD064NoMultipleConsecutiveSpaces::new();
801
802 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
805 let result = rule.check(&ctx).unwrap();
806 assert!(result.is_empty());
807
808 let content = "1. Item one\n2. Item two\n10. Item ten\n";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert!(result.is_empty());
813
814 let content = " * Indented item\n 1. Nested numbered item\n";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert!(result.is_empty());
819 }
820
821 #[test]
822 fn test_flag_spaces_in_list_content() {
823 let rule = MD064NoMultipleConsecutiveSpaces::new();
824
825 let content = "* Item with extra spaces in content\n";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
830 }
831
832 #[test]
833 fn test_skip_reference_link_definition_spaces() {
834 let rule = MD064NoMultipleConsecutiveSpaces::new();
835
836 let content = "[ref]: https://example.com\n";
838 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 let result = rule.check(&ctx).unwrap();
840 assert!(result.is_empty());
841
842 let content = "[reference-link]: https://example.com \"Title\"\n";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846 assert!(result.is_empty());
847 }
848
849 #[test]
850 fn test_skip_footnote_marker_spaces() {
851 let rule = MD064NoMultipleConsecutiveSpaces::new();
852
853 let content = "[^1]: Footnote with extra space\n";
855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
856 let result = rule.check(&ctx).unwrap();
857 assert!(result.is_empty());
858
859 let content = "[^footnote-label]: This is the footnote text.\n";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863 assert!(result.is_empty());
864 }
865
866 #[test]
867 fn test_skip_definition_list_marker_spaces() {
868 let rule = MD064NoMultipleConsecutiveSpaces::new();
869
870 let content = "Term\n: Definition with extra spaces\n";
872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
873 let result = rule.check(&ctx).unwrap();
874 assert!(result.is_empty());
875
876 let content = ": Another definition\n";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880 assert!(result.is_empty());
881 }
882
883 #[test]
884 fn test_skip_task_list_checkbox_spaces() {
885 let rule = MD064NoMultipleConsecutiveSpaces::new();
886
887 let content = "- [ ] Task with extra space\n";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let result = rule.check(&ctx).unwrap();
891 assert!(result.is_empty());
892
893 let content = "- [x] Completed task\n";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897 assert!(result.is_empty());
898
899 let content = "* [ ] Task with asterisk marker\n";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert!(result.is_empty());
904 }
905
906 #[test]
907 fn test_skip_extended_task_checkbox_spaces_obsidian() {
908 let rule = MD064NoMultipleConsecutiveSpaces::new();
910
911 let content = "- [/] In progress task\n";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
914 let result = rule.check(&ctx).unwrap();
915 assert!(result.is_empty(), "Should skip [/] checkbox in Obsidian");
916
917 let content = "- [-] Cancelled task\n";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
920 let result = rule.check(&ctx).unwrap();
921 assert!(result.is_empty(), "Should skip [-] checkbox in Obsidian");
922
923 let content = "- [>] Deferred task\n";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
926 let result = rule.check(&ctx).unwrap();
927 assert!(result.is_empty(), "Should skip [>] checkbox in Obsidian");
928
929 let content = "- [<] Scheduled task\n";
931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
932 let result = rule.check(&ctx).unwrap();
933 assert!(result.is_empty(), "Should skip [<] checkbox in Obsidian");
934
935 let content = "- [?] Question task\n";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
938 let result = rule.check(&ctx).unwrap();
939 assert!(result.is_empty(), "Should skip [?] checkbox in Obsidian");
940
941 let content = "- [!] Important task\n";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
944 let result = rule.check(&ctx).unwrap();
945 assert!(result.is_empty(), "Should skip [!] checkbox in Obsidian");
946
947 let content = "- [*] Starred task\n";
949 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
950 let result = rule.check(&ctx).unwrap();
951 assert!(result.is_empty(), "Should skip [*] checkbox in Obsidian");
952
953 let content = "* [/] In progress with asterisk\n";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
956 let result = rule.check(&ctx).unwrap();
957 assert!(result.is_empty(), "Should skip extended checkbox with * marker");
958
959 let content = "+ [-] Cancelled with plus\n";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
962 let result = rule.check(&ctx).unwrap();
963 assert!(result.is_empty(), "Should skip extended checkbox with + marker");
964
965 let content = "- [✓] Completed with checkmark\n";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
968 let result = rule.check(&ctx).unwrap();
969 assert!(result.is_empty(), "Should skip Unicode checkmark [✓]");
970
971 let content = "- [✗] Failed with X mark\n";
972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
973 let result = rule.check(&ctx).unwrap();
974 assert!(result.is_empty(), "Should skip Unicode X mark [✗]");
975
976 let content = "- [→] Forwarded with arrow\n";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
978 let result = rule.check(&ctx).unwrap();
979 assert!(result.is_empty(), "Should skip Unicode arrow [→]");
980 }
981
982 #[test]
983 fn test_flag_extended_checkboxes_in_standard_flavor() {
984 let rule = MD064NoMultipleConsecutiveSpaces::new();
986
987 let content = "- [/] In progress task\n";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990 assert_eq!(result.len(), 1, "Should flag [/] in Standard flavor");
991
992 let content = "- [-] Cancelled task\n";
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995 assert_eq!(result.len(), 1, "Should flag [-] in Standard flavor");
996
997 let content = "- [✓] Unicode checkbox\n";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let result = rule.check(&ctx).unwrap();
1000 assert_eq!(result.len(), 1, "Should flag [✓] in Standard flavor");
1001 }
1002
1003 #[test]
1004 fn test_extended_checkboxes_with_indentation() {
1005 let rule = MD064NoMultipleConsecutiveSpaces::new();
1006
1007 let content = " - [/] In progress task\n";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1011 let result = rule.check(&ctx).unwrap();
1012 assert!(
1013 result.is_empty(),
1014 "Should skip space-indented extended checkbox in Obsidian"
1015 );
1016
1017 let content = " - [-] Cancelled task\n";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert!(
1022 result.is_empty(),
1023 "Should skip 3-space indented extended checkbox in Obsidian"
1024 );
1025
1026 let content = "- Parent item\n\t- [/] In progress task\n";
1029 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, None);
1030 let result = rule.check(&ctx).unwrap();
1031 assert!(
1032 result.is_empty(),
1033 "Should skip tab-indented nested extended checkbox in Obsidian"
1034 );
1035
1036 let content = " - [/] In progress task\n";
1038 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040 assert_eq!(result.len(), 1, "Should flag indented [/] in Standard flavor");
1041
1042 let content = " - [-] Cancelled task\n";
1044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1045 let result = rule.check(&ctx).unwrap();
1046 assert_eq!(result.len(), 1, "Should flag 3-space indented [-] in Standard flavor");
1047
1048 let content = "- Parent item\n\t- [-] Cancelled task\n";
1050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1051 let result = rule.check(&ctx).unwrap();
1052 assert_eq!(
1053 result.len(),
1054 1,
1055 "Should flag tab-indented nested [-] in Standard flavor"
1056 );
1057
1058 let content = " - [x] Completed task\n";
1060 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061 let result = rule.check(&ctx).unwrap();
1062 assert!(
1063 result.is_empty(),
1064 "Should skip indented standard [x] checkbox in Standard flavor"
1065 );
1066
1067 let content = "- Parent\n\t- [ ] Pending task\n";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071 assert!(
1072 result.is_empty(),
1073 "Should skip tab-indented nested standard [ ] checkbox"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_skip_table_without_outer_pipes() {
1079 let rule = MD064NoMultipleConsecutiveSpaces::new();
1080
1081 let content = "Col1 | Col2 | Col3\n";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085 assert!(result.is_empty());
1086
1087 let content = "--------- | --------- | ---------\n";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090 let result = rule.check(&ctx).unwrap();
1091 assert!(result.is_empty());
1092
1093 let content = "Data1 | Data2 | Data3\n";
1095 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096 let result = rule.check(&ctx).unwrap();
1097 assert!(result.is_empty());
1098 }
1099
1100 #[test]
1101 fn test_flag_spaces_in_footnote_content() {
1102 let rule = MD064NoMultipleConsecutiveSpaces::new();
1103
1104 let content = "[^1]: Footnote with extra spaces in content.\n";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
1109 }
1110
1111 #[test]
1112 fn test_flag_spaces_in_reference_content() {
1113 let rule = MD064NoMultipleConsecutiveSpaces::new();
1114
1115 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
1120 }
1121
1122 #[test]
1125 fn test_sentence_double_space_disabled_by_default() {
1126 let rule = MD064NoMultipleConsecutiveSpaces::new();
1128 let content = "First sentence. Second sentence.";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let result = rule.check(&ctx).unwrap();
1131 assert_eq!(result.len(), 1, "Default should flag 2 spaces after period");
1132 }
1133
1134 #[test]
1135 fn test_sentence_double_space_enabled_allows_period() {
1136 let config = MD064Config {
1138 allow_sentence_double_space: true,
1139 };
1140 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1141
1142 let content = "First sentence. Second sentence.";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 let result = rule.check(&ctx).unwrap();
1145 assert!(result.is_empty(), "Should allow 2 spaces after period");
1146 }
1147
1148 #[test]
1149 fn test_sentence_double_space_enabled_allows_exclamation() {
1150 let config = MD064Config {
1151 allow_sentence_double_space: true,
1152 };
1153 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1154
1155 let content = "Wow! That was great.";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158 assert!(result.is_empty(), "Should allow 2 spaces after exclamation");
1159 }
1160
1161 #[test]
1162 fn test_sentence_double_space_enabled_allows_question() {
1163 let config = MD064Config {
1164 allow_sentence_double_space: true,
1165 };
1166 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1167
1168 let content = "Is this OK? Yes it is.";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171 assert!(result.is_empty(), "Should allow 2 spaces after question mark");
1172 }
1173
1174 #[test]
1175 fn test_sentence_double_space_flags_mid_sentence() {
1176 let config = MD064Config {
1178 allow_sentence_double_space: true,
1179 };
1180 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1181
1182 let content = "Word word in the middle.";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185 assert_eq!(result.len(), 1, "Should flag 2 spaces mid-sentence");
1186 }
1187
1188 #[test]
1189 fn test_sentence_double_space_flags_triple_after_period() {
1190 let config = MD064Config {
1192 allow_sentence_double_space: true,
1193 };
1194 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1195
1196 let content = "First sentence. Three spaces here.";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198 let result = rule.check(&ctx).unwrap();
1199 assert_eq!(result.len(), 1, "Should flag 3 spaces even after period");
1200 }
1201
1202 #[test]
1203 fn test_sentence_double_space_with_closing_quote() {
1204 let config = MD064Config {
1206 allow_sentence_double_space: true,
1207 };
1208 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1209
1210 let content = r#"He said "Hello." Then he left."#;
1211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212 let result = rule.check(&ctx).unwrap();
1213 assert!(result.is_empty(), "Should allow 2 spaces after .\" ");
1214
1215 let content = "She said 'Goodbye.' And she was gone.";
1217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1218 let result = rule.check(&ctx).unwrap();
1219 assert!(result.is_empty(), "Should allow 2 spaces after .' ");
1220 }
1221
1222 #[test]
1223 fn test_sentence_double_space_with_curly_quotes() {
1224 let config = MD064Config {
1225 allow_sentence_double_space: true,
1226 };
1227 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1228
1229 let content = format!(
1232 "He said {}Hello.{} Then left.",
1233 '\u{201C}', '\u{201D}' );
1236 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238 assert!(result.is_empty(), "Should allow 2 spaces after curly double quote");
1239
1240 let content = format!(
1242 "She said {}Hi.{} And left.",
1243 '\u{2018}', '\u{2019}' );
1246 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1247 let result = rule.check(&ctx).unwrap();
1248 assert!(result.is_empty(), "Should allow 2 spaces after curly single quote");
1249 }
1250
1251 #[test]
1252 fn test_sentence_double_space_with_closing_paren() {
1253 let config = MD064Config {
1254 allow_sentence_double_space: true,
1255 };
1256 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1257
1258 let content = "(See reference.) The next point is.";
1259 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1260 let result = rule.check(&ctx).unwrap();
1261 assert!(result.is_empty(), "Should allow 2 spaces after .) ");
1262 }
1263
1264 #[test]
1265 fn test_sentence_double_space_with_closing_bracket() {
1266 let config = MD064Config {
1267 allow_sentence_double_space: true,
1268 };
1269 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1270
1271 let content = "[Citation needed.] More text here.";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let result = rule.check(&ctx).unwrap();
1274 assert!(result.is_empty(), "Should allow 2 spaces after .] ");
1275 }
1276
1277 #[test]
1278 fn test_sentence_double_space_with_ellipsis() {
1279 let config = MD064Config {
1280 allow_sentence_double_space: true,
1281 };
1282 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1283
1284 let content = "He paused... Then continued.";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287 assert!(result.is_empty(), "Should allow 2 spaces after ellipsis");
1288 }
1289
1290 #[test]
1291 fn test_sentence_double_space_complex_ending() {
1292 let config = MD064Config {
1294 allow_sentence_double_space: true,
1295 };
1296 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1297
1298 let content = r#"(He said "Yes.") Then they agreed."#;
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301 assert!(result.is_empty(), "Should allow 2 spaces after .\") ");
1302 }
1303
1304 #[test]
1305 fn test_sentence_double_space_mixed_content() {
1306 let config = MD064Config {
1308 allow_sentence_double_space: true,
1309 };
1310 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1311
1312 let content = "Good sentence. Bad mid-sentence. Another good one! OK? Yes.";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315 assert_eq!(result.len(), 1, "Should only flag mid-sentence double space");
1316 assert!(
1317 result[0].column > 15 && result[0].column < 25,
1318 "Should flag the 'Bad mid' double space"
1319 );
1320 }
1321
1322 #[test]
1323 fn test_sentence_double_space_fix_collapses_to_two() {
1324 let config = MD064Config {
1326 allow_sentence_double_space: true,
1327 };
1328 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1329
1330 let content = "Sentence. Three spaces here.";
1331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1332 let fixed = rule.fix(&ctx).unwrap();
1333 assert_eq!(
1334 fixed, "Sentence. Three spaces here.",
1335 "Should collapse to 2 spaces after sentence"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_sentence_double_space_fix_collapses_mid_sentence_to_one() {
1341 let config = MD064Config {
1343 allow_sentence_double_space: true,
1344 };
1345 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1346
1347 let content = "Word word here.";
1348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349 let fixed = rule.fix(&ctx).unwrap();
1350 assert_eq!(fixed, "Word word here.", "Should collapse to 1 space mid-sentence");
1351 }
1352
1353 #[test]
1354 fn test_sentence_double_space_config_kebab_case() {
1355 let toml_str = r#"
1356 allow-sentence-double-space = true
1357 "#;
1358 let config: MD064Config = toml::from_str(toml_str).unwrap();
1359 assert!(config.allow_sentence_double_space);
1360 }
1361
1362 #[test]
1363 fn test_sentence_double_space_config_snake_case() {
1364 let toml_str = r#"
1365 allow_sentence_double_space = true
1366 "#;
1367 let config: MD064Config = toml::from_str(toml_str).unwrap();
1368 assert!(config.allow_sentence_double_space);
1369 }
1370
1371 #[test]
1372 fn test_sentence_double_space_at_line_start() {
1373 let config = MD064Config {
1375 allow_sentence_double_space: true,
1376 };
1377 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1378
1379 let content = ". Text after period at start.";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let _result = rule.check(&ctx).unwrap();
1384 }
1385
1386 #[test]
1387 fn test_sentence_double_space_guillemets() {
1388 let config = MD064Config {
1390 allow_sentence_double_space: true,
1391 };
1392 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1393
1394 let content = "Il a dit «Oui.» Puis il est parti.";
1395 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1396 let result = rule.check(&ctx).unwrap();
1397 assert!(result.is_empty(), "Should allow 2 spaces after .» (guillemet)");
1398 }
1399
1400 #[test]
1401 fn test_sentence_double_space_multiple_sentences() {
1402 let config = MD064Config {
1404 allow_sentence_double_space: true,
1405 };
1406 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1407
1408 let content = "First. Second. Third. Fourth.";
1409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let result = rule.check(&ctx).unwrap();
1411 assert!(result.is_empty(), "Should allow all sentence-ending double spaces");
1412 }
1413
1414 #[test]
1415 fn test_sentence_double_space_abbreviation_detection() {
1416 let config = MD064Config {
1418 allow_sentence_double_space: true,
1419 };
1420 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1421
1422 let content = "Dr. Smith arrived.";
1424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1425 let result = rule.check(&ctx).unwrap();
1426 assert_eq!(result.len(), 1, "Should flag Dr. as abbreviation, not sentence ending");
1427
1428 let content = "Prof. Williams teaches.";
1430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431 let result = rule.check(&ctx).unwrap();
1432 assert_eq!(result.len(), 1, "Should flag Prof. as abbreviation");
1433
1434 let content = "Use e.g. this example.";
1436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1437 let result = rule.check(&ctx).unwrap();
1438 assert_eq!(result.len(), 1, "Should flag e.g. as abbreviation");
1439
1440 let content = "Acme Inc. Next company.";
1443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444 let result = rule.check(&ctx).unwrap();
1445 assert!(
1446 result.is_empty(),
1447 "Inc. not in abbreviation list, treated as sentence end"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_sentence_double_space_default_config_has_correct_defaults() {
1453 let config = MD064Config::default();
1454 assert!(
1455 !config.allow_sentence_double_space,
1456 "Default allow_sentence_double_space should be false"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_sentence_double_space_from_config_integration() {
1462 use crate::config::Config;
1463 use std::collections::BTreeMap;
1464
1465 let mut config = Config::default();
1466 let mut values = BTreeMap::new();
1467 values.insert("allow-sentence-double-space".to_string(), toml::Value::Boolean(true));
1468 config.rules.insert(
1469 "MD064".to_string(),
1470 crate::config::RuleConfig { severity: None, values },
1471 );
1472
1473 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1474
1475 let content = "Sentence. Two spaces OK. But three is not.";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let result = rule.check(&ctx).unwrap();
1479 assert_eq!(result.len(), 1, "Should only flag the triple spaces");
1480 }
1481
1482 #[test]
1483 fn test_sentence_double_space_after_inline_code() {
1484 let config = MD064Config {
1486 allow_sentence_double_space: true,
1487 };
1488 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1489
1490 let content = "Hello from `backticks`. How's it going?";
1492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1493 let result = rule.check(&ctx).unwrap();
1494 assert!(
1495 result.is_empty(),
1496 "Should allow 2 spaces after inline code ending with period"
1497 );
1498
1499 let content = "Use `foo` and `bar`. Next sentence.";
1501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1502 let result = rule.check(&ctx).unwrap();
1503 assert!(result.is_empty(), "Should allow 2 spaces after code at end of sentence");
1504
1505 let content = "The `code` worked! Celebrate.";
1507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 let result = rule.check(&ctx).unwrap();
1509 assert!(result.is_empty(), "Should allow 2 spaces after code with exclamation");
1510
1511 let content = "Is `null` falsy? Yes.";
1513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1514 let result = rule.check(&ctx).unwrap();
1515 assert!(result.is_empty(), "Should allow 2 spaces after code with question mark");
1516
1517 let content = "The `code` is here.";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521 assert_eq!(result.len(), 1, "Should flag 2 spaces after code mid-sentence");
1522 }
1523
1524 #[test]
1525 fn test_sentence_double_space_code_with_closing_punctuation() {
1526 let config = MD064Config {
1528 allow_sentence_double_space: true,
1529 };
1530 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1531
1532 let content = "(see `example`). Next sentence.";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536 assert!(result.is_empty(), "Should allow 2 spaces after code in parentheses");
1537
1538 let content = "He said \"use `code`\". Then left.";
1540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1541 let result = rule.check(&ctx).unwrap();
1542 assert!(result.is_empty(), "Should allow 2 spaces after code in quotes");
1543 }
1544
1545 #[test]
1546 fn test_sentence_double_space_after_emphasis() {
1547 let config = MD064Config {
1549 allow_sentence_double_space: true,
1550 };
1551 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1552
1553 let content = "The word is *important*. Next sentence.";
1555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1556 let result = rule.check(&ctx).unwrap();
1557 assert!(result.is_empty(), "Should allow 2 spaces after emphasis");
1558
1559 let content = "The word is _important_. Next sentence.";
1561 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1562 let result = rule.check(&ctx).unwrap();
1563 assert!(result.is_empty(), "Should allow 2 spaces after underscore emphasis");
1564
1565 let content = "The word is **critical**. Next sentence.";
1567 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568 let result = rule.check(&ctx).unwrap();
1569 assert!(result.is_empty(), "Should allow 2 spaces after bold");
1570
1571 let content = "The word is __critical__. Next sentence.";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let result = rule.check(&ctx).unwrap();
1575 assert!(result.is_empty(), "Should allow 2 spaces after underscore bold");
1576 }
1577
1578 #[test]
1579 fn test_sentence_double_space_after_strikethrough() {
1580 let config = MD064Config {
1582 allow_sentence_double_space: true,
1583 };
1584 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1585
1586 let content = "This is ~~wrong~~. Next sentence.";
1587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588 let result = rule.check(&ctx).unwrap();
1589 assert!(result.is_empty(), "Should allow 2 spaces after strikethrough");
1590
1591 let content = "That was ~~bad~~! Learn from it.";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let result = rule.check(&ctx).unwrap();
1595 assert!(
1596 result.is_empty(),
1597 "Should allow 2 spaces after strikethrough with exclamation"
1598 );
1599 }
1600
1601 #[test]
1602 fn test_sentence_double_space_after_extended_markdown() {
1603 let config = MD064Config {
1605 allow_sentence_double_space: true,
1606 };
1607 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1608
1609 let content = "This is ==highlighted==. Next sentence.";
1611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613 assert!(result.is_empty(), "Should allow 2 spaces after highlight");
1614
1615 let content = "E equals mc^2^. Einstein said.";
1617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1618 let result = rule.check(&ctx).unwrap();
1619 assert!(result.is_empty(), "Should allow 2 spaces after superscript");
1620 }
1621
1622 #[test]
1623 fn test_inline_config_allow_sentence_double_space() {
1624 let rule = MD064NoMultipleConsecutiveSpaces::new(); let content = "`<svg>`. Fortunately";
1631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1632 let result = rule.check(&ctx).unwrap();
1633 assert_eq!(result.len(), 1, "Default config should flag double spaces");
1634
1635 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1638
1639`<svg>`. Fortunately"#;
1640 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1641 let base_config = crate::config::Config::default();
1642 let merged_config = base_config.merge_with_inline_config(&inline_config);
1643 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645 let result = effective_rule.check(&ctx).unwrap();
1646 assert!(
1647 result.is_empty(),
1648 "Inline config should allow double spaces after sentence"
1649 );
1650
1651 let content = r#"<!-- markdownlint-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1653
1654**scalable**. Pick"#;
1655 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1656 let merged_config = base_config.merge_with_inline_config(&inline_config);
1657 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let result = effective_rule.check(&ctx).unwrap();
1660 assert!(result.is_empty(), "Inline config with markdownlint prefix should work");
1661 }
1662
1663 #[test]
1664 fn test_inline_config_allow_sentence_double_space_issue_364() {
1665 let content = r#"<!-- rumdl-configure-file { "MD064": { "allow-sentence-double-space": true } } -->
1669
1670# Title
1671
1672what the font size is for the toplevel `<svg>`. Fortunately, librsvg
1673
1674And here is where I want to say, SVG documents are **scalable**. Pick
1675
1676That's right, no `width`, no `height`, no `viewBox`. There is no easy
1677
1678**SVG documents are scalable**. That's their whole reason for being!"#;
1679
1680 let inline_config = crate::inline_config::InlineConfig::from_content(content);
1682 let base_config = crate::config::Config::default();
1683 let merged_config = base_config.merge_with_inline_config(&inline_config);
1684 let effective_rule = MD064NoMultipleConsecutiveSpaces::from_config(&merged_config);
1685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1686 let result = effective_rule.check(&ctx).unwrap();
1687 assert!(
1688 result.is_empty(),
1689 "Issue #364: All sentence-ending double spaces should be allowed with inline config. Found {} warnings",
1690 result.len()
1691 );
1692 }
1693
1694 #[test]
1695 fn test_indented_reference_link_not_flagged() {
1696 let rule = MD064NoMultipleConsecutiveSpaces::default();
1699
1700 let content = " [label]: https://example.com";
1702 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704 assert!(
1705 result.is_empty(),
1706 "Indented reference link definitions should not be flagged, got: {:?}",
1707 result
1708 .iter()
1709 .map(|w| format!("col={}: {}", w.column, &w.message))
1710 .collect::<Vec<_>>()
1711 );
1712
1713 let content = "[label]: https://example.com";
1715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1716 let result = rule.check(&ctx).unwrap();
1717 assert!(result.is_empty(), "Reference link definitions should not be flagged");
1718 }
1719}