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
9#[derive(Debug, Clone, Default)]
79pub struct MD032BlanksAroundLists;
80
81impl MD032BlanksAroundLists {
82 fn should_require_blank_line_before(
84 ctx: &crate::lint_context::LintContext,
85 prev_line_num: usize,
86 current_line_num: usize,
87 ) -> bool {
88 if ctx
90 .line_info(prev_line_num)
91 .is_some_and(|info| info.in_code_block || info.in_front_matter)
92 {
93 return true;
94 }
95
96 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
98 return false;
99 }
100
101 true
103 }
104
105 fn is_nested_list(
107 ctx: &crate::lint_context::LintContext,
108 prev_line_num: usize, current_line_num: usize, ) -> bool {
111 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
113 let current_line = &ctx.lines[current_line_num - 1];
114 if current_line.indent >= 2 {
115 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
117 let prev_line = &ctx.lines[prev_line_num - 1];
118 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
120 return true;
121 }
122 }
123 }
124 }
125 false
126 }
127
128 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
130 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
131
132 for block in &ctx.list_blocks {
133 let mut segments: Vec<(usize, usize)> = Vec::new();
139 let mut current_start = block.start_line;
140 let mut prev_item_line = 0;
141
142 for &item_line in &block.item_lines {
143 if prev_item_line > 0 {
144 let mut has_standalone_code_fence = false;
147
148 let min_indent_for_content = if block.is_ordered {
150 3 } else {
154 2 };
157
158 for check_line in (prev_item_line + 1)..item_line {
159 if check_line - 1 < ctx.lines.len() {
160 let line = &ctx.lines[check_line - 1];
161 let line_content = line.content(ctx.content);
162 if line.in_code_block
163 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
164 {
165 if line.indent < min_indent_for_content {
168 has_standalone_code_fence = true;
169 break;
170 }
171 }
172 }
173 }
174
175 if has_standalone_code_fence {
176 segments.push((current_start, prev_item_line));
178 current_start = item_line;
179 }
180 }
181 prev_item_line = item_line;
182 }
183
184 if prev_item_line > 0 {
187 segments.push((current_start, prev_item_line));
188 }
189
190 let has_code_fence_splits = segments.len() > 1 && {
192 let mut found_fence = false;
194 for i in 0..segments.len() - 1 {
195 let seg_end = segments[i].1;
196 let next_start = segments[i + 1].0;
197 for check_line in (seg_end + 1)..next_start {
199 if check_line - 1 < ctx.lines.len() {
200 let line = &ctx.lines[check_line - 1];
201 let line_content = line.content(ctx.content);
202 if line.in_code_block
203 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
204 {
205 found_fence = true;
206 break;
207 }
208 }
209 }
210 if found_fence {
211 break;
212 }
213 }
214 found_fence
215 };
216
217 for (start, end) in segments.iter() {
219 let mut actual_end = *end;
221
222 if !has_code_fence_splits && *end < block.end_line {
225 for check_line in (*end + 1)..=block.end_line {
226 if check_line - 1 < ctx.lines.len() {
227 let line = &ctx.lines[check_line - 1];
228 if block.item_lines.contains(&check_line) || line.heading.is_some() {
230 break;
231 }
232 if line.in_code_block {
234 break;
235 }
236 if line.indent >= 2 {
238 actual_end = check_line;
239 }
240 else if !line.is_blank
242 && line.heading.is_none()
243 && !block.item_lines.contains(&check_line)
244 {
245 actual_end = check_line;
248 } else if !line.is_blank {
249 break;
251 }
252 }
253 }
254 }
255
256 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
257 }
258 }
259
260 blocks
261 }
262
263 fn perform_checks(
264 &self,
265 ctx: &crate::lint_context::LintContext,
266 lines: &[&str],
267 list_blocks: &[(usize, usize, String)],
268 line_index: &LineIndex,
269 ) -> LintResult {
270 let mut warnings = Vec::new();
271 let num_lines = lines.len();
272
273 for (line_idx, line) in lines.iter().enumerate() {
276 let line_num = line_idx + 1;
277
278 let is_in_list = list_blocks
280 .iter()
281 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
282 if is_in_list {
283 continue;
284 }
285
286 if ctx
288 .line_info(line_num)
289 .is_some_and(|info| info.in_code_block || info.in_front_matter)
290 {
291 continue;
292 }
293
294 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
296 if line_idx > 0 {
298 let prev_line = lines[line_idx - 1];
299 let prev_is_blank = is_blank_in_context(prev_line);
300 let prev_excluded = ctx
301 .line_info(line_idx)
302 .is_some_and(|info| info.in_code_block || info.in_front_matter);
303
304 if !prev_is_blank && !prev_excluded {
305 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
307
308 warnings.push(LintWarning {
309 line: start_line,
310 column: start_col,
311 end_line,
312 end_column: end_col,
313 severity: Severity::Error,
314 rule_name: Some(self.name().to_string()),
315 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
316 fix: Some(Fix {
317 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
318 replacement: "\n".to_string(),
319 }),
320 });
321 }
322 }
323 }
324 }
325
326 for &(start_line, end_line, ref prefix) in list_blocks {
327 if start_line > 1 {
328 let prev_line_actual_idx_0 = start_line - 2;
329 let prev_line_actual_idx_1 = start_line - 1;
330 let prev_line_str = lines[prev_line_actual_idx_0];
331 let is_prev_excluded = ctx
332 .line_info(prev_line_actual_idx_1)
333 .is_some_and(|info| info.in_code_block || info.in_front_matter);
334 let prev_prefix = BLOCKQUOTE_PREFIX_RE
335 .find(prev_line_str)
336 .map_or(String::new(), |m| m.as_str().to_string());
337 let prev_is_blank = is_blank_in_context(prev_line_str);
338 let prefixes_match = prev_prefix.trim() == prefix.trim();
339
340 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
343 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
344 let (start_line, start_col, end_line, end_col) =
346 calculate_line_range(start_line, lines[start_line - 1]);
347
348 warnings.push(LintWarning {
349 line: start_line,
350 column: start_col,
351 end_line,
352 end_column: end_col,
353 severity: Severity::Error,
354 rule_name: Some(self.name().to_string()),
355 message: "List should be preceded by blank line".to_string(),
356 fix: Some(Fix {
357 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
358 replacement: format!("{prefix}\n"),
359 }),
360 });
361 }
362 }
363
364 if end_line < num_lines {
365 let next_line_idx_0 = end_line;
366 let next_line_idx_1 = end_line + 1;
367 let next_line_str = lines[next_line_idx_0];
368 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
371 || (next_line_idx_0 < ctx.lines.len()
372 && ctx.lines[next_line_idx_0].in_code_block
373 && ctx.lines[next_line_idx_0].indent >= 2);
374 let next_prefix = BLOCKQUOTE_PREFIX_RE
375 .find(next_line_str)
376 .map_or(String::new(), |m| m.as_str().to_string());
377 let next_is_blank = is_blank_in_context(next_line_str);
378 let prefixes_match = next_prefix.trim() == prefix.trim();
379
380 if !is_next_excluded && !next_is_blank && prefixes_match {
382 let (start_line_last, start_col_last, end_line_last, end_col_last) =
384 calculate_line_range(end_line, lines[end_line - 1]);
385
386 warnings.push(LintWarning {
387 line: start_line_last,
388 column: start_col_last,
389 end_line: end_line_last,
390 end_column: end_col_last,
391 severity: Severity::Error,
392 rule_name: Some(self.name().to_string()),
393 message: "List should be followed by blank line".to_string(),
394 fix: Some(Fix {
395 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
396 replacement: format!("{prefix}\n"),
397 }),
398 });
399 }
400 }
401 }
402 Ok(warnings)
403 }
404}
405
406impl Rule for MD032BlanksAroundLists {
407 fn name(&self) -> &'static str {
408 "MD032"
409 }
410
411 fn description(&self) -> &'static str {
412 "Lists should be surrounded by blank lines"
413 }
414
415 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
416 let content = ctx.content;
417 let lines: Vec<&str> = content.lines().collect();
418 let line_index = &ctx.line_index;
419
420 if lines.is_empty() {
422 return Ok(Vec::new());
423 }
424
425 let list_blocks = self.convert_list_blocks(ctx);
426
427 if list_blocks.is_empty() {
428 return Ok(Vec::new());
429 }
430
431 self.perform_checks(ctx, &lines, &list_blocks, line_index)
432 }
433
434 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
435 self.fix_with_structure_impl(ctx)
436 }
437
438 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
439 if ctx.content.is_empty() || !ctx.likely_has_lists() {
441 return true;
442 }
443 ctx.list_blocks.is_empty()
445 }
446
447 fn category(&self) -> RuleCategory {
448 RuleCategory::List
449 }
450
451 fn as_any(&self) -> &dyn std::any::Any {
452 self
453 }
454
455 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
456 where
457 Self: Sized,
458 {
459 Box::new(MD032BlanksAroundLists)
460 }
461}
462
463impl MD032BlanksAroundLists {
464 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
466 let lines: Vec<&str> = ctx.content.lines().collect();
467 let num_lines = lines.len();
468 if num_lines == 0 {
469 return Ok(String::new());
470 }
471
472 let list_blocks = self.convert_list_blocks(ctx);
473 if list_blocks.is_empty() {
474 return Ok(ctx.content.to_string());
475 }
476
477 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
478
479 for &(start_line, end_line, ref prefix) in &list_blocks {
481 if start_line > 1 {
483 let prev_line_actual_idx_0 = start_line - 2;
484 let prev_line_actual_idx_1 = start_line - 1;
485 let is_prev_excluded = ctx
486 .line_info(prev_line_actual_idx_1)
487 .is_some_and(|info| info.in_code_block || info.in_front_matter);
488 let prev_prefix = BLOCKQUOTE_PREFIX_RE
489 .find(lines[prev_line_actual_idx_0])
490 .map_or(String::new(), |m| m.as_str().to_string());
491
492 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
493 if !is_prev_excluded
494 && !is_blank_in_context(lines[prev_line_actual_idx_0])
495 && prev_prefix == *prefix
496 && should_require
497 {
498 insertions.insert(start_line, prefix.clone());
499 }
500 }
501
502 if end_line < num_lines {
504 let after_block_line_idx_0 = end_line;
505 let after_block_line_idx_1 = end_line + 1;
506 let line_after_block_content_str = lines[after_block_line_idx_0];
507 let is_line_after_excluded = ctx
510 .line_info(after_block_line_idx_1)
511 .is_some_and(|info| info.in_code_block || info.in_front_matter)
512 || (after_block_line_idx_0 < ctx.lines.len()
513 && ctx.lines[after_block_line_idx_0].in_code_block
514 && ctx.lines[after_block_line_idx_0].indent >= 2
515 && (ctx.lines[after_block_line_idx_0]
516 .content(ctx.content)
517 .trim()
518 .starts_with("```")
519 || ctx.lines[after_block_line_idx_0]
520 .content(ctx.content)
521 .trim()
522 .starts_with("~~~")));
523 let after_prefix = BLOCKQUOTE_PREFIX_RE
524 .find(line_after_block_content_str)
525 .map_or(String::new(), |m| m.as_str().to_string());
526
527 if !is_line_after_excluded
528 && !is_blank_in_context(line_after_block_content_str)
529 && after_prefix == *prefix
530 {
531 insertions.insert(after_block_line_idx_1, prefix.clone());
532 }
533 }
534 }
535
536 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
538 for (i, line) in lines.iter().enumerate() {
539 let current_line_num = i + 1;
540 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
541 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
542 {
543 result_lines.push(prefix_to_insert.clone());
544 }
545 result_lines.push(line.to_string());
546 }
547
548 let mut result = result_lines.join("\n");
550 if ctx.content.ends_with('\n') {
551 result.push('\n');
552 }
553 Ok(result)
554 }
555}
556
557fn is_blank_in_context(line: &str) -> bool {
559 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
562 line[m.end()..].trim().is_empty()
564 } else {
565 line.trim().is_empty()
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573 use crate::lint_context::LintContext;
574 use crate::rule::Rule;
575
576 fn lint(content: &str) -> Vec<LintWarning> {
577 let rule = MD032BlanksAroundLists;
578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579 rule.check(&ctx).expect("Lint check failed")
580 }
581
582 fn fix(content: &str) -> String {
583 let rule = MD032BlanksAroundLists;
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585 rule.fix(&ctx).expect("Lint fix failed")
586 }
587
588 fn check_warnings_have_fixes(content: &str) {
590 let warnings = lint(content);
591 for warning in &warnings {
592 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
593 }
594 }
595
596 #[test]
597 fn test_list_at_start() {
598 let content = "- Item 1\n- Item 2\nText";
601 let warnings = lint(content);
602 assert_eq!(
603 warnings.len(),
604 0,
605 "Trailing text is lazy continuation per CommonMark - no warning expected"
606 );
607 }
608
609 #[test]
610 fn test_list_at_end() {
611 let content = "Text\n- Item 1\n- Item 2";
612 let warnings = lint(content);
613 assert_eq!(
614 warnings.len(),
615 1,
616 "Expected 1 warning for list at end without preceding blank line"
617 );
618 assert_eq!(
619 warnings[0].line, 2,
620 "Warning should be on the first line of the list (line 2)"
621 );
622 assert!(warnings[0].message.contains("preceded by blank line"));
623
624 check_warnings_have_fixes(content);
626
627 let fixed_content = fix(content);
628 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
629
630 let warnings_after_fix = lint(&fixed_content);
632 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
633 }
634
635 #[test]
636 fn test_list_in_middle() {
637 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
640 let warnings = lint(content);
641 assert_eq!(
642 warnings.len(),
643 1,
644 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
645 );
646 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
647 assert!(warnings[0].message.contains("preceded by blank line"));
648
649 check_warnings_have_fixes(content);
651
652 let fixed_content = fix(content);
653 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
654
655 let warnings_after_fix = lint(&fixed_content);
657 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
658 }
659
660 #[test]
661 fn test_correct_spacing() {
662 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
663 let warnings = lint(content);
664 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
665
666 let fixed_content = fix(content);
667 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
668 }
669
670 #[test]
671 fn test_list_with_content() {
672 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
675 let warnings = lint(content);
676 assert_eq!(
677 warnings.len(),
678 1,
679 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
680 );
681 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
682 assert!(warnings[0].message.contains("preceded by blank line"));
683
684 check_warnings_have_fixes(content);
686
687 let fixed_content = fix(content);
688 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
689 assert_eq!(
690 fixed_content, expected_fixed,
691 "Fix did not produce the expected output. Got:\n{fixed_content}"
692 );
693
694 let warnings_after_fix = lint(&fixed_content);
696 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
697 }
698
699 #[test]
700 fn test_nested_list() {
701 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
703 let warnings = lint(content);
704 assert_eq!(
705 warnings.len(),
706 1,
707 "Nested list block needs preceding blank only. Got: {warnings:?}"
708 );
709 assert_eq!(warnings[0].line, 2);
710 assert!(warnings[0].message.contains("preceded by blank line"));
711
712 check_warnings_have_fixes(content);
714
715 let fixed_content = fix(content);
716 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
717
718 let warnings_after_fix = lint(&fixed_content);
720 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
721 }
722
723 #[test]
724 fn test_list_with_internal_blanks() {
725 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
727 let warnings = lint(content);
728 assert_eq!(
729 warnings.len(),
730 1,
731 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
732 );
733 assert_eq!(warnings[0].line, 2);
734 assert!(warnings[0].message.contains("preceded by blank line"));
735
736 check_warnings_have_fixes(content);
738
739 let fixed_content = fix(content);
740 assert_eq!(
741 fixed_content,
742 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
743 );
744
745 let warnings_after_fix = lint(&fixed_content);
747 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
748 }
749
750 #[test]
751 fn test_ignore_code_blocks() {
752 let content = "```\n- Not a list item\n```\nText";
753 let warnings = lint(content);
754 assert_eq!(warnings.len(), 0);
755 let fixed_content = fix(content);
756 assert_eq!(fixed_content, content);
757 }
758
759 #[test]
760 fn test_ignore_front_matter() {
761 let content = "---\ntitle: Test\n---\n- List Item\nText";
763 let warnings = lint(content);
764 assert_eq!(
765 warnings.len(),
766 0,
767 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
768 );
769
770 let fixed_content = fix(content);
772 assert_eq!(fixed_content, content, "No changes when no warnings");
773 }
774
775 #[test]
776 fn test_multiple_lists() {
777 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
782 let warnings = lint(content);
783 assert!(
785 !warnings.is_empty(),
786 "Should have at least one warning for missing blank line. Got: {warnings:?}"
787 );
788
789 check_warnings_have_fixes(content);
791
792 let fixed_content = fix(content);
793 let warnings_after_fix = lint(&fixed_content);
795 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
796 }
797
798 #[test]
799 fn test_adjacent_lists() {
800 let content = "- List 1\n\n* List 2";
801 let warnings = lint(content);
802 assert_eq!(warnings.len(), 0);
803 let fixed_content = fix(content);
804 assert_eq!(fixed_content, content);
805 }
806
807 #[test]
808 fn test_list_in_blockquote() {
809 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
811 let warnings = lint(content);
812 assert_eq!(
813 warnings.len(),
814 1,
815 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
816 );
817 assert_eq!(warnings[0].line, 2);
818
819 check_warnings_have_fixes(content);
821
822 let fixed_content = fix(content);
823 assert_eq!(
825 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
826 "Fix for blockquoted list failed. Got:\n{fixed_content}"
827 );
828
829 let warnings_after_fix = lint(&fixed_content);
831 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
832 }
833
834 #[test]
835 fn test_ordered_list() {
836 let content = "Text\n1. Item 1\n2. Item 2\nText";
838 let warnings = lint(content);
839 assert_eq!(warnings.len(), 1);
840
841 check_warnings_have_fixes(content);
843
844 let fixed_content = fix(content);
845 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
846
847 let warnings_after_fix = lint(&fixed_content);
849 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
850 }
851
852 #[test]
853 fn test_no_double_blank_fix() {
854 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
857 assert_eq!(
858 warnings.len(),
859 0,
860 "Should have no warnings - properly preceded, trailing is lazy"
861 );
862
863 let fixed_content = fix(content);
864 assert_eq!(
865 fixed_content, content,
866 "No fix needed when no warnings. Got:\n{fixed_content}"
867 );
868
869 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
871 assert_eq!(warnings2.len(), 1);
872 if !warnings2.is_empty() {
873 assert_eq!(
874 warnings2[0].line, 2,
875 "Warning line for missing blank before should be the first line of the block"
876 );
877 }
878
879 check_warnings_have_fixes(content2);
881
882 let fixed_content2 = fix(content2);
883 assert_eq!(
884 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
885 "Fix added extra blank before. Got:\n{fixed_content2}"
886 );
887 }
888
889 #[test]
890 fn test_empty_input() {
891 let content = "";
892 let warnings = lint(content);
893 assert_eq!(warnings.len(), 0);
894 let fixed_content = fix(content);
895 assert_eq!(fixed_content, "");
896 }
897
898 #[test]
899 fn test_only_list() {
900 let content = "- Item 1\n- Item 2";
901 let warnings = lint(content);
902 assert_eq!(warnings.len(), 0);
903 let fixed_content = fix(content);
904 assert_eq!(fixed_content, content);
905 }
906
907 #[test]
910 fn test_fix_complex_nested_blockquote() {
911 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
913 let warnings = lint(content);
914 assert_eq!(
915 warnings.len(),
916 1,
917 "Should warn for missing preceding blank only. Got: {warnings:?}"
918 );
919
920 check_warnings_have_fixes(content);
922
923 let fixed_content = fix(content);
924 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
925 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
926
927 let warnings_after_fix = lint(&fixed_content);
928 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
929 }
930
931 #[test]
932 fn test_fix_mixed_list_markers() {
933 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
936 let warnings = lint(content);
937 assert!(
939 !warnings.is_empty(),
940 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
941 );
942
943 check_warnings_have_fixes(content);
945
946 let fixed_content = fix(content);
947 assert!(
949 fixed_content.contains("Text\n\n-"),
950 "Fix should add blank line before first list item"
951 );
952
953 let warnings_after_fix = lint(&fixed_content);
955 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
956 }
957
958 #[test]
959 fn test_fix_ordered_list_with_different_numbers() {
960 let content = "Text\n1. First\n3. Third\n2. Second\nText";
962 let warnings = lint(content);
963 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
964
965 check_warnings_have_fixes(content);
967
968 let fixed_content = fix(content);
969 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
970 assert_eq!(
971 fixed_content, expected,
972 "Fix should handle ordered lists with non-sequential numbers"
973 );
974
975 let warnings_after_fix = lint(&fixed_content);
977 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
978 }
979
980 #[test]
981 fn test_fix_list_with_code_blocks_inside() {
982 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
984 let warnings = lint(content);
985 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
986
987 check_warnings_have_fixes(content);
989
990 let fixed_content = fix(content);
991 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
992 assert_eq!(
993 fixed_content, expected,
994 "Fix should handle lists with internal code blocks"
995 );
996
997 let warnings_after_fix = lint(&fixed_content);
999 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1000 }
1001
1002 #[test]
1003 fn test_fix_deeply_nested_lists() {
1004 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1006 let warnings = lint(content);
1007 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1008
1009 check_warnings_have_fixes(content);
1011
1012 let fixed_content = fix(content);
1013 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1014 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1015
1016 let warnings_after_fix = lint(&fixed_content);
1018 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1019 }
1020
1021 #[test]
1022 fn test_fix_list_with_multiline_items() {
1023 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1026 let warnings = lint(content);
1027 assert_eq!(
1028 warnings.len(),
1029 1,
1030 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1031 );
1032
1033 check_warnings_have_fixes(content);
1035
1036 let fixed_content = fix(content);
1037 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1038 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1039
1040 let warnings_after_fix = lint(&fixed_content);
1042 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1043 }
1044
1045 #[test]
1046 fn test_fix_list_at_document_boundaries() {
1047 let content1 = "- Item 1\n- Item 2";
1049 let warnings1 = lint(content1);
1050 assert_eq!(
1051 warnings1.len(),
1052 0,
1053 "List at document start should not need blank before"
1054 );
1055 let fixed1 = fix(content1);
1056 assert_eq!(fixed1, content1, "No fix needed for list at start");
1057
1058 let content2 = "Text\n- Item 1\n- Item 2";
1060 let warnings2 = lint(content2);
1061 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1062 check_warnings_have_fixes(content2);
1063 let fixed2 = fix(content2);
1064 assert_eq!(
1065 fixed2, "Text\n\n- Item 1\n- Item 2",
1066 "Should add blank before list at end"
1067 );
1068 }
1069
1070 #[test]
1071 fn test_fix_preserves_existing_blank_lines() {
1072 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1073 let warnings = lint(content);
1074 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1075 let fixed_content = fix(content);
1076 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1077 }
1078
1079 #[test]
1080 fn test_fix_handles_tabs_and_spaces() {
1081 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1083 let warnings = lint(content);
1084 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1087
1088 check_warnings_have_fixes(content);
1090
1091 let fixed_content = fix(content);
1092 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\nText";
1094 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1095
1096 let warnings_after_fix = lint(&fixed_content);
1098 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1099 }
1100
1101 #[test]
1102 fn test_fix_warning_objects_have_correct_ranges() {
1103 let content = "Text\n- Item 1\n- Item 2\nText";
1105 let warnings = lint(content);
1106 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1107
1108 for warning in &warnings {
1110 assert!(warning.fix.is_some(), "Warning should have fix");
1111 let fix = warning.fix.as_ref().unwrap();
1112 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1113 assert!(
1114 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1115 "Fix should have replacement or be insertion"
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn test_fix_idempotent() {
1122 let content = "Text\n- Item 1\n- Item 2\nText";
1124
1125 let fixed_once = fix(content);
1127 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1128
1129 let fixed_twice = fix(&fixed_once);
1131 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1132
1133 let warnings_after_fix = lint(&fixed_once);
1135 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1136 }
1137
1138 #[test]
1139 fn test_fix_with_normalized_line_endings() {
1140 let content = "Text\n- Item 1\n- Item 2\nText";
1144 let warnings = lint(content);
1145 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1146
1147 check_warnings_have_fixes(content);
1149
1150 let fixed_content = fix(content);
1151 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1153 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1154 }
1155
1156 #[test]
1157 fn test_fix_preserves_final_newline() {
1158 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1161 let fixed_with_newline = fix(content_with_newline);
1162 assert!(
1163 fixed_with_newline.ends_with('\n'),
1164 "Fix should preserve final newline when present"
1165 );
1166 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1168
1169 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1171 let fixed_without_newline = fix(content_without_newline);
1172 assert!(
1173 !fixed_without_newline.ends_with('\n'),
1174 "Fix should not add final newline when not present"
1175 );
1176 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1178 }
1179
1180 #[test]
1181 fn test_fix_multiline_list_items_no_indent() {
1182 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";
1183
1184 let warnings = lint(content);
1185 assert_eq!(
1187 warnings.len(),
1188 0,
1189 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1190 );
1191
1192 let fixed_content = fix(content);
1193 assert_eq!(
1195 fixed_content, content,
1196 "Should not modify correctly formatted multi-line list items"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_nested_list_with_lazy_continuation() {
1202 let content = r#"# Test
1208
1209- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1210 1. Switch/case dispatcher statements (original Phase 3.2)
1211 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1212`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1213 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1214 references"#;
1215
1216 let warnings = lint(content);
1217 let md032_warnings: Vec<_> = warnings
1220 .iter()
1221 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1222 .collect();
1223 assert_eq!(
1224 md032_warnings.len(),
1225 0,
1226 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_pipes_in_code_spans_not_detected_as_table() {
1232 let content = r#"# Test
1234
1235- Item with `a | b` inline code
1236 - Nested item should work
1237
1238"#;
1239
1240 let warnings = lint(content);
1241 let md032_warnings: Vec<_> = warnings
1242 .iter()
1243 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1244 .collect();
1245 assert_eq!(
1246 md032_warnings.len(),
1247 0,
1248 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_multiple_code_spans_with_pipes() {
1254 let content = r#"# Test
1256
1257- Item with `a | b` and `c || d` operators
1258 - Nested item should work
1259
1260"#;
1261
1262 let warnings = lint(content);
1263 let md032_warnings: Vec<_> = warnings
1264 .iter()
1265 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1266 .collect();
1267 assert_eq!(
1268 md032_warnings.len(),
1269 0,
1270 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_actual_table_breaks_list() {
1276 let content = r#"# Test
1278
1279- Item before table
1280
1281| Col1 | Col2 |
1282|------|------|
1283| A | B |
1284
1285- Item after table
1286
1287"#;
1288
1289 let warnings = lint(content);
1290 let md032_warnings: Vec<_> = warnings
1292 .iter()
1293 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1294 .collect();
1295 assert_eq!(
1296 md032_warnings.len(),
1297 0,
1298 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1299 );
1300 }
1301}