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