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