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