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 lazy_static::lazy_static;
5use regex::Regex;
6
7lazy_static! {
8 static ref BLANK_LINE_RE: Regex = Regex::new(r"^\s*$").unwrap();
9 static ref ORDERED_LIST_NON_ONE_RE: Regex = Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap();
11}
12
13#[derive(Debug, Clone, Default)]
83pub struct MD032BlanksAroundLists {
84 pub allow_after_headings: bool,
86 pub allow_after_colons: bool,
88}
89
90impl MD032BlanksAroundLists {
91 pub fn strict() -> Self {
92 Self {
93 allow_after_headings: false,
94 allow_after_colons: false,
95 }
96 }
97
98 fn should_require_blank_line_before(
100 &self,
101 prev_line: &str,
102 ctx: &crate::lint_context::LintContext,
103 prev_line_num: usize,
104 current_line_num: usize,
105 ) -> bool {
106 let trimmed_prev = prev_line.trim();
107
108 if ctx
110 .line_info(prev_line_num)
111 .is_some_and(|info| info.in_code_block || info.in_front_matter)
112 {
113 return true;
114 }
115
116 if self.is_nested_list(ctx, prev_line_num, current_line_num) {
118 return false;
119 }
120
121 if self.allow_after_headings && self.is_heading_line_from_context(ctx, prev_line_num - 1) {
123 return false;
124 }
125
126 if self.allow_after_colons && trimmed_prev.ends_with(':') {
128 return false;
129 }
130
131 true
133 }
134
135 fn is_heading_line_from_context(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
137 if line_idx < ctx.lines.len() {
138 ctx.lines[line_idx].heading.is_some()
139 } else {
140 false
141 }
142 }
143
144 fn is_nested_list(
146 &self,
147 ctx: &crate::lint_context::LintContext,
148 prev_line_num: usize, current_line_num: usize, ) -> bool {
151 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
153 let current_line = &ctx.lines[current_line_num - 1];
154 if current_line.indent >= 2 {
155 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
157 let prev_line = &ctx.lines[prev_line_num - 1];
158 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
160 return true;
161 }
162 }
163 }
164 }
165 false
166 }
167
168 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
170 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
171
172 for block in &ctx.list_blocks {
173 let mut segments: Vec<(usize, usize)> = Vec::new();
179 let mut current_start = block.start_line;
180 let mut prev_item_line = 0;
181
182 for &item_line in &block.item_lines {
183 if prev_item_line > 0 {
184 let mut has_standalone_code_fence = false;
187
188 let min_indent_for_content = if block.is_ordered {
190 3 } else {
194 2 };
197
198 for check_line in (prev_item_line + 1)..item_line {
199 if check_line - 1 < ctx.lines.len() {
200 let line = &ctx.lines[check_line - 1];
201 if line.in_code_block
202 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
203 {
204 if line.indent < min_indent_for_content {
207 has_standalone_code_fence = true;
208 break;
209 }
210 }
211 }
212 }
213
214 if has_standalone_code_fence {
215 segments.push((current_start, prev_item_line));
217 current_start = item_line;
218 }
219 }
220 prev_item_line = item_line;
221 }
222
223 if prev_item_line > 0 {
226 segments.push((current_start, prev_item_line));
227 }
228
229 let has_code_fence_splits = segments.len() > 1 && {
231 let mut found_fence = false;
233 for i in 0..segments.len() - 1 {
234 let seg_end = segments[i].1;
235 let next_start = segments[i + 1].0;
236 for check_line in (seg_end + 1)..next_start {
238 if check_line - 1 < ctx.lines.len() {
239 let line = &ctx.lines[check_line - 1];
240 if line.in_code_block
241 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
242 {
243 found_fence = true;
244 break;
245 }
246 }
247 }
248 if found_fence {
249 break;
250 }
251 }
252 found_fence
253 };
254
255 for (start, end) in segments.iter() {
257 let mut actual_end = *end;
259
260 if !has_code_fence_splits && *end < block.end_line {
263 for check_line in (*end + 1)..=block.end_line {
264 if check_line - 1 < ctx.lines.len() {
265 let line = &ctx.lines[check_line - 1];
266 if block.item_lines.contains(&check_line) || line.heading.is_some() {
268 break;
269 }
270 if line.in_code_block {
272 break;
273 }
274 if line.indent >= 2 {
276 actual_end = check_line;
277 }
278 else if !line.is_blank
280 && line.heading.is_none()
281 && !block.item_lines.contains(&check_line)
282 {
283 actual_end = check_line;
286 } else if !line.is_blank {
287 break;
289 }
290 }
291 }
292 }
293
294 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
295 }
296 }
297
298 blocks
299 }
300
301 fn perform_checks(
302 &self,
303 ctx: &crate::lint_context::LintContext,
304 lines: &[&str],
305 list_blocks: &[(usize, usize, String)],
306 line_index: &LineIndex,
307 ) -> LintResult {
308 let mut warnings = Vec::new();
309 let num_lines = lines.len();
310
311 for (line_idx, line) in lines.iter().enumerate() {
314 let line_num = line_idx + 1;
315
316 let is_in_list = list_blocks
318 .iter()
319 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
320 if is_in_list {
321 continue;
322 }
323
324 if ctx
326 .line_info(line_num)
327 .is_some_and(|info| info.in_code_block || info.in_front_matter)
328 {
329 continue;
330 }
331
332 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
334 if line_idx > 0 {
336 let prev_line = lines[line_idx - 1];
337 let prev_is_blank = is_blank_in_context(prev_line);
338 let prev_excluded = ctx
339 .line_info(line_idx)
340 .is_some_and(|info| info.in_code_block || info.in_front_matter);
341
342 if !prev_is_blank && !prev_excluded {
343 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
345
346 warnings.push(LintWarning {
347 line: start_line,
348 column: start_col,
349 end_line,
350 end_column: end_col,
351 severity: Severity::Error,
352 rule_name: Some(self.name().to_string()),
353 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
354 fix: Some(Fix {
355 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
356 replacement: "\n".to_string(),
357 }),
358 });
359 }
360 }
361 }
362 }
363
364 for &(start_line, end_line, ref prefix) in list_blocks {
365 if start_line > 1 {
366 let prev_line_actual_idx_0 = start_line - 2;
367 let prev_line_actual_idx_1 = start_line - 1;
368 let prev_line_str = lines[prev_line_actual_idx_0];
369 let is_prev_excluded = ctx
370 .line_info(prev_line_actual_idx_1)
371 .is_some_and(|info| info.in_code_block || info.in_front_matter);
372 let prev_prefix = BLOCKQUOTE_PREFIX_RE
373 .find(prev_line_str)
374 .map_or(String::new(), |m| m.as_str().to_string());
375 let prev_is_blank = is_blank_in_context(prev_line_str);
376 let prefixes_match = prev_prefix.trim() == prefix.trim();
377
378 let should_require =
381 self.should_require_blank_line_before(prev_line_str, ctx, prev_line_actual_idx_1, start_line);
382 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
383 let (start_line, start_col, end_line, end_col) =
385 calculate_line_range(start_line, lines[start_line - 1]);
386
387 warnings.push(LintWarning {
388 line: start_line,
389 column: start_col,
390 end_line,
391 end_column: end_col,
392 severity: Severity::Error,
393 rule_name: Some(self.name().to_string()),
394 message: "List should be preceded by blank line".to_string(),
395 fix: Some(Fix {
396 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
397 replacement: format!("{prefix}\n"),
398 }),
399 });
400 }
401 }
402
403 if end_line < num_lines {
404 let next_line_idx_0 = end_line;
405 let next_line_idx_1 = end_line + 1;
406 let next_line_str = lines[next_line_idx_0];
407 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
410 || (next_line_idx_0 < ctx.lines.len()
411 && ctx.lines[next_line_idx_0].in_code_block
412 && ctx.lines[next_line_idx_0].indent >= 2);
413 let next_prefix = BLOCKQUOTE_PREFIX_RE
414 .find(next_line_str)
415 .map_or(String::new(), |m| m.as_str().to_string());
416 let next_is_blank = is_blank_in_context(next_line_str);
417 let prefixes_match = next_prefix.trim() == prefix.trim();
418
419 if !is_next_excluded && !next_is_blank && prefixes_match {
421 let (start_line_last, start_col_last, end_line_last, end_col_last) =
423 calculate_line_range(end_line, lines[end_line - 1]);
424
425 warnings.push(LintWarning {
426 line: start_line_last,
427 column: start_col_last,
428 end_line: end_line_last,
429 end_column: end_col_last,
430 severity: Severity::Error,
431 rule_name: Some(self.name().to_string()),
432 message: "List should be followed by blank line".to_string(),
433 fix: Some(Fix {
434 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
435 replacement: format!("{prefix}\n"),
436 }),
437 });
438 }
439 }
440 }
441 Ok(warnings)
442 }
443}
444
445impl Rule for MD032BlanksAroundLists {
446 fn name(&self) -> &'static str {
447 "MD032"
448 }
449
450 fn description(&self) -> &'static str {
451 "Lists should be surrounded by blank lines"
452 }
453
454 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
455 let content = ctx.content;
456 let lines: Vec<&str> = content.lines().collect();
457 let line_index = &ctx.line_index;
458
459 if lines.is_empty() {
461 return Ok(Vec::new());
462 }
463
464 let list_blocks = self.convert_list_blocks(ctx);
465
466 if list_blocks.is_empty() {
467 return Ok(Vec::new());
468 }
469
470 self.perform_checks(ctx, &lines, &list_blocks, line_index)
471 }
472
473 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
474 self.fix_with_structure_impl(ctx)
475 }
476
477 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
478 if ctx.content.is_empty() || !ctx.likely_has_lists() {
480 return true;
481 }
482 ctx.list_blocks.is_empty()
484 }
485
486 fn category(&self) -> RuleCategory {
487 RuleCategory::List
488 }
489
490 fn as_any(&self) -> &dyn std::any::Any {
491 self
492 }
493
494 fn default_config_section(&self) -> Option<(String, toml::Value)> {
495 let mut map = toml::map::Map::new();
496 map.insert(
497 "allow_after_headings".to_string(),
498 toml::Value::Boolean(self.allow_after_headings),
499 );
500 map.insert(
501 "allow_after_colons".to_string(),
502 toml::Value::Boolean(self.allow_after_colons),
503 );
504 Some((self.name().to_string(), toml::Value::Table(map)))
505 }
506
507 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
508 where
509 Self: Sized,
510 {
511 let allow_after_headings =
512 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_headings").unwrap_or(false); let allow_after_colons =
515 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_colons").unwrap_or(false); Box::new(MD032BlanksAroundLists {
518 allow_after_headings,
519 allow_after_colons,
520 })
521 }
522}
523
524impl MD032BlanksAroundLists {
525 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
527 let lines: Vec<&str> = ctx.content.lines().collect();
528 let num_lines = lines.len();
529 if num_lines == 0 {
530 return Ok(String::new());
531 }
532
533 let list_blocks = self.convert_list_blocks(ctx);
534 if list_blocks.is_empty() {
535 return Ok(ctx.content.to_string());
536 }
537
538 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
539
540 for &(start_line, end_line, ref prefix) in &list_blocks {
542 if start_line > 1 {
544 let prev_line_actual_idx_0 = start_line - 2;
545 let prev_line_actual_idx_1 = start_line - 1;
546 let is_prev_excluded = ctx
547 .line_info(prev_line_actual_idx_1)
548 .is_some_and(|info| info.in_code_block || info.in_front_matter);
549 let prev_prefix = BLOCKQUOTE_PREFIX_RE
550 .find(lines[prev_line_actual_idx_0])
551 .map_or(String::new(), |m| m.as_str().to_string());
552
553 let should_require = self.should_require_blank_line_before(
554 lines[prev_line_actual_idx_0],
555 ctx,
556 prev_line_actual_idx_1,
557 start_line,
558 );
559 if !is_prev_excluded
560 && !is_blank_in_context(lines[prev_line_actual_idx_0])
561 && prev_prefix == *prefix
562 && should_require
563 {
564 insertions.insert(start_line, prefix.clone());
565 }
566 }
567
568 if end_line < num_lines {
570 let after_block_line_idx_0 = end_line;
571 let after_block_line_idx_1 = end_line + 1;
572 let line_after_block_content_str = lines[after_block_line_idx_0];
573 let is_line_after_excluded = ctx
576 .line_info(after_block_line_idx_1)
577 .is_some_and(|info| info.in_code_block || info.in_front_matter)
578 || (after_block_line_idx_0 < ctx.lines.len()
579 && ctx.lines[after_block_line_idx_0].in_code_block
580 && ctx.lines[after_block_line_idx_0].indent >= 2
581 && (ctx.lines[after_block_line_idx_0].content.trim().starts_with("```")
582 || ctx.lines[after_block_line_idx_0].content.trim().starts_with("~~~")));
583 let after_prefix = BLOCKQUOTE_PREFIX_RE
584 .find(line_after_block_content_str)
585 .map_or(String::new(), |m| m.as_str().to_string());
586
587 if !is_line_after_excluded
588 && !is_blank_in_context(line_after_block_content_str)
589 && after_prefix == *prefix
590 {
591 insertions.insert(after_block_line_idx_1, prefix.clone());
592 }
593 }
594 }
595
596 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
598 for (i, line) in lines.iter().enumerate() {
599 let current_line_num = i + 1;
600 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
601 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
602 {
603 result_lines.push(prefix_to_insert.clone());
604 }
605 result_lines.push(line.to_string());
606 }
607
608 let mut result = result_lines.join("\n");
610 if ctx.content.ends_with('\n') {
611 result.push('\n');
612 }
613 Ok(result)
614 }
615}
616
617fn is_blank_in_context(line: &str) -> bool {
619 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
622 line[m.end()..].trim().is_empty()
624 } else {
625 line.trim().is_empty()
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use crate::lint_context::LintContext;
634 use crate::rule::Rule;
635
636 fn lint(content: &str) -> Vec<LintWarning> {
637 let rule = MD032BlanksAroundLists::default();
638 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
639 rule.check(&ctx).expect("Lint check failed")
640 }
641
642 fn fix(content: &str) -> String {
643 let rule = MD032BlanksAroundLists::default();
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
645 rule.fix(&ctx).expect("Lint fix failed")
646 }
647
648 fn check_warnings_have_fixes(content: &str) {
650 let warnings = lint(content);
651 for warning in &warnings {
652 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
653 }
654 }
655
656 #[test]
657 fn test_list_at_start() {
658 let content = "- Item 1\n- Item 2\nText";
659 let warnings = lint(content);
660 assert_eq!(
661 warnings.len(),
662 1,
663 "Expected 1 warning for list at start without trailing blank line"
664 );
665 assert_eq!(
666 warnings[0].line, 2,
667 "Warning should be on the last line of the list (line 2)"
668 );
669 assert!(warnings[0].message.contains("followed by blank line"));
670
671 check_warnings_have_fixes(content);
673
674 let fixed_content = fix(content);
675 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
676
677 let warnings_after_fix = lint(&fixed_content);
679 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
680 }
681
682 #[test]
683 fn test_list_at_end() {
684 let content = "Text\n- Item 1\n- Item 2";
685 let warnings = lint(content);
686 assert_eq!(
687 warnings.len(),
688 1,
689 "Expected 1 warning for list at end without preceding blank line"
690 );
691 assert_eq!(
692 warnings[0].line, 2,
693 "Warning should be on the first line of the list (line 2)"
694 );
695 assert!(warnings[0].message.contains("preceded by blank line"));
696
697 check_warnings_have_fixes(content);
699
700 let fixed_content = fix(content);
701 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
702
703 let warnings_after_fix = lint(&fixed_content);
705 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
706 }
707
708 #[test]
709 fn test_list_in_middle() {
710 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
711 let warnings = lint(content);
712 assert_eq!(
713 warnings.len(),
714 2,
715 "Expected 2 warnings for list in middle without surrounding blank lines"
716 );
717 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
718 assert!(warnings[0].message.contains("preceded by blank line"));
719 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
720 assert!(warnings[1].message.contains("followed by blank line"));
721
722 check_warnings_have_fixes(content);
724
725 let fixed_content = fix(content);
726 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
727
728 let warnings_after_fix = lint(&fixed_content);
730 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
731 }
732
733 #[test]
734 fn test_correct_spacing() {
735 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
736 let warnings = lint(content);
737 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
738
739 let fixed_content = fix(content);
740 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
741 }
742
743 #[test]
744 fn test_list_with_content() {
745 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
746 let warnings = lint(content);
747 assert_eq!(
748 warnings.len(),
749 2,
750 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
751 );
752 if warnings.len() == 2 {
753 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
754 assert!(warnings[0].message.contains("preceded by blank line"));
755 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
756 assert!(warnings[1].message.contains("followed by blank line"));
757 }
758
759 check_warnings_have_fixes(content);
761
762 let fixed_content = fix(content);
763 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
764 assert_eq!(
765 fixed_content, expected_fixed,
766 "Fix did not produce the expected output. Got:\n{fixed_content}"
767 );
768
769 let warnings_after_fix = lint(&fixed_content);
771 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
772 }
773
774 #[test]
775 fn test_nested_list() {
776 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
777 let warnings = lint(content);
778 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
780 assert_eq!(warnings[0].line, 2);
781 assert_eq!(warnings[1].line, 4);
782 }
783
784 check_warnings_have_fixes(content);
786
787 let fixed_content = fix(content);
788 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
789
790 let warnings_after_fix = lint(&fixed_content);
792 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
793 }
794
795 #[test]
796 fn test_list_with_internal_blanks() {
797 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
798 let warnings = lint(content);
799 assert_eq!(
800 warnings.len(),
801 2,
802 "List with internal blanks warnings. Got: {warnings:?}"
803 );
804 if warnings.len() == 2 {
805 assert_eq!(warnings[0].line, 2);
806 assert_eq!(warnings[1].line, 5); }
808
809 check_warnings_have_fixes(content);
811
812 let fixed_content = fix(content);
813 assert_eq!(
814 fixed_content,
815 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
816 );
817
818 let warnings_after_fix = lint(&fixed_content);
820 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
821 }
822
823 #[test]
824 fn test_ignore_code_blocks() {
825 let content = "```\n- Not a list item\n```\nText";
826 let warnings = lint(content);
827 assert_eq!(warnings.len(), 0);
828 let fixed_content = fix(content);
829 assert_eq!(fixed_content, content);
830 }
831
832 #[test]
833 fn test_ignore_front_matter() {
834 let content = "---\ntitle: Test\n---\n- List Item\nText";
835 let warnings = lint(content);
836 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
837 if !warnings.is_empty() {
838 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
840 }
841
842 check_warnings_have_fixes(content);
844
845 let fixed_content = fix(content);
846 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
847
848 let warnings_after_fix = lint(&fixed_content);
850 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
851 }
852
853 #[test]
854 fn test_multiple_lists() {
855 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
856 let warnings = lint(content);
857 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
858
859 check_warnings_have_fixes(content);
861
862 let fixed_content = fix(content);
863 assert_eq!(
864 fixed_content,
865 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
866 );
867
868 let warnings_after_fix = lint(&fixed_content);
870 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
871 }
872
873 #[test]
874 fn test_adjacent_lists() {
875 let content = "- List 1\n\n* List 2";
876 let warnings = lint(content);
877 assert_eq!(warnings.len(), 0);
878 let fixed_content = fix(content);
879 assert_eq!(fixed_content, content);
880 }
881
882 #[test]
883 fn test_list_in_blockquote() {
884 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
885 let warnings = lint(content);
886 assert_eq!(
887 warnings.len(),
888 2,
889 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
890 );
891 if warnings.len() == 2 {
892 assert_eq!(warnings[0].line, 2);
893 assert_eq!(warnings[1].line, 3);
894 }
895
896 check_warnings_have_fixes(content);
898
899 let fixed_content = fix(content);
900 assert_eq!(
902 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
903 "Fix for blockquoted list failed. Got:\n{fixed_content}"
904 );
905
906 let warnings_after_fix = lint(&fixed_content);
908 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
909 }
910
911 #[test]
912 fn test_ordered_list() {
913 let content = "Text\n1. Item 1\n2. Item 2\nText";
914 let warnings = lint(content);
915 assert_eq!(warnings.len(), 2);
916
917 check_warnings_have_fixes(content);
919
920 let fixed_content = fix(content);
921 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
922
923 let warnings_after_fix = lint(&fixed_content);
925 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
926 }
927
928 #[test]
929 fn test_no_double_blank_fix() {
930 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
932 assert_eq!(warnings.len(), 1);
933 if !warnings.is_empty() {
934 assert_eq!(
935 warnings[0].line, 4,
936 "Warning line for missing blank after should be the last line of the block"
937 );
938 }
939
940 check_warnings_have_fixes(content);
942
943 let fixed_content = fix(content);
944 assert_eq!(
945 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
946 "Fix added extra blank after. Got:\n{fixed_content}"
947 );
948
949 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
951 assert_eq!(warnings2.len(), 1);
952 if !warnings2.is_empty() {
953 assert_eq!(
954 warnings2[0].line, 2,
955 "Warning line for missing blank before should be the first line of the block"
956 );
957 }
958
959 check_warnings_have_fixes(content2);
961
962 let fixed_content2 = fix(content2);
963 assert_eq!(
964 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
965 "Fix added extra blank before. Got:\n{fixed_content2}"
966 );
967 }
968
969 #[test]
970 fn test_empty_input() {
971 let content = "";
972 let warnings = lint(content);
973 assert_eq!(warnings.len(), 0);
974 let fixed_content = fix(content);
975 assert_eq!(fixed_content, "");
976 }
977
978 #[test]
979 fn test_only_list() {
980 let content = "- Item 1\n- Item 2";
981 let warnings = lint(content);
982 assert_eq!(warnings.len(), 0);
983 let fixed_content = fix(content);
984 assert_eq!(fixed_content, content);
985 }
986
987 #[test]
990 fn test_fix_complex_nested_blockquote() {
991 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
992 let warnings = lint(content);
993 assert_eq!(
997 warnings.len(),
998 2,
999 "Should warn for missing blanks around the entire list block"
1000 );
1001
1002 check_warnings_have_fixes(content);
1004
1005 let fixed_content = fix(content);
1006 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
1007 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1008
1009 let warnings_after_fix = lint(&fixed_content);
1011 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1012 }
1013
1014 #[test]
1015 fn test_fix_mixed_list_markers() {
1016 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1017 let warnings = lint(content);
1018 assert_eq!(
1019 warnings.len(),
1020 2,
1021 "Should warn for missing blanks around mixed marker list"
1022 );
1023
1024 check_warnings_have_fixes(content);
1026
1027 let fixed_content = fix(content);
1028 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
1029 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
1030
1031 let warnings_after_fix = lint(&fixed_content);
1033 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1034 }
1035
1036 #[test]
1037 fn test_fix_ordered_list_with_different_numbers() {
1038 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1039 let warnings = lint(content);
1040 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1041
1042 check_warnings_have_fixes(content);
1044
1045 let fixed_content = fix(content);
1046 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1047 assert_eq!(
1048 fixed_content, expected,
1049 "Fix should handle ordered lists with non-sequential numbers"
1050 );
1051
1052 let warnings_after_fix = lint(&fixed_content);
1054 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1055 }
1056
1057 #[test]
1058 fn test_fix_list_with_code_blocks_inside() {
1059 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1060 let warnings = lint(content);
1061 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around list");
1065
1066 check_warnings_have_fixes(content);
1068
1069 let fixed_content = fix(content);
1070 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\n\nText";
1071 assert_eq!(
1072 fixed_content, expected,
1073 "Fix should handle lists with internal code blocks"
1074 );
1075
1076 let warnings_after_fix = lint(&fixed_content);
1078 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1079 }
1080
1081 #[test]
1082 fn test_fix_deeply_nested_lists() {
1083 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1084 let warnings = lint(content);
1085 assert_eq!(
1086 warnings.len(),
1087 2,
1088 "Should warn for missing blanks around deeply nested list"
1089 );
1090
1091 check_warnings_have_fixes(content);
1093
1094 let fixed_content = fix(content);
1095 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1096 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1097
1098 let warnings_after_fix = lint(&fixed_content);
1100 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1101 }
1102
1103 #[test]
1104 fn test_fix_list_with_multiline_items() {
1105 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1106 let warnings = lint(content);
1107 assert_eq!(
1108 warnings.len(),
1109 2,
1110 "Should warn for missing blanks around multiline list"
1111 );
1112
1113 check_warnings_have_fixes(content);
1115
1116 let fixed_content = fix(content);
1117 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1118 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1119
1120 let warnings_after_fix = lint(&fixed_content);
1122 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1123 }
1124
1125 #[test]
1126 fn test_fix_list_at_document_boundaries() {
1127 let content1 = "- Item 1\n- Item 2";
1129 let warnings1 = lint(content1);
1130 assert_eq!(
1131 warnings1.len(),
1132 0,
1133 "List at document start should not need blank before"
1134 );
1135 let fixed1 = fix(content1);
1136 assert_eq!(fixed1, content1, "No fix needed for list at start");
1137
1138 let content2 = "Text\n- Item 1\n- Item 2";
1140 let warnings2 = lint(content2);
1141 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1142 check_warnings_have_fixes(content2);
1143 let fixed2 = fix(content2);
1144 assert_eq!(
1145 fixed2, "Text\n\n- Item 1\n- Item 2",
1146 "Should add blank before list at end"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_fix_preserves_existing_blank_lines() {
1152 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1153 let warnings = lint(content);
1154 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1155 let fixed_content = fix(content);
1156 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1157 }
1158
1159 #[test]
1160 fn test_fix_handles_tabs_and_spaces() {
1161 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1162 let warnings = lint(content);
1163 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1164
1165 check_warnings_have_fixes(content);
1167
1168 let fixed_content = fix(content);
1169 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1170 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1171
1172 let warnings_after_fix = lint(&fixed_content);
1174 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1175 }
1176
1177 #[test]
1178 fn test_fix_warning_objects_have_correct_ranges() {
1179 let content = "Text\n- Item 1\n- Item 2\nText";
1180 let warnings = lint(content);
1181 assert_eq!(warnings.len(), 2);
1182
1183 for warning in &warnings {
1185 assert!(warning.fix.is_some(), "Warning should have fix");
1186 let fix = warning.fix.as_ref().unwrap();
1187 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1188 assert!(
1189 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1190 "Fix should have replacement or be insertion"
1191 );
1192 }
1193 }
1194
1195 #[test]
1196 fn test_fix_idempotent() {
1197 let content = "Text\n- Item 1\n- Item 2\nText";
1198
1199 let fixed_once = fix(content);
1201 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1202
1203 let fixed_twice = fix(&fixed_once);
1205 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1206
1207 let warnings_after_fix = lint(&fixed_once);
1209 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1210 }
1211
1212 #[test]
1213 fn test_fix_with_normalized_line_endings() {
1214 let content = "Text\n- Item 1\n- Item 2\nText";
1217 let warnings = lint(content);
1218 assert_eq!(warnings.len(), 2, "Should detect issues with normalized line endings");
1219
1220 check_warnings_have_fixes(content);
1222
1223 let fixed_content = fix(content);
1224 let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1225 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1226 }
1227
1228 #[test]
1229 fn test_fix_preserves_final_newline() {
1230 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1232 let fixed_with_newline = fix(content_with_newline);
1233 assert!(
1234 fixed_with_newline.ends_with('\n'),
1235 "Fix should preserve final newline when present"
1236 );
1237 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1238
1239 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1241 let fixed_without_newline = fix(content_without_newline);
1242 assert!(
1243 !fixed_without_newline.ends_with('\n'),
1244 "Fix should not add final newline when not present"
1245 );
1246 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1247 }
1248
1249 #[test]
1250 fn test_fix_multiline_list_items_no_indent() {
1251 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";
1252
1253 let warnings = lint(content);
1254 assert_eq!(
1256 warnings.len(),
1257 0,
1258 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1259 );
1260
1261 let fixed_content = fix(content);
1262 assert_eq!(
1264 fixed_content, content,
1265 "Should not modify correctly formatted multi-line list items"
1266 );
1267 }
1268}