1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::range_utils::{LineIndex, calculate_line_range};
3use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
4use regex::Regex;
5use std::sync::LazyLock;
6static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
8
9fn is_thematic_break(line: &str) -> bool {
12 let leading_spaces = line.len() - line.trim_start_matches(' ').len();
13 if leading_spaces > 3 || line.starts_with('\t') {
14 return false;
15 }
16
17 let trimmed = line.trim();
18 if trimmed.len() < 3 {
19 return false;
20 }
21
22 let chars: Vec<char> = trimmed.chars().collect();
23 let first_non_space = chars.iter().find(|&&c| c != ' ');
24
25 if let Some(&marker) = first_non_space {
26 if marker != '-' && marker != '*' && marker != '_' {
27 return false;
28 }
29 let marker_count = chars.iter().filter(|&&c| c == marker).count();
30 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
31 marker_count >= 3 && other_count == 0
32 } else {
33 false
34 }
35}
36
37#[derive(Debug, Clone, Default)]
107pub struct MD032BlanksAroundLists;
108
109impl MD032BlanksAroundLists {
110 fn should_require_blank_line_before(
112 ctx: &crate::lint_context::LintContext,
113 prev_line_num: usize,
114 current_line_num: usize,
115 ) -> bool {
116 if ctx
118 .line_info(prev_line_num)
119 .is_some_and(|info| info.in_code_block || info.in_front_matter)
120 {
121 return true;
122 }
123
124 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
126 return false;
127 }
128
129 true
131 }
132
133 fn is_nested_list(
135 ctx: &crate::lint_context::LintContext,
136 prev_line_num: usize, current_line_num: usize, ) -> bool {
139 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
141 let current_line = &ctx.lines[current_line_num - 1];
142 if current_line.indent >= 2 {
143 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
145 let prev_line = &ctx.lines[prev_line_num - 1];
146 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
148 return true;
149 }
150 }
151 }
152 }
153 false
154 }
155
156 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
158 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
159
160 for block in &ctx.list_blocks {
161 let mut segments: Vec<(usize, usize)> = Vec::new();
167 let mut current_start = block.start_line;
168 let mut prev_item_line = 0;
169
170 for &item_line in &block.item_lines {
171 if prev_item_line > 0 {
172 let mut has_standalone_code_fence = false;
175
176 let min_indent_for_content = if block.is_ordered {
178 3 } else {
182 2 };
185
186 for check_line in (prev_item_line + 1)..item_line {
187 if check_line - 1 < ctx.lines.len() {
188 let line = &ctx.lines[check_line - 1];
189 let line_content = line.content(ctx.content);
190 if line.in_code_block
191 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
192 {
193 if line.indent < min_indent_for_content {
196 has_standalone_code_fence = true;
197 break;
198 }
199 }
200 }
201 }
202
203 if has_standalone_code_fence {
204 segments.push((current_start, prev_item_line));
206 current_start = item_line;
207 }
208 }
209 prev_item_line = item_line;
210 }
211
212 if prev_item_line > 0 {
215 segments.push((current_start, prev_item_line));
216 }
217
218 let has_code_fence_splits = segments.len() > 1 && {
220 let mut found_fence = false;
222 for i in 0..segments.len() - 1 {
223 let seg_end = segments[i].1;
224 let next_start = segments[i + 1].0;
225 for check_line in (seg_end + 1)..next_start {
227 if check_line - 1 < ctx.lines.len() {
228 let line = &ctx.lines[check_line - 1];
229 let line_content = line.content(ctx.content);
230 if line.in_code_block
231 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
232 {
233 found_fence = true;
234 break;
235 }
236 }
237 }
238 if found_fence {
239 break;
240 }
241 }
242 found_fence
243 };
244
245 for (start, end) in segments.iter() {
247 let mut actual_end = *end;
249
250 if !has_code_fence_splits && *end < block.end_line {
253 for check_line in (*end + 1)..=block.end_line {
254 if check_line - 1 < ctx.lines.len() {
255 let line = &ctx.lines[check_line - 1];
256 let line_content = line.content(ctx.content);
257 if block.item_lines.contains(&check_line) || line.heading.is_some() {
259 break;
260 }
261 if line.in_code_block {
263 break;
264 }
265 if line.indent >= 2 {
267 actual_end = check_line;
268 }
269 else if !line.is_blank
273 && line.heading.is_none()
274 && !block.item_lines.contains(&check_line)
275 && !is_thematic_break(line_content)
276 {
277 actual_end = check_line;
280 } else if !line.is_blank {
281 break;
283 }
284 }
285 }
286 }
287
288 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
289 }
290 }
291
292 blocks
293 }
294
295 fn perform_checks(
296 &self,
297 ctx: &crate::lint_context::LintContext,
298 lines: &[&str],
299 list_blocks: &[(usize, usize, String)],
300 line_index: &LineIndex,
301 ) -> LintResult {
302 let mut warnings = Vec::new();
303 let num_lines = lines.len();
304
305 for (line_idx, line) in lines.iter().enumerate() {
308 let line_num = line_idx + 1;
309
310 let is_in_list = list_blocks
312 .iter()
313 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
314 if is_in_list {
315 continue;
316 }
317
318 if ctx
320 .line_info(line_num)
321 .is_some_and(|info| info.in_code_block || info.in_front_matter)
322 {
323 continue;
324 }
325
326 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
328 if line_idx > 0 {
330 let prev_line = lines[line_idx - 1];
331 let prev_is_blank = is_blank_in_context(prev_line);
332 let prev_excluded = ctx
333 .line_info(line_idx)
334 .is_some_and(|info| info.in_code_block || info.in_front_matter);
335
336 if !prev_is_blank && !prev_excluded {
337 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
339
340 warnings.push(LintWarning {
341 line: start_line,
342 column: start_col,
343 end_line,
344 end_column: end_col,
345 severity: Severity::Error,
346 rule_name: Some(self.name().to_string()),
347 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
348 fix: Some(Fix {
349 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
350 replacement: "\n".to_string(),
351 }),
352 });
353 }
354 }
355 }
356 }
357
358 for &(start_line, end_line, ref prefix) in list_blocks {
359 if start_line > 1 {
360 let prev_line_actual_idx_0 = start_line - 2;
361 let prev_line_actual_idx_1 = start_line - 1;
362 let prev_line_str = lines[prev_line_actual_idx_0];
363 let is_prev_excluded = ctx
364 .line_info(prev_line_actual_idx_1)
365 .is_some_and(|info| info.in_code_block || info.in_front_matter);
366 let prev_prefix = BLOCKQUOTE_PREFIX_RE
367 .find(prev_line_str)
368 .map_or(String::new(), |m| m.as_str().to_string());
369 let prev_is_blank = is_blank_in_context(prev_line_str);
370 let prefixes_match = prev_prefix.trim() == prefix.trim();
371
372 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
375 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
376 let (start_line, start_col, end_line, end_col) =
378 calculate_line_range(start_line, lines[start_line - 1]);
379
380 warnings.push(LintWarning {
381 line: start_line,
382 column: start_col,
383 end_line,
384 end_column: end_col,
385 severity: Severity::Error,
386 rule_name: Some(self.name().to_string()),
387 message: "List should be preceded by blank line".to_string(),
388 fix: Some(Fix {
389 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
390 replacement: format!("{prefix}\n"),
391 }),
392 });
393 }
394 }
395
396 if end_line < num_lines {
397 let next_line_idx_0 = end_line;
398 let next_line_idx_1 = end_line + 1;
399 let next_line_str = lines[next_line_idx_0];
400 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
403 || (next_line_idx_0 < ctx.lines.len()
404 && ctx.lines[next_line_idx_0].in_code_block
405 && ctx.lines[next_line_idx_0].indent >= 2);
406 let next_prefix = BLOCKQUOTE_PREFIX_RE
407 .find(next_line_str)
408 .map_or(String::new(), |m| m.as_str().to_string());
409 let next_is_blank = is_blank_in_context(next_line_str);
410 let prefixes_match = next_prefix.trim() == prefix.trim();
411
412 if !is_next_excluded && !next_is_blank && prefixes_match {
414 let (start_line_last, start_col_last, end_line_last, end_col_last) =
416 calculate_line_range(end_line, lines[end_line - 1]);
417
418 warnings.push(LintWarning {
419 line: start_line_last,
420 column: start_col_last,
421 end_line: end_line_last,
422 end_column: end_col_last,
423 severity: Severity::Error,
424 rule_name: Some(self.name().to_string()),
425 message: "List should be followed by blank line".to_string(),
426 fix: Some(Fix {
427 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
428 replacement: format!("{prefix}\n"),
429 }),
430 });
431 }
432 }
433 }
434 Ok(warnings)
435 }
436}
437
438impl Rule for MD032BlanksAroundLists {
439 fn name(&self) -> &'static str {
440 "MD032"
441 }
442
443 fn description(&self) -> &'static str {
444 "Lists should be surrounded by blank lines"
445 }
446
447 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
448 let content = ctx.content;
449 let lines: Vec<&str> = content.lines().collect();
450 let line_index = &ctx.line_index;
451
452 if lines.is_empty() {
454 return Ok(Vec::new());
455 }
456
457 let list_blocks = self.convert_list_blocks(ctx);
458
459 if list_blocks.is_empty() {
460 return Ok(Vec::new());
461 }
462
463 self.perform_checks(ctx, &lines, &list_blocks, line_index)
464 }
465
466 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
467 self.fix_with_structure_impl(ctx)
468 }
469
470 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
471 if ctx.content.is_empty() || !ctx.likely_has_lists() {
473 return true;
474 }
475 ctx.list_blocks.is_empty()
477 }
478
479 fn category(&self) -> RuleCategory {
480 RuleCategory::List
481 }
482
483 fn as_any(&self) -> &dyn std::any::Any {
484 self
485 }
486
487 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
488 where
489 Self: Sized,
490 {
491 Box::new(MD032BlanksAroundLists)
492 }
493}
494
495impl MD032BlanksAroundLists {
496 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
498 let lines: Vec<&str> = ctx.content.lines().collect();
499 let num_lines = lines.len();
500 if num_lines == 0 {
501 return Ok(String::new());
502 }
503
504 let list_blocks = self.convert_list_blocks(ctx);
505 if list_blocks.is_empty() {
506 return Ok(ctx.content.to_string());
507 }
508
509 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
510
511 for &(start_line, end_line, ref prefix) in &list_blocks {
513 if start_line > 1 {
515 let prev_line_actual_idx_0 = start_line - 2;
516 let prev_line_actual_idx_1 = start_line - 1;
517 let is_prev_excluded = ctx
518 .line_info(prev_line_actual_idx_1)
519 .is_some_and(|info| info.in_code_block || info.in_front_matter);
520 let prev_prefix = BLOCKQUOTE_PREFIX_RE
521 .find(lines[prev_line_actual_idx_0])
522 .map_or(String::new(), |m| m.as_str().to_string());
523
524 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
525 if !is_prev_excluded
526 && !is_blank_in_context(lines[prev_line_actual_idx_0])
527 && prev_prefix == *prefix
528 && should_require
529 {
530 insertions.insert(start_line, prefix.clone());
531 }
532 }
533
534 if end_line < num_lines {
536 let after_block_line_idx_0 = end_line;
537 let after_block_line_idx_1 = end_line + 1;
538 let line_after_block_content_str = lines[after_block_line_idx_0];
539 let is_line_after_excluded = ctx
542 .line_info(after_block_line_idx_1)
543 .is_some_and(|info| info.in_code_block || info.in_front_matter)
544 || (after_block_line_idx_0 < ctx.lines.len()
545 && ctx.lines[after_block_line_idx_0].in_code_block
546 && ctx.lines[after_block_line_idx_0].indent >= 2
547 && (ctx.lines[after_block_line_idx_0]
548 .content(ctx.content)
549 .trim()
550 .starts_with("```")
551 || ctx.lines[after_block_line_idx_0]
552 .content(ctx.content)
553 .trim()
554 .starts_with("~~~")));
555 let after_prefix = BLOCKQUOTE_PREFIX_RE
556 .find(line_after_block_content_str)
557 .map_or(String::new(), |m| m.as_str().to_string());
558
559 if !is_line_after_excluded
560 && !is_blank_in_context(line_after_block_content_str)
561 && after_prefix == *prefix
562 {
563 insertions.insert(after_block_line_idx_1, prefix.clone());
564 }
565 }
566 }
567
568 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
570 for (i, line) in lines.iter().enumerate() {
571 let current_line_num = i + 1;
572 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
573 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
574 {
575 result_lines.push(prefix_to_insert.clone());
576 }
577 result_lines.push(line.to_string());
578 }
579
580 let mut result = result_lines.join("\n");
582 if ctx.content.ends_with('\n') {
583 result.push('\n');
584 }
585 Ok(result)
586 }
587}
588
589fn is_blank_in_context(line: &str) -> bool {
591 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
594 line[m.end()..].trim().is_empty()
596 } else {
597 line.trim().is_empty()
599 }
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::lint_context::LintContext;
606 use crate::rule::Rule;
607
608 fn lint(content: &str) -> Vec<LintWarning> {
609 let rule = MD032BlanksAroundLists;
610 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
611 rule.check(&ctx).expect("Lint check failed")
612 }
613
614 fn fix(content: &str) -> String {
615 let rule = MD032BlanksAroundLists;
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 rule.fix(&ctx).expect("Lint fix failed")
618 }
619
620 fn check_warnings_have_fixes(content: &str) {
622 let warnings = lint(content);
623 for warning in &warnings {
624 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
625 }
626 }
627
628 #[test]
629 fn test_list_at_start() {
630 let content = "- Item 1\n- Item 2\nText";
633 let warnings = lint(content);
634 assert_eq!(
635 warnings.len(),
636 0,
637 "Trailing text is lazy continuation per CommonMark - no warning expected"
638 );
639 }
640
641 #[test]
642 fn test_list_at_end() {
643 let content = "Text\n- Item 1\n- Item 2";
644 let warnings = lint(content);
645 assert_eq!(
646 warnings.len(),
647 1,
648 "Expected 1 warning for list at end without preceding blank line"
649 );
650 assert_eq!(
651 warnings[0].line, 2,
652 "Warning should be on the first line of the list (line 2)"
653 );
654 assert!(warnings[0].message.contains("preceded by blank line"));
655
656 check_warnings_have_fixes(content);
658
659 let fixed_content = fix(content);
660 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
661
662 let warnings_after_fix = lint(&fixed_content);
664 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
665 }
666
667 #[test]
668 fn test_list_in_middle() {
669 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
672 let warnings = lint(content);
673 assert_eq!(
674 warnings.len(),
675 1,
676 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
677 );
678 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
679 assert!(warnings[0].message.contains("preceded by blank line"));
680
681 check_warnings_have_fixes(content);
683
684 let fixed_content = fix(content);
685 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
686
687 let warnings_after_fix = lint(&fixed_content);
689 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
690 }
691
692 #[test]
693 fn test_correct_spacing() {
694 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
695 let warnings = lint(content);
696 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
697
698 let fixed_content = fix(content);
699 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
700 }
701
702 #[test]
703 fn test_list_with_content() {
704 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
707 let warnings = lint(content);
708 assert_eq!(
709 warnings.len(),
710 1,
711 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
712 );
713 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
714 assert!(warnings[0].message.contains("preceded by blank line"));
715
716 check_warnings_have_fixes(content);
718
719 let fixed_content = fix(content);
720 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
721 assert_eq!(
722 fixed_content, expected_fixed,
723 "Fix did not produce the expected output. Got:\n{fixed_content}"
724 );
725
726 let warnings_after_fix = lint(&fixed_content);
728 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
729 }
730
731 #[test]
732 fn test_nested_list() {
733 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
735 let warnings = lint(content);
736 assert_eq!(
737 warnings.len(),
738 1,
739 "Nested list block needs preceding blank only. Got: {warnings:?}"
740 );
741 assert_eq!(warnings[0].line, 2);
742 assert!(warnings[0].message.contains("preceded by blank line"));
743
744 check_warnings_have_fixes(content);
746
747 let fixed_content = fix(content);
748 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
749
750 let warnings_after_fix = lint(&fixed_content);
752 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
753 }
754
755 #[test]
756 fn test_list_with_internal_blanks() {
757 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
759 let warnings = lint(content);
760 assert_eq!(
761 warnings.len(),
762 1,
763 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
764 );
765 assert_eq!(warnings[0].line, 2);
766 assert!(warnings[0].message.contains("preceded by blank line"));
767
768 check_warnings_have_fixes(content);
770
771 let fixed_content = fix(content);
772 assert_eq!(
773 fixed_content,
774 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
775 );
776
777 let warnings_after_fix = lint(&fixed_content);
779 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
780 }
781
782 #[test]
783 fn test_ignore_code_blocks() {
784 let content = "```\n- Not a list item\n```\nText";
785 let warnings = lint(content);
786 assert_eq!(warnings.len(), 0);
787 let fixed_content = fix(content);
788 assert_eq!(fixed_content, content);
789 }
790
791 #[test]
792 fn test_ignore_front_matter() {
793 let content = "---\ntitle: Test\n---\n- List Item\nText";
795 let warnings = lint(content);
796 assert_eq!(
797 warnings.len(),
798 0,
799 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
800 );
801
802 let fixed_content = fix(content);
804 assert_eq!(fixed_content, content, "No changes when no warnings");
805 }
806
807 #[test]
808 fn test_multiple_lists() {
809 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
814 let warnings = lint(content);
815 assert!(
817 !warnings.is_empty(),
818 "Should have at least one warning for missing blank line. Got: {warnings:?}"
819 );
820
821 check_warnings_have_fixes(content);
823
824 let fixed_content = fix(content);
825 let warnings_after_fix = lint(&fixed_content);
827 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
828 }
829
830 #[test]
831 fn test_adjacent_lists() {
832 let content = "- List 1\n\n* List 2";
833 let warnings = lint(content);
834 assert_eq!(warnings.len(), 0);
835 let fixed_content = fix(content);
836 assert_eq!(fixed_content, content);
837 }
838
839 #[test]
840 fn test_list_in_blockquote() {
841 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
843 let warnings = lint(content);
844 assert_eq!(
845 warnings.len(),
846 1,
847 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
848 );
849 assert_eq!(warnings[0].line, 2);
850
851 check_warnings_have_fixes(content);
853
854 let fixed_content = fix(content);
855 assert_eq!(
857 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
858 "Fix for blockquoted list failed. Got:\n{fixed_content}"
859 );
860
861 let warnings_after_fix = lint(&fixed_content);
863 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
864 }
865
866 #[test]
867 fn test_ordered_list() {
868 let content = "Text\n1. Item 1\n2. Item 2\nText";
870 let warnings = lint(content);
871 assert_eq!(warnings.len(), 1);
872
873 check_warnings_have_fixes(content);
875
876 let fixed_content = fix(content);
877 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
878
879 let warnings_after_fix = lint(&fixed_content);
881 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
882 }
883
884 #[test]
885 fn test_no_double_blank_fix() {
886 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
889 assert_eq!(
890 warnings.len(),
891 0,
892 "Should have no warnings - properly preceded, trailing is lazy"
893 );
894
895 let fixed_content = fix(content);
896 assert_eq!(
897 fixed_content, content,
898 "No fix needed when no warnings. Got:\n{fixed_content}"
899 );
900
901 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
903 assert_eq!(warnings2.len(), 1);
904 if !warnings2.is_empty() {
905 assert_eq!(
906 warnings2[0].line, 2,
907 "Warning line for missing blank before should be the first line of the block"
908 );
909 }
910
911 check_warnings_have_fixes(content2);
913
914 let fixed_content2 = fix(content2);
915 assert_eq!(
916 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
917 "Fix added extra blank before. Got:\n{fixed_content2}"
918 );
919 }
920
921 #[test]
922 fn test_empty_input() {
923 let content = "";
924 let warnings = lint(content);
925 assert_eq!(warnings.len(), 0);
926 let fixed_content = fix(content);
927 assert_eq!(fixed_content, "");
928 }
929
930 #[test]
931 fn test_only_list() {
932 let content = "- Item 1\n- Item 2";
933 let warnings = lint(content);
934 assert_eq!(warnings.len(), 0);
935 let fixed_content = fix(content);
936 assert_eq!(fixed_content, content);
937 }
938
939 #[test]
942 fn test_fix_complex_nested_blockquote() {
943 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
945 let warnings = lint(content);
946 assert_eq!(
947 warnings.len(),
948 1,
949 "Should warn for missing preceding blank only. Got: {warnings:?}"
950 );
951
952 check_warnings_have_fixes(content);
954
955 let fixed_content = fix(content);
956 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
957 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
958
959 let warnings_after_fix = lint(&fixed_content);
960 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
961 }
962
963 #[test]
964 fn test_fix_mixed_list_markers() {
965 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
968 let warnings = lint(content);
969 assert!(
971 !warnings.is_empty(),
972 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
973 );
974
975 check_warnings_have_fixes(content);
977
978 let fixed_content = fix(content);
979 assert!(
981 fixed_content.contains("Text\n\n-"),
982 "Fix should add blank line before first list item"
983 );
984
985 let warnings_after_fix = lint(&fixed_content);
987 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
988 }
989
990 #[test]
991 fn test_fix_ordered_list_with_different_numbers() {
992 let content = "Text\n1. First\n3. Third\n2. Second\nText";
994 let warnings = lint(content);
995 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
996
997 check_warnings_have_fixes(content);
999
1000 let fixed_content = fix(content);
1001 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1002 assert_eq!(
1003 fixed_content, expected,
1004 "Fix should handle ordered lists with non-sequential numbers"
1005 );
1006
1007 let warnings_after_fix = lint(&fixed_content);
1009 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1010 }
1011
1012 #[test]
1013 fn test_fix_list_with_code_blocks_inside() {
1014 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1016 let warnings = lint(content);
1017 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1018
1019 check_warnings_have_fixes(content);
1021
1022 let fixed_content = fix(content);
1023 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1024 assert_eq!(
1025 fixed_content, expected,
1026 "Fix should handle lists with internal code blocks"
1027 );
1028
1029 let warnings_after_fix = lint(&fixed_content);
1031 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1032 }
1033
1034 #[test]
1035 fn test_fix_deeply_nested_lists() {
1036 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1038 let warnings = lint(content);
1039 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1040
1041 check_warnings_have_fixes(content);
1043
1044 let fixed_content = fix(content);
1045 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1046 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1047
1048 let warnings_after_fix = lint(&fixed_content);
1050 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1051 }
1052
1053 #[test]
1054 fn test_fix_list_with_multiline_items() {
1055 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1058 let warnings = lint(content);
1059 assert_eq!(
1060 warnings.len(),
1061 1,
1062 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1063 );
1064
1065 check_warnings_have_fixes(content);
1067
1068 let fixed_content = fix(content);
1069 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1070 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1071
1072 let warnings_after_fix = lint(&fixed_content);
1074 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1075 }
1076
1077 #[test]
1078 fn test_fix_list_at_document_boundaries() {
1079 let content1 = "- Item 1\n- Item 2";
1081 let warnings1 = lint(content1);
1082 assert_eq!(
1083 warnings1.len(),
1084 0,
1085 "List at document start should not need blank before"
1086 );
1087 let fixed1 = fix(content1);
1088 assert_eq!(fixed1, content1, "No fix needed for list at start");
1089
1090 let content2 = "Text\n- Item 1\n- Item 2";
1092 let warnings2 = lint(content2);
1093 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1094 check_warnings_have_fixes(content2);
1095 let fixed2 = fix(content2);
1096 assert_eq!(
1097 fixed2, "Text\n\n- Item 1\n- Item 2",
1098 "Should add blank before list at end"
1099 );
1100 }
1101
1102 #[test]
1103 fn test_fix_preserves_existing_blank_lines() {
1104 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1105 let warnings = lint(content);
1106 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1107 let fixed_content = fix(content);
1108 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1109 }
1110
1111 #[test]
1112 fn test_fix_handles_tabs_and_spaces() {
1113 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1115 let warnings = lint(content);
1116 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1119
1120 check_warnings_have_fixes(content);
1122
1123 let fixed_content = fix(content);
1124 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\nText";
1126 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1127
1128 let warnings_after_fix = lint(&fixed_content);
1130 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1131 }
1132
1133 #[test]
1134 fn test_fix_warning_objects_have_correct_ranges() {
1135 let content = "Text\n- Item 1\n- Item 2\nText";
1137 let warnings = lint(content);
1138 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1139
1140 for warning in &warnings {
1142 assert!(warning.fix.is_some(), "Warning should have fix");
1143 let fix = warning.fix.as_ref().unwrap();
1144 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1145 assert!(
1146 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1147 "Fix should have replacement or be insertion"
1148 );
1149 }
1150 }
1151
1152 #[test]
1153 fn test_fix_idempotent() {
1154 let content = "Text\n- Item 1\n- Item 2\nText";
1156
1157 let fixed_once = fix(content);
1159 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1160
1161 let fixed_twice = fix(&fixed_once);
1163 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1164
1165 let warnings_after_fix = lint(&fixed_once);
1167 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1168 }
1169
1170 #[test]
1171 fn test_fix_with_normalized_line_endings() {
1172 let content = "Text\n- Item 1\n- Item 2\nText";
1176 let warnings = lint(content);
1177 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1178
1179 check_warnings_have_fixes(content);
1181
1182 let fixed_content = fix(content);
1183 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1185 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1186 }
1187
1188 #[test]
1189 fn test_fix_preserves_final_newline() {
1190 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1193 let fixed_with_newline = fix(content_with_newline);
1194 assert!(
1195 fixed_with_newline.ends_with('\n'),
1196 "Fix should preserve final newline when present"
1197 );
1198 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1200
1201 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1203 let fixed_without_newline = fix(content_without_newline);
1204 assert!(
1205 !fixed_without_newline.ends_with('\n'),
1206 "Fix should not add final newline when not present"
1207 );
1208 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1210 }
1211
1212 #[test]
1213 fn test_fix_multiline_list_items_no_indent() {
1214 let content = "## Configuration\n\nThis rule has the following configuration options:\n\n- `option1`: Description that continues\non the next line without indentation.\n- `option2`: Another description that also continues\non the next line.\n\n## Next Section";
1215
1216 let warnings = lint(content);
1217 assert_eq!(
1219 warnings.len(),
1220 0,
1221 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1222 );
1223
1224 let fixed_content = fix(content);
1225 assert_eq!(
1227 fixed_content, content,
1228 "Should not modify correctly formatted multi-line list items"
1229 );
1230 }
1231
1232 #[test]
1233 fn test_nested_list_with_lazy_continuation() {
1234 let content = r#"# Test
1240
1241- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1242 1. Switch/case dispatcher statements (original Phase 3.2)
1243 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1244`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1245 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1246 references"#;
1247
1248 let warnings = lint(content);
1249 let md032_warnings: Vec<_> = warnings
1252 .iter()
1253 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1254 .collect();
1255 assert_eq!(
1256 md032_warnings.len(),
1257 0,
1258 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_pipes_in_code_spans_not_detected_as_table() {
1264 let content = r#"# Test
1266
1267- Item with `a | b` inline code
1268 - Nested item should work
1269
1270"#;
1271
1272 let warnings = lint(content);
1273 let md032_warnings: Vec<_> = warnings
1274 .iter()
1275 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1276 .collect();
1277 assert_eq!(
1278 md032_warnings.len(),
1279 0,
1280 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_multiple_code_spans_with_pipes() {
1286 let content = r#"# Test
1288
1289- Item with `a | b` and `c || d` operators
1290 - Nested item should work
1291
1292"#;
1293
1294 let warnings = lint(content);
1295 let md032_warnings: Vec<_> = warnings
1296 .iter()
1297 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1298 .collect();
1299 assert_eq!(
1300 md032_warnings.len(),
1301 0,
1302 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_actual_table_breaks_list() {
1308 let content = r#"# Test
1310
1311- Item before table
1312
1313| Col1 | Col2 |
1314|------|------|
1315| A | B |
1316
1317- Item after table
1318
1319"#;
1320
1321 let warnings = lint(content);
1322 let md032_warnings: Vec<_> = warnings
1324 .iter()
1325 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1326 .collect();
1327 assert_eq!(
1328 md032_warnings.len(),
1329 0,
1330 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_thematic_break_not_lazy_continuation() {
1336 let content = r#"- Item 1
1339- Item 2
1340***
1341
1342More text.
1343"#;
1344
1345 let warnings = lint(content);
1346 let md032_warnings: Vec<_> = warnings
1347 .iter()
1348 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1349 .collect();
1350 assert_eq!(
1351 md032_warnings.len(),
1352 1,
1353 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1354 );
1355 assert!(
1356 md032_warnings[0].message.contains("followed by blank line"),
1357 "Warning should be about missing blank after list"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_thematic_break_with_blank_line() {
1363 let content = r#"- Item 1
1365- Item 2
1366
1367***
1368
1369More text.
1370"#;
1371
1372 let warnings = lint(content);
1373 let md032_warnings: Vec<_> = warnings
1374 .iter()
1375 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1376 .collect();
1377 assert_eq!(
1378 md032_warnings.len(),
1379 0,
1380 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1381 );
1382 }
1383
1384 #[test]
1385 fn test_various_thematic_break_styles() {
1386 for hr in ["---", "***", "___"] {
1391 let content = format!(
1392 r#"- Item 1
1393- Item 2
1394{hr}
1395
1396More text.
1397"#
1398 );
1399
1400 let warnings = lint(&content);
1401 let md032_warnings: Vec<_> = warnings
1402 .iter()
1403 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1404 .collect();
1405 assert_eq!(
1406 md032_warnings.len(),
1407 1,
1408 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1409 );
1410 }
1411 }
1412}