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);
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);
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";
599 let warnings = lint(content);
600 assert_eq!(
601 warnings.len(),
602 1,
603 "Expected 1 warning for list at start without trailing blank line"
604 );
605 assert_eq!(
606 warnings[0].line, 2,
607 "Warning should be on the last line of the list (line 2)"
608 );
609 assert!(warnings[0].message.contains("followed by blank line"));
610
611 check_warnings_have_fixes(content);
613
614 let fixed_content = fix(content);
615 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
616
617 let warnings_after_fix = lint(&fixed_content);
619 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
620 }
621
622 #[test]
623 fn test_list_at_end() {
624 let content = "Text\n- Item 1\n- Item 2";
625 let warnings = lint(content);
626 assert_eq!(
627 warnings.len(),
628 1,
629 "Expected 1 warning for list at end without preceding blank line"
630 );
631 assert_eq!(
632 warnings[0].line, 2,
633 "Warning should be on the first line of the list (line 2)"
634 );
635 assert!(warnings[0].message.contains("preceded by blank line"));
636
637 check_warnings_have_fixes(content);
639
640 let fixed_content = fix(content);
641 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
642
643 let warnings_after_fix = lint(&fixed_content);
645 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
646 }
647
648 #[test]
649 fn test_list_in_middle() {
650 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
651 let warnings = lint(content);
652 assert_eq!(
653 warnings.len(),
654 2,
655 "Expected 2 warnings for list in middle without surrounding blank lines"
656 );
657 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
658 assert!(warnings[0].message.contains("preceded by blank line"));
659 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
660 assert!(warnings[1].message.contains("followed by blank line"));
661
662 check_warnings_have_fixes(content);
664
665 let fixed_content = fix(content);
666 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
667
668 let warnings_after_fix = lint(&fixed_content);
670 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
671 }
672
673 #[test]
674 fn test_correct_spacing() {
675 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
676 let warnings = lint(content);
677 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
678
679 let fixed_content = fix(content);
680 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
681 }
682
683 #[test]
684 fn test_list_with_content() {
685 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
686 let warnings = lint(content);
687 assert_eq!(
688 warnings.len(),
689 2,
690 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
691 );
692 if warnings.len() == 2 {
693 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
694 assert!(warnings[0].message.contains("preceded by blank line"));
695 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
696 assert!(warnings[1].message.contains("followed by blank line"));
697 }
698
699 check_warnings_have_fixes(content);
701
702 let fixed_content = fix(content);
703 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
704 assert_eq!(
705 fixed_content, expected_fixed,
706 "Fix did not produce the expected output. Got:\n{fixed_content}"
707 );
708
709 let warnings_after_fix = lint(&fixed_content);
711 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
712 }
713
714 #[test]
715 fn test_nested_list() {
716 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
717 let warnings = lint(content);
718 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
720 assert_eq!(warnings[0].line, 2);
721 assert_eq!(warnings[1].line, 4);
722 }
723
724 check_warnings_have_fixes(content);
726
727 let fixed_content = fix(content);
728 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
729
730 let warnings_after_fix = lint(&fixed_content);
732 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
733 }
734
735 #[test]
736 fn test_list_with_internal_blanks() {
737 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
738 let warnings = lint(content);
739 assert_eq!(
740 warnings.len(),
741 2,
742 "List with internal blanks warnings. Got: {warnings:?}"
743 );
744 if warnings.len() == 2 {
745 assert_eq!(warnings[0].line, 2);
746 assert_eq!(warnings[1].line, 5); }
748
749 check_warnings_have_fixes(content);
751
752 let fixed_content = fix(content);
753 assert_eq!(
754 fixed_content,
755 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
756 );
757
758 let warnings_after_fix = lint(&fixed_content);
760 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
761 }
762
763 #[test]
764 fn test_ignore_code_blocks() {
765 let content = "```\n- Not a list item\n```\nText";
766 let warnings = lint(content);
767 assert_eq!(warnings.len(), 0);
768 let fixed_content = fix(content);
769 assert_eq!(fixed_content, content);
770 }
771
772 #[test]
773 fn test_ignore_front_matter() {
774 let content = "---\ntitle: Test\n---\n- List Item\nText";
775 let warnings = lint(content);
776 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
777 if !warnings.is_empty() {
778 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
780 }
781
782 check_warnings_have_fixes(content);
784
785 let fixed_content = fix(content);
786 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
787
788 let warnings_after_fix = lint(&fixed_content);
790 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
791 }
792
793 #[test]
794 fn test_multiple_lists() {
795 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
796 let warnings = lint(content);
797 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
798
799 check_warnings_have_fixes(content);
801
802 let fixed_content = fix(content);
803 assert_eq!(
804 fixed_content,
805 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
806 );
807
808 let warnings_after_fix = lint(&fixed_content);
810 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
811 }
812
813 #[test]
814 fn test_adjacent_lists() {
815 let content = "- List 1\n\n* List 2";
816 let warnings = lint(content);
817 assert_eq!(warnings.len(), 0);
818 let fixed_content = fix(content);
819 assert_eq!(fixed_content, content);
820 }
821
822 #[test]
823 fn test_list_in_blockquote() {
824 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
825 let warnings = lint(content);
826 assert_eq!(
827 warnings.len(),
828 2,
829 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
830 );
831 if warnings.len() == 2 {
832 assert_eq!(warnings[0].line, 2);
833 assert_eq!(warnings[1].line, 3);
834 }
835
836 check_warnings_have_fixes(content);
838
839 let fixed_content = fix(content);
840 assert_eq!(
842 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
843 "Fix for blockquoted list failed. Got:\n{fixed_content}"
844 );
845
846 let warnings_after_fix = lint(&fixed_content);
848 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
849 }
850
851 #[test]
852 fn test_ordered_list() {
853 let content = "Text\n1. Item 1\n2. Item 2\nText";
854 let warnings = lint(content);
855 assert_eq!(warnings.len(), 2);
856
857 check_warnings_have_fixes(content);
859
860 let fixed_content = fix(content);
861 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
862
863 let warnings_after_fix = lint(&fixed_content);
865 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
866 }
867
868 #[test]
869 fn test_no_double_blank_fix() {
870 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
872 assert_eq!(warnings.len(), 1);
873 if !warnings.is_empty() {
874 assert_eq!(
875 warnings[0].line, 4,
876 "Warning line for missing blank after should be the last line of the block"
877 );
878 }
879
880 check_warnings_have_fixes(content);
882
883 let fixed_content = fix(content);
884 assert_eq!(
885 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
886 "Fix added extra blank after. Got:\n{fixed_content}"
887 );
888
889 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
891 assert_eq!(warnings2.len(), 1);
892 if !warnings2.is_empty() {
893 assert_eq!(
894 warnings2[0].line, 2,
895 "Warning line for missing blank before should be the first line of the block"
896 );
897 }
898
899 check_warnings_have_fixes(content2);
901
902 let fixed_content2 = fix(content2);
903 assert_eq!(
904 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
905 "Fix added extra blank before. Got:\n{fixed_content2}"
906 );
907 }
908
909 #[test]
910 fn test_empty_input() {
911 let content = "";
912 let warnings = lint(content);
913 assert_eq!(warnings.len(), 0);
914 let fixed_content = fix(content);
915 assert_eq!(fixed_content, "");
916 }
917
918 #[test]
919 fn test_only_list() {
920 let content = "- Item 1\n- Item 2";
921 let warnings = lint(content);
922 assert_eq!(warnings.len(), 0);
923 let fixed_content = fix(content);
924 assert_eq!(fixed_content, content);
925 }
926
927 #[test]
930 fn test_fix_complex_nested_blockquote() {
931 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
932 let warnings = lint(content);
933 assert_eq!(
937 warnings.len(),
938 2,
939 "Should warn for missing blanks around the entire list block"
940 );
941
942 check_warnings_have_fixes(content);
944
945 let fixed_content = fix(content);
946 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
947 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
948
949 let warnings_after_fix = lint(&fixed_content);
951 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
952 }
953
954 #[test]
955 fn test_fix_mixed_list_markers() {
956 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
957 let warnings = lint(content);
958 assert_eq!(
959 warnings.len(),
960 2,
961 "Should warn for missing blanks around mixed marker list"
962 );
963
964 check_warnings_have_fixes(content);
966
967 let fixed_content = fix(content);
968 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
969 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
970
971 let warnings_after_fix = lint(&fixed_content);
973 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
974 }
975
976 #[test]
977 fn test_fix_ordered_list_with_different_numbers() {
978 let content = "Text\n1. First\n3. Third\n2. Second\nText";
979 let warnings = lint(content);
980 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
981
982 check_warnings_have_fixes(content);
984
985 let fixed_content = fix(content);
986 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
987 assert_eq!(
988 fixed_content, expected,
989 "Fix should handle ordered lists with non-sequential numbers"
990 );
991
992 let warnings_after_fix = lint(&fixed_content);
994 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
995 }
996
997 #[test]
998 fn test_fix_list_with_code_blocks_inside() {
999 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1000 let warnings = lint(content);
1001 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around list");
1005
1006 check_warnings_have_fixes(content);
1008
1009 let fixed_content = fix(content);
1010 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\n\nText";
1011 assert_eq!(
1012 fixed_content, expected,
1013 "Fix should handle lists with internal code blocks"
1014 );
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_deeply_nested_lists() {
1023 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1024 let warnings = lint(content);
1025 assert_eq!(
1026 warnings.len(),
1027 2,
1028 "Should warn for missing blanks around deeply nested list"
1029 );
1030
1031 check_warnings_have_fixes(content);
1033
1034 let fixed_content = fix(content);
1035 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1036 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1037
1038 let warnings_after_fix = lint(&fixed_content);
1040 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1041 }
1042
1043 #[test]
1044 fn test_fix_list_with_multiline_items() {
1045 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1046 let warnings = lint(content);
1047 assert_eq!(
1048 warnings.len(),
1049 2,
1050 "Should warn for missing blanks around multiline list"
1051 );
1052
1053 check_warnings_have_fixes(content);
1055
1056 let fixed_content = fix(content);
1057 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1058 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1059
1060 let warnings_after_fix = lint(&fixed_content);
1062 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1063 }
1064
1065 #[test]
1066 fn test_fix_list_at_document_boundaries() {
1067 let content1 = "- Item 1\n- Item 2";
1069 let warnings1 = lint(content1);
1070 assert_eq!(
1071 warnings1.len(),
1072 0,
1073 "List at document start should not need blank before"
1074 );
1075 let fixed1 = fix(content1);
1076 assert_eq!(fixed1, content1, "No fix needed for list at start");
1077
1078 let content2 = "Text\n- Item 1\n- Item 2";
1080 let warnings2 = lint(content2);
1081 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1082 check_warnings_have_fixes(content2);
1083 let fixed2 = fix(content2);
1084 assert_eq!(
1085 fixed2, "Text\n\n- Item 1\n- Item 2",
1086 "Should add blank before list at end"
1087 );
1088 }
1089
1090 #[test]
1091 fn test_fix_preserves_existing_blank_lines() {
1092 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1093 let warnings = lint(content);
1094 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1095 let fixed_content = fix(content);
1096 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1097 }
1098
1099 #[test]
1100 fn test_fix_handles_tabs_and_spaces() {
1101 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1102 let warnings = lint(content);
1103 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1104
1105 check_warnings_have_fixes(content);
1107
1108 let fixed_content = fix(content);
1109 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1110 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1111
1112 let warnings_after_fix = lint(&fixed_content);
1114 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1115 }
1116
1117 #[test]
1118 fn test_fix_warning_objects_have_correct_ranges() {
1119 let content = "Text\n- Item 1\n- Item 2\nText";
1120 let warnings = lint(content);
1121 assert_eq!(warnings.len(), 2);
1122
1123 for warning in &warnings {
1125 assert!(warning.fix.is_some(), "Warning should have fix");
1126 let fix = warning.fix.as_ref().unwrap();
1127 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1128 assert!(
1129 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1130 "Fix should have replacement or be insertion"
1131 );
1132 }
1133 }
1134
1135 #[test]
1136 fn test_fix_idempotent() {
1137 let content = "Text\n- Item 1\n- Item 2\nText";
1138
1139 let fixed_once = fix(content);
1141 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1142
1143 let fixed_twice = fix(&fixed_once);
1145 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1146
1147 let warnings_after_fix = lint(&fixed_once);
1149 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1150 }
1151
1152 #[test]
1153 fn test_fix_with_normalized_line_endings() {
1154 let content = "Text\n- Item 1\n- Item 2\nText";
1157 let warnings = lint(content);
1158 assert_eq!(warnings.len(), 2, "Should detect issues with normalized line endings");
1159
1160 check_warnings_have_fixes(content);
1162
1163 let fixed_content = fix(content);
1164 let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1165 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1166 }
1167
1168 #[test]
1169 fn test_fix_preserves_final_newline() {
1170 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1172 let fixed_with_newline = fix(content_with_newline);
1173 assert!(
1174 fixed_with_newline.ends_with('\n'),
1175 "Fix should preserve final newline when present"
1176 );
1177 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1178
1179 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1181 let fixed_without_newline = fix(content_without_newline);
1182 assert!(
1183 !fixed_without_newline.ends_with('\n'),
1184 "Fix should not add final newline when not present"
1185 );
1186 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1187 }
1188
1189 #[test]
1190 fn test_fix_multiline_list_items_no_indent() {
1191 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";
1192
1193 let warnings = lint(content);
1194 assert_eq!(
1196 warnings.len(),
1197 0,
1198 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1199 );
1200
1201 let fixed_content = fix(content);
1202 assert_eq!(
1204 fixed_content, content,
1205 "Should not modify correctly formatted multi-line list items"
1206 );
1207 }
1208}