1use crate::filtered_lines::FilteredLinesExt;
29use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
30use crate::rule_config_serde::RuleConfig;
31use crate::utils::skip_context::is_table_line;
32use serde::{Deserialize, Serialize};
33use std::sync::Arc;
34
35use regex::Regex;
37use std::sync::LazyLock;
38
39static MULTIPLE_SPACES_REGEX: LazyLock<Regex> = LazyLock::new(|| {
40 Regex::new(r" {2,}").unwrap()
42});
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46#[serde(rename_all = "kebab-case")]
47pub struct MD064Config {
48 #[serde(default = "default_max_consecutive_spaces", alias = "max_consecutive_spaces")]
59 pub max_consecutive_spaces: usize,
60}
61
62fn default_max_consecutive_spaces() -> usize {
63 1
64}
65
66impl Default for MD064Config {
67 fn default() -> Self {
68 Self {
69 max_consecutive_spaces: default_max_consecutive_spaces(),
70 }
71 }
72}
73
74impl RuleConfig for MD064Config {
75 const RULE_NAME: &'static str = "MD064";
76}
77
78#[derive(Debug, Clone)]
79pub struct MD064NoMultipleConsecutiveSpaces {
80 config: MD064Config,
81}
82
83impl Default for MD064NoMultipleConsecutiveSpaces {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89impl MD064NoMultipleConsecutiveSpaces {
90 pub fn new() -> Self {
91 Self {
92 config: MD064Config::default(),
93 }
94 }
95
96 pub fn from_config_struct(config: MD064Config) -> Self {
97 Self { config }
98 }
99
100 fn is_in_code_span(&self, code_spans: &[crate::lint_context::CodeSpan], byte_pos: usize) -> bool {
102 code_spans
103 .iter()
104 .any(|span| byte_pos >= span.byte_offset && byte_pos < span.byte_end)
105 }
106
107 fn is_trailing_whitespace(&self, line: &str, match_end: usize) -> bool {
110 let remaining = &line[match_end..];
112 remaining.is_empty() || remaining.chars().all(|c| c == '\n' || c == '\r')
113 }
114
115 fn is_leading_indentation(&self, line: &str, match_start: usize) -> bool {
117 line[..match_start].chars().all(|c| c == ' ' || c == '\t')
119 }
120
121 fn is_after_list_marker(&self, line: &str, match_start: usize) -> bool {
123 let before = line[..match_start].trim_start();
124
125 if before == "*" || before == "-" || before == "+" {
127 return true;
128 }
129
130 if before.len() >= 2 {
133 let last_char = before.chars().last().unwrap();
134 if last_char == '.' || last_char == ')' {
135 let prefix = &before[..before.len() - 1];
136 if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) {
137 return true;
138 }
139 }
140 }
141
142 false
143 }
144
145 fn is_after_blockquote_marker(&self, line: &str, match_start: usize) -> bool {
148 let before = line[..match_start].trim_start();
149
150 if before.is_empty() {
152 return false;
153 }
154
155 let trimmed = before.trim_end();
157 if trimmed.chars().all(|c| c == '>') {
158 return true;
159 }
160
161 if trimmed.ends_with('>') {
163 let inner = trimmed.trim_end_matches('>').trim();
164 if inner.is_empty() || inner.chars().all(|c| c == '>') {
165 return true;
166 }
167 }
168
169 false
170 }
171
172 fn is_tab_replacement_pattern(&self, space_count: usize) -> bool {
176 space_count >= 4 && space_count.is_multiple_of(4)
177 }
178
179 fn is_reference_link_definition(&self, line: &str, match_start: usize) -> bool {
182 let trimmed = line.trim_start();
183
184 if trimmed.starts_with('[')
186 && let Some(bracket_end) = trimmed.find("]:")
187 {
188 let colon_pos = trimmed.len() - trimmed.trim_start().len() + bracket_end + 2;
189 if match_start >= colon_pos - 1 && match_start <= colon_pos + 1 {
191 return true;
192 }
193 }
194
195 false
196 }
197
198 fn is_after_footnote_marker(&self, line: &str, match_start: usize) -> bool {
201 let trimmed = line.trim_start();
202
203 if trimmed.starts_with("[^")
205 && let Some(bracket_end) = trimmed.find("]:")
206 {
207 let leading_spaces = line.len() - trimmed.len();
208 let colon_pos = leading_spaces + bracket_end + 2;
209 if match_start >= colon_pos.saturating_sub(1) && match_start <= colon_pos + 1 {
211 return true;
212 }
213 }
214
215 false
216 }
217
218 fn is_after_definition_marker(&self, line: &str, match_start: usize) -> bool {
221 let before = line[..match_start].trim_start();
222
223 before == ":"
225 }
226
227 fn is_after_task_checkbox(&self, line: &str, match_start: usize) -> bool {
230 let before = line[..match_start].trim_start();
231
232 if before.len() >= 4 {
235 let patterns = [
236 "- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]", "+ [ ]", "+ [x]", "+ [X]",
237 ];
238 for pattern in patterns {
239 if before == pattern {
240 return true;
241 }
242 }
243 }
244
245 false
246 }
247
248 fn is_table_without_outer_pipes(&self, line: &str) -> bool {
251 let trimmed = line.trim();
252
253 if !trimmed.contains('|') {
255 return false;
256 }
257
258 if trimmed.starts_with('|') || trimmed.ends_with('|') {
260 return false;
261 }
262
263 let parts: Vec<&str> = trimmed.split('|').collect();
267 if parts.len() >= 2 {
268 let first_has_content = !parts.first().unwrap_or(&"").trim().is_empty();
271 let last_has_content = !parts.last().unwrap_or(&"").trim().is_empty();
272 if first_has_content || last_has_content {
273 return true;
274 }
275 }
276
277 false
278 }
279}
280
281impl Rule for MD064NoMultipleConsecutiveSpaces {
282 fn name(&self) -> &'static str {
283 "MD064"
284 }
285
286 fn description(&self) -> &'static str {
287 "Multiple consecutive spaces"
288 }
289
290 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
291 let content = ctx.content;
292
293 if !content.contains(" ") {
295 return Ok(vec![]);
296 }
297
298 let mut warnings = Vec::new();
299 let code_spans: Arc<Vec<crate::lint_context::CodeSpan>> = ctx.code_spans();
300 let line_index = &ctx.line_index;
301
302 for line in ctx
304 .filtered_lines()
305 .skip_front_matter()
306 .skip_code_blocks()
307 .skip_html_blocks()
308 .skip_html_comments()
309 .skip_mkdocstrings()
310 .skip_esm_blocks()
311 {
312 if !line.content.contains(" ") {
314 continue;
315 }
316
317 if is_table_line(line.content) {
319 continue;
320 }
321
322 if self.is_table_without_outer_pipes(line.content) {
324 continue;
325 }
326
327 let line_start_byte = line_index.get_line_start_byte(line.line_num).unwrap_or(0);
328
329 for mat in MULTIPLE_SPACES_REGEX.find_iter(line.content) {
331 let match_start = mat.start();
332 let match_end = mat.end();
333 let space_count = match_end - match_start;
334
335 if space_count <= self.config.max_consecutive_spaces {
337 continue;
338 }
339
340 if self.is_leading_indentation(line.content, match_start) {
342 continue;
343 }
344
345 if self.is_trailing_whitespace(line.content, match_end) {
347 continue;
348 }
349
350 if self.is_tab_replacement_pattern(space_count) {
353 continue;
354 }
355
356 if self.is_after_list_marker(line.content, match_start) {
358 continue;
359 }
360
361 if self.is_after_blockquote_marker(line.content, match_start) {
363 continue;
364 }
365
366 if self.is_after_footnote_marker(line.content, match_start) {
368 continue;
369 }
370
371 if self.is_reference_link_definition(line.content, match_start) {
373 continue;
374 }
375
376 if self.is_after_definition_marker(line.content, match_start) {
378 continue;
379 }
380
381 if self.is_after_task_checkbox(line.content, match_start) {
383 continue;
384 }
385
386 let abs_byte_start = line_start_byte + match_start;
388
389 if self.is_in_code_span(&code_spans, abs_byte_start) {
391 continue;
392 }
393
394 let abs_byte_end = line_start_byte + match_end;
396
397 warnings.push(LintWarning {
398 rule_name: Some(self.name().to_string()),
399 message: format!("Multiple consecutive spaces ({space_count}) found"),
400 line: line.line_num,
401 column: match_start + 1, end_line: line.line_num,
403 end_column: match_end + 1, severity: Severity::Warning,
405 fix: Some(Fix {
406 range: abs_byte_start..abs_byte_end,
407 replacement: " ".to_string(), }),
409 });
410 }
411 }
412
413 Ok(warnings)
414 }
415
416 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
417 let content = ctx.content;
418
419 if !content.contains(" ") {
421 return Ok(content.to_string());
422 }
423
424 let warnings = self.check(ctx)?;
426 if warnings.is_empty() {
427 return Ok(content.to_string());
428 }
429
430 let mut fixes: Vec<(std::ops::Range<usize>, String)> = warnings
432 .into_iter()
433 .filter_map(|w| w.fix.map(|f| (f.range, f.replacement)))
434 .collect();
435
436 fixes.sort_by_key(|(range, _)| std::cmp::Reverse(range.start));
437
438 let mut result = content.to_string();
440 for (range, replacement) in fixes {
441 if range.start < result.len() && range.end <= result.len() {
442 result.replace_range(range, &replacement);
443 }
444 }
445
446 Ok(result)
447 }
448
449 fn category(&self) -> RuleCategory {
451 RuleCategory::Whitespace
452 }
453
454 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
456 ctx.content.is_empty() || !ctx.content.contains(" ")
457 }
458
459 fn as_any(&self) -> &dyn std::any::Any {
460 self
461 }
462
463 fn default_config_section(&self) -> Option<(String, toml::Value)> {
464 let default_config = MD064Config::default();
465 let json_value = serde_json::to_value(&default_config).ok()?;
466 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
467
468 if let toml::Value::Table(table) = toml_value {
469 if !table.is_empty() {
470 Some((MD064Config::RULE_NAME.to_string(), toml::Value::Table(table)))
471 } else {
472 None
473 }
474 } else {
475 None
476 }
477 }
478
479 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
480 where
481 Self: Sized,
482 {
483 let rule_config = crate::rule_config_serde::load_rule_config::<MD064Config>(config);
484 Box::new(MD064NoMultipleConsecutiveSpaces::from_config_struct(rule_config))
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::lint_context::LintContext;
492
493 #[test]
494 fn test_basic_multiple_spaces() {
495 let rule = MD064NoMultipleConsecutiveSpaces::new();
496
497 let content = "This is a sentence with extra spaces.";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert_eq!(result.len(), 1);
502 assert_eq!(result[0].line, 1);
503 assert_eq!(result[0].column, 8); }
505
506 #[test]
507 fn test_no_issues_single_spaces() {
508 let rule = MD064NoMultipleConsecutiveSpaces::new();
509
510 let content = "This is a normal sentence with single spaces.";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513 let result = rule.check(&ctx).unwrap();
514 assert!(result.is_empty());
515 }
516
517 #[test]
518 fn test_skip_inline_code() {
519 let rule = MD064NoMultipleConsecutiveSpaces::new();
520
521 let content = "Use `code with spaces` for formatting.";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert!(result.is_empty());
526 }
527
528 #[test]
529 fn test_skip_code_blocks() {
530 let rule = MD064NoMultipleConsecutiveSpaces::new();
531
532 let content = "# Heading\n\n```\ncode with spaces\n```\n\nNormal text.";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let result = rule.check(&ctx).unwrap();
536 assert!(result.is_empty());
537 }
538
539 #[test]
540 fn test_skip_leading_indentation() {
541 let rule = MD064NoMultipleConsecutiveSpaces::new();
542
543 let content = " This is indented text.";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert!(result.is_empty());
548 }
549
550 #[test]
551 fn test_skip_trailing_spaces() {
552 let rule = MD064NoMultipleConsecutiveSpaces::new();
553
554 let content = "Line with trailing spaces \nNext line.";
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_all_trailing_spaces() {
563 let rule = MD064NoMultipleConsecutiveSpaces::new();
564
565 let content = "Two spaces \nThree spaces \nFour spaces \n";
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_front_matter() {
574 let rule = MD064NoMultipleConsecutiveSpaces::new();
575
576 let content = "---\ntitle: Test Title\n---\n\nContent here.";
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_html_comments() {
585 let rule = MD064NoMultipleConsecutiveSpaces::new();
586
587 let content = "<!-- comment with spaces -->\n\nContent here.";
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_multiple_issues_one_line() {
596 let rule = MD064NoMultipleConsecutiveSpaces::new();
597
598 let content = "This has multiple issues.";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 3, "Should flag all 3 occurrences");
603 }
604
605 #[test]
606 fn test_fix_collapses_spaces() {
607 let rule = MD064NoMultipleConsecutiveSpaces::new();
608
609 let content = "This is a sentence with extra spaces.";
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 let fixed = rule.fix(&ctx).unwrap();
612 assert_eq!(fixed, "This is a sentence with extra spaces.");
613 }
614
615 #[test]
616 fn test_fix_preserves_inline_code() {
617 let rule = MD064NoMultipleConsecutiveSpaces::new();
618
619 let content = "Text here `code inside` and more.";
620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
621 let fixed = rule.fix(&ctx).unwrap();
622 assert_eq!(fixed, "Text here `code inside` and more.");
623 }
624
625 #[test]
626 fn test_fix_preserves_trailing_spaces() {
627 let rule = MD064NoMultipleConsecutiveSpaces::new();
628
629 let content = "Line with extra and trailing \nNext line.";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
632 let fixed = rule.fix(&ctx).unwrap();
633 assert_eq!(fixed, "Line with extra and trailing \nNext line.");
635 }
636
637 #[test]
638 fn test_list_items_with_extra_spaces() {
639 let rule = MD064NoMultipleConsecutiveSpaces::new();
640
641 let content = "- Item one\n- Item two\n";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644 assert_eq!(result.len(), 2, "Should flag spaces in list items");
645 }
646
647 #[test]
648 fn test_blockquote_with_extra_spaces_in_content() {
649 let rule = MD064NoMultipleConsecutiveSpaces::new();
650
651 let content = "> Quote with extra spaces\n";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655 assert_eq!(result.len(), 2, "Should flag spaces in blockquote content");
656 }
657
658 #[test]
659 fn test_skip_blockquote_marker_spaces() {
660 let rule = MD064NoMultipleConsecutiveSpaces::new();
661
662 let content = "> Text with extra space after marker\n";
664 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert!(result.is_empty());
667
668 let content = "> Text with three spaces after marker\n";
670 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert!(result.is_empty());
673
674 let content = ">> Nested blockquote\n";
676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678 assert!(result.is_empty());
679 }
680
681 #[test]
682 fn test_mixed_content() {
683 let rule = MD064NoMultipleConsecutiveSpaces::new();
684
685 let content = r#"# Heading
686
687This has extra spaces.
688
689```
690code here is fine
691```
692
693- List item
694
695> Quote text
696
697Normal paragraph.
698"#;
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert_eq!(result.len(), 3, "Should flag only content outside code blocks");
703 }
704
705 #[test]
706 fn test_multibyte_utf8() {
707 let rule = MD064NoMultipleConsecutiveSpaces::new();
708
709 let content = "日本語 テスト 文字列";
711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx);
713 assert!(result.is_ok(), "Should handle multi-byte UTF-8 characters");
714
715 let warnings = result.unwrap();
716 assert_eq!(warnings.len(), 2, "Should find 2 occurrences of multiple spaces");
717 }
718
719 #[test]
720 fn test_table_rows_skipped() {
721 let rule = MD064NoMultipleConsecutiveSpaces::new();
722
723 let content = "| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty());
729 }
730
731 #[test]
732 fn test_link_text_with_extra_spaces() {
733 let rule = MD064NoMultipleConsecutiveSpaces::new();
734
735 let content = "[Link text](https://example.com)";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739 assert_eq!(result.len(), 1, "Should flag extra spaces in link text");
740 }
741
742 #[test]
743 fn test_image_alt_with_extra_spaces() {
744 let rule = MD064NoMultipleConsecutiveSpaces::new();
745
746 let content = "";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert_eq!(result.len(), 1, "Should flag extra spaces in image alt text");
751 }
752
753 #[test]
754 fn test_skip_list_marker_spaces() {
755 let rule = MD064NoMultipleConsecutiveSpaces::new();
756
757 let content = "* Item with extra spaces after marker\n- Another item\n+ Third item\n";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert!(result.is_empty());
762
763 let content = "1. Item one\n2. Item two\n10. Item ten\n";
765 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
766 let result = rule.check(&ctx).unwrap();
767 assert!(result.is_empty());
768
769 let content = " * Indented item\n 1. Nested numbered item\n";
771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
772 let result = rule.check(&ctx).unwrap();
773 assert!(result.is_empty());
774 }
775
776 #[test]
777 fn test_flag_spaces_in_list_content() {
778 let rule = MD064NoMultipleConsecutiveSpaces::new();
779
780 let content = "* Item with extra spaces in content\n";
782 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
783 let result = rule.check(&ctx).unwrap();
784 assert_eq!(result.len(), 1, "Should flag extra spaces in list content");
785 }
786
787 #[test]
788 fn test_skip_reference_link_definition_spaces() {
789 let rule = MD064NoMultipleConsecutiveSpaces::new();
790
791 let content = "[ref]: https://example.com\n";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795 assert!(result.is_empty());
796
797 let content = "[reference-link]: https://example.com \"Title\"\n";
799 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert!(result.is_empty());
802 }
803
804 #[test]
805 fn test_skip_footnote_marker_spaces() {
806 let rule = MD064NoMultipleConsecutiveSpaces::new();
807
808 let content = "[^1]: Footnote with extra space\n";
810 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert!(result.is_empty());
813
814 let content = "[^footnote-label]: This is the footnote text.\n";
816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
817 let result = rule.check(&ctx).unwrap();
818 assert!(result.is_empty());
819 }
820
821 #[test]
822 fn test_skip_definition_list_marker_spaces() {
823 let rule = MD064NoMultipleConsecutiveSpaces::new();
824
825 let content = "Term\n: Definition with extra spaces\n";
827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert!(result.is_empty());
830
831 let content = ": Another definition\n";
833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834 let result = rule.check(&ctx).unwrap();
835 assert!(result.is_empty());
836 }
837
838 #[test]
839 fn test_skip_task_list_checkbox_spaces() {
840 let rule = MD064NoMultipleConsecutiveSpaces::new();
841
842 let content = "- [ ] Task with extra space\n";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846 assert!(result.is_empty());
847
848 let content = "- [x] Completed task\n";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851 let result = rule.check(&ctx).unwrap();
852 assert!(result.is_empty());
853
854 let content = "* [ ] Task with asterisk marker\n";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(result.is_empty());
859 }
860
861 #[test]
862 fn test_skip_table_without_outer_pipes() {
863 let rule = MD064NoMultipleConsecutiveSpaces::new();
864
865 let content = "Col1 | Col2 | Col3\n";
867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(result.is_empty());
870
871 let content = "--------- | --------- | ---------\n";
873 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let result = rule.check(&ctx).unwrap();
875 assert!(result.is_empty());
876
877 let content = "Data1 | Data2 | Data3\n";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881 assert!(result.is_empty());
882 }
883
884 #[test]
885 fn test_flag_spaces_in_footnote_content() {
886 let rule = MD064NoMultipleConsecutiveSpaces::new();
887
888 let content = "[^1]: Footnote with extra spaces in content.\n";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892 assert_eq!(result.len(), 1, "Should flag extra spaces in footnote content");
893 }
894
895 #[test]
896 fn test_flag_spaces_in_reference_content() {
897 let rule = MD064NoMultipleConsecutiveSpaces::new();
898
899 let content = "[ref]: https://example.com \"Title with extra spaces\"\n";
901 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let result = rule.check(&ctx).unwrap();
903 assert_eq!(result.len(), 1, "Should flag extra spaces in reference link title");
904 }
905
906 #[test]
909 fn test_default_config() {
910 let config = MD064Config::default();
911 assert_eq!(config.max_consecutive_spaces, 1);
912 }
913
914 #[test]
915 fn test_config_kebab_case() {
916 let toml_str = r#"
917 max-consecutive-spaces = 2
918 "#;
919 let config: MD064Config = toml::from_str(toml_str).unwrap();
920 assert_eq!(config.max_consecutive_spaces, 2);
921 }
922
923 #[test]
924 fn test_config_snake_case_backwards_compatibility() {
925 let toml_str = r#"
926 max_consecutive_spaces = 2
927 "#;
928 let config: MD064Config = toml::from_str(toml_str).unwrap();
929 assert_eq!(config.max_consecutive_spaces, 2);
930 }
931
932 #[test]
933 fn test_max_consecutive_spaces_two_allows_double_spaces() {
934 let config = MD064Config {
936 max_consecutive_spaces: 2,
937 };
938 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
939
940 let content = "This is fine. Two spaces between sentences.";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.check(&ctx).unwrap();
943 assert!(result.is_empty(), "Double spaces should be allowed with max=2");
944 }
945
946 #[test]
947 fn test_max_consecutive_spaces_two_flags_triple_spaces() {
948 let config = MD064Config {
950 max_consecutive_spaces: 2,
951 };
952 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
953
954 let content = "This has three spaces here.";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957 assert_eq!(result.len(), 1, "Triple spaces should be flagged with max=2");
958 }
959
960 #[test]
961 fn test_max_consecutive_spaces_mixed() {
962 let config = MD064Config {
964 max_consecutive_spaces: 2,
965 };
966 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
967
968 let content = "Two spaces. OK here. Three spaces flagged.";
969 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 let result = rule.check(&ctx).unwrap();
971 assert_eq!(result.len(), 1, "Only triple spaces should be flagged");
972 assert_eq!(result[0].column, 22, "Should flag the triple space at column 22");
973 }
974
975 #[test]
976 fn test_fix_respects_max_consecutive_spaces() {
977 let config = MD064Config {
979 max_consecutive_spaces: 2,
980 };
981 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
982
983 let content = "Has three spaces here.";
984 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let fixed = rule.fix(&ctx).unwrap();
986 assert!(!fixed.contains(" "), "Triple spaces should be fixed");
989 }
990
991 #[test]
992 fn test_default_config_section_returns_schema() {
993 let rule = MD064NoMultipleConsecutiveSpaces::new();
994 let config_section = rule.default_config_section();
995
996 assert!(config_section.is_some(), "Should return config section");
997
998 let (rule_name, toml_value) = config_section.unwrap();
999 assert_eq!(rule_name, "MD064");
1000
1001 if let toml::Value::Table(table) = toml_value {
1003 assert!(
1004 table.contains_key("max-consecutive-spaces"),
1005 "Should contain max-consecutive-spaces key"
1006 );
1007 } else {
1008 panic!("Expected a toml table");
1009 }
1010 }
1011
1012 #[test]
1013 fn test_max_consecutive_spaces_zero_flags_all_double_spaces() {
1014 let config = MD064Config {
1016 max_consecutive_spaces: 0,
1017 };
1018 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1019
1020 let content = "Double spaces here.";
1021 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023 assert_eq!(result.len(), 1, "max=0 should flag double spaces");
1024 }
1025
1026 #[test]
1027 fn test_max_consecutive_spaces_three_allows_triple() {
1028 let config = MD064Config {
1030 max_consecutive_spaces: 3,
1031 };
1032 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1033
1034 let content = "Two spaces OK. Three spaces OK. Five spaces flagged.";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038 assert_eq!(result.len(), 1, "Only 5 spaces should be flagged with max=3");
1039 }
1040
1041 #[test]
1042 fn test_exact_boundary_at_threshold() {
1043 let config = MD064Config {
1046 max_consecutive_spaces: 3,
1047 };
1048 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1049
1050 let content = "Three spaces exactly.";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054 assert!(result.is_empty(), "Exactly 3 spaces should be allowed with max=3");
1055
1056 let content = "Five spaces here.";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060 assert_eq!(result.len(), 1, "5 spaces should be flagged with max=3");
1061 }
1062
1063 #[test]
1064 fn test_config_with_skip_contexts() {
1065 let config = MD064Config {
1067 max_consecutive_spaces: 2,
1068 };
1069 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1070
1071 let content = "Text `code with spaces` more text.";
1073 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074 let result = rule.check(&ctx).unwrap();
1075 assert_eq!(result.len(), 1, "Only content outside code span flagged");
1077 assert!(
1078 result[0].column > 25,
1079 "Warning should be for 'more text' not code span"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_from_config_integration() {
1085 use crate::config::Config;
1087 use std::collections::BTreeMap;
1088
1089 let mut config = Config::default();
1090 let mut values = BTreeMap::new();
1091 values.insert("max-consecutive-spaces".to_string(), toml::Value::Integer(2));
1092 config.rules.insert(
1093 "MD064".to_string(),
1094 crate::config::RuleConfig { severity: None, values },
1095 );
1096
1097 let rule = MD064NoMultipleConsecutiveSpaces::from_config(&config);
1098
1099 let content = "Two spaces OK. Three spaces flagged.";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let result = rule.check(&ctx).unwrap();
1103 assert_eq!(result.len(), 1, "Should use loaded config value of 2");
1104 }
1105
1106 #[test]
1107 fn test_very_large_threshold_effectively_disables_rule() {
1108 let config = MD064Config {
1110 max_consecutive_spaces: usize::MAX,
1111 };
1112 let rule = MD064NoMultipleConsecutiveSpaces::from_config_struct(config);
1113
1114 let content = "Many spaces here.";
1116 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117 let result = rule.check(&ctx).unwrap();
1118 assert!(
1119 result.is_empty(),
1120 "Very large threshold should allow any number of spaces"
1121 );
1122 }
1123}