1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::document_structure::document_structure_from_str;
3use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
4use crate::utils::range_utils::{LineIndex, calculate_line_range};
5use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
6use lazy_static::lazy_static;
7use regex::Regex;
8
9lazy_static! {
10 static ref BLANK_LINE_RE: Regex = Regex::new(r"^\s*$").unwrap();
11}
12
13#[derive(Debug, Clone)]
83pub struct MD032BlanksAroundLists {
84 pub allow_after_headings: bool,
86 pub allow_after_colons: bool,
88}
89
90impl Default for MD032BlanksAroundLists {
91 fn default() -> Self {
92 Self {
93 allow_after_headings: true, allow_after_colons: true,
95 }
96 }
97}
98
99impl MD032BlanksAroundLists {
100 pub fn strict() -> Self {
101 Self {
102 allow_after_headings: false,
103 allow_after_colons: false,
104 }
105 }
106
107 fn should_require_blank_line_before(
109 &self,
110 prev_line: &str,
111 ctx: &crate::lint_context::LintContext,
112 structure: &DocumentStructure,
113 prev_line_num: usize,
114 current_line_num: usize,
115 ) -> bool {
116 let trimmed_prev = prev_line.trim();
117
118 if structure.is_in_code_block(prev_line_num) || structure.is_in_front_matter(prev_line_num) {
120 return true;
121 }
122
123 if self.is_nested_list(ctx, prev_line_num, current_line_num) {
125 return false;
126 }
127
128 if self.allow_after_headings && self.is_heading_line_from_context(ctx, prev_line_num - 1) {
130 return false;
131 }
132
133 if self.allow_after_colons && trimmed_prev.ends_with(':') {
135 return false;
136 }
137
138 true
140 }
141
142 fn is_heading_line_from_context(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
144 if line_idx < ctx.lines.len() {
145 ctx.lines[line_idx].heading.is_some()
146 } else {
147 false
148 }
149 }
150
151 fn is_nested_list(
153 &self,
154 ctx: &crate::lint_context::LintContext,
155 prev_line_num: usize, current_line_num: usize, ) -> bool {
158 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
160 let current_line = &ctx.lines[current_line_num - 1];
161 if current_line.indent >= 2 {
162 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
164 let prev_line = &ctx.lines[prev_line_num - 1];
165 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
167 return true;
168 }
169 }
170 }
171 }
172 false
173 }
174
175 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
177 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
178
179 for block in &ctx.list_blocks {
180 let mut segments: Vec<(usize, usize)> = Vec::new();
186 let mut current_start = block.start_line;
187 let mut prev_item_line = 0;
188
189 for &item_line in &block.item_lines {
190 if prev_item_line > 0 {
191 let mut has_code_fence = false;
193 for check_line in (prev_item_line + 1)..item_line {
194 if check_line - 1 < ctx.lines.len() {
195 let line = &ctx.lines[check_line - 1];
196 if line.in_code_block
197 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
198 {
199 has_code_fence = true;
200 break;
201 }
202 }
203 }
204
205 if has_code_fence {
206 segments.push((current_start, prev_item_line));
208 current_start = item_line;
209 }
210 }
211 prev_item_line = item_line;
212 }
213
214 if prev_item_line > 0 {
217 segments.push((current_start, prev_item_line));
218 }
219
220 let has_code_fence_splits = segments.len() > 1 && {
222 let mut found_fence = false;
224 for i in 0..segments.len() - 1 {
225 let seg_end = segments[i].1;
226 let next_start = segments[i + 1].0;
227 for check_line in (seg_end + 1)..next_start {
229 if check_line - 1 < ctx.lines.len() {
230 let line = &ctx.lines[check_line - 1];
231 if line.in_code_block
232 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
233 {
234 found_fence = true;
235 break;
236 }
237 }
238 }
239 if found_fence {
240 break;
241 }
242 }
243 found_fence
244 };
245
246 for (start, end) in segments.iter() {
248 let mut actual_end = *end;
250
251 if !has_code_fence_splits && *end < block.end_line {
254 for check_line in (*end + 1)..=block.end_line {
255 if check_line - 1 < ctx.lines.len() {
256 let line = &ctx.lines[check_line - 1];
257 if block.item_lines.contains(&check_line) || line.heading.is_some() {
259 break;
260 }
261 if line.in_code_block {
263 break;
264 }
265 if line.indent >= 2 {
267 actual_end = check_line;
268 }
269 else if check_line == *end + 1 && !line.is_blank && line.heading.is_none() {
271 let is_likely_new_paragraph = {
275 let first_char = line.content.trim().chars().next();
276 first_char.is_some_and(|c| c.is_uppercase())
277 };
278
279 if !is_likely_new_paragraph {
280 actual_end = check_line;
281 } else {
282 break;
283 }
284 } else if !line.is_blank {
285 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 structure: &DocumentStructure,
302 lines: &[&str],
303 list_blocks: &[(usize, usize, String)],
304 line_index: &LineIndex,
305 ) -> LintResult {
306 let mut warnings = Vec::new();
307 let num_lines = lines.len();
308
309 for &(start_line, end_line, ref prefix) in list_blocks {
310 if start_line > 1 {
311 let prev_line_actual_idx_0 = start_line - 2;
312 let prev_line_actual_idx_1 = start_line - 1;
313 let prev_line_str = lines[prev_line_actual_idx_0];
314 let is_prev_excluded = structure.is_in_code_block(prev_line_actual_idx_1)
315 || structure.is_in_front_matter(prev_line_actual_idx_1);
316 let prev_prefix = BLOCKQUOTE_PREFIX_RE
317 .find(prev_line_str)
318 .map_or(String::new(), |m| m.as_str().to_string());
319 let prev_is_blank = is_blank_in_context(prev_line_str);
320 let prefixes_match = prev_prefix.trim() == prefix.trim();
321
322 let should_require = self.should_require_blank_line_before(
325 prev_line_str,
326 ctx,
327 structure,
328 prev_line_actual_idx_1,
329 start_line,
330 );
331 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
332 let (start_line, start_col, end_line, end_col) =
334 calculate_line_range(start_line, lines[start_line - 1]);
335
336 warnings.push(LintWarning {
337 line: start_line,
338 column: start_col,
339 end_line,
340 end_column: end_col,
341 severity: Severity::Error,
342 rule_name: Some(self.name()),
343 message: "List should be preceded by blank line".to_string(),
344 fix: Some(Fix {
345 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
346 replacement: format!("{prefix}\n"),
347 }),
348 });
349 }
350 }
351
352 if end_line < num_lines {
353 let next_line_idx_0 = end_line;
354 let next_line_idx_1 = end_line + 1;
355 let next_line_str = lines[next_line_idx_0];
356 let is_next_excluded = structure.is_in_code_block(next_line_idx_1)
359 || structure.is_in_front_matter(next_line_idx_1)
360 || (next_line_idx_0 < ctx.lines.len()
361 && ctx.lines[next_line_idx_0].in_code_block
362 && ctx.lines[next_line_idx_0].indent >= 2
363 && (ctx.lines[next_line_idx_0].content.trim().starts_with("```")
364 || ctx.lines[next_line_idx_0].content.trim().starts_with("~~~")));
365 let next_prefix = BLOCKQUOTE_PREFIX_RE
366 .find(next_line_str)
367 .map_or(String::new(), |m| m.as_str().to_string());
368 let next_is_blank = is_blank_in_context(next_line_str);
369 let prefixes_match = next_prefix.trim() == prefix.trim();
370
371 if !is_next_excluded && !next_is_blank && prefixes_match {
373 let (start_line_last, start_col_last, end_line_last, end_col_last) =
375 calculate_line_range(end_line, lines[end_line - 1]);
376
377 warnings.push(LintWarning {
378 line: start_line_last,
379 column: start_col_last,
380 end_line: end_line_last,
381 end_column: end_col_last,
382 severity: Severity::Error,
383 rule_name: Some(self.name()),
384 message: "List should be followed by blank line".to_string(),
385 fix: Some(Fix {
386 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
387 replacement: format!("{prefix}\n"),
388 }),
389 });
390 }
391 }
392 }
393 Ok(warnings)
394 }
395}
396
397impl Rule for MD032BlanksAroundLists {
398 fn name(&self) -> &'static str {
399 "MD032"
400 }
401
402 fn description(&self) -> &'static str {
403 "Lists should be surrounded by blank lines"
404 }
405
406 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
407 let structure = document_structure_from_str(ctx.content);
410 self.check_with_structure(ctx, &structure)
411 }
412
413 fn check_with_structure(
415 &self,
416 ctx: &crate::lint_context::LintContext,
417 structure: &DocumentStructure,
418 ) -> LintResult {
419 let content = ctx.content;
420 let lines: Vec<&str> = content.lines().collect();
421 let line_index = LineIndex::new(content.to_string());
422
423 if lines.is_empty() {
425 return Ok(Vec::new());
426 }
427
428 let list_blocks = self.convert_list_blocks(ctx);
429
430 if list_blocks.is_empty() {
431 return Ok(Vec::new());
432 }
433
434 self.perform_checks(ctx, structure, &lines, &list_blocks, &line_index)
435 }
436
437 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
438 let structure = document_structure_from_str(ctx.content);
440 self.fix_with_structure(ctx, &structure)
441 }
442
443 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
444 ctx.content.is_empty() || 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 default_config_section(&self) -> Option<(String, toml::Value)> {
456 let mut map = toml::map::Map::new();
457 map.insert(
458 "allow_after_headings".to_string(),
459 toml::Value::Boolean(self.allow_after_headings),
460 );
461 map.insert(
462 "allow_after_colons".to_string(),
463 toml::Value::Boolean(self.allow_after_colons),
464 );
465 Some((self.name().to_string(), toml::Value::Table(map)))
466 }
467
468 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
469 where
470 Self: Sized,
471 {
472 let allow_after_headings =
473 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_headings").unwrap_or(true); let allow_after_colons =
476 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_colons").unwrap_or(true);
477
478 Box::new(MD032BlanksAroundLists {
479 allow_after_headings,
480 allow_after_colons,
481 })
482 }
483
484 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
485 Some(self)
486 }
487}
488
489impl MD032BlanksAroundLists {
490 fn fix_with_structure(
492 &self,
493 ctx: &crate::lint_context::LintContext,
494 structure: &DocumentStructure,
495 ) -> Result<String, LintError> {
496 let lines: Vec<&str> = ctx.content.lines().collect();
497 let num_lines = lines.len();
498 if num_lines == 0 {
499 return Ok(String::new());
500 }
501
502 let list_blocks = self.convert_list_blocks(ctx);
503 if list_blocks.is_empty() {
504 return Ok(ctx.content.to_string());
505 }
506
507 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
508
509 for &(start_line, end_line, ref prefix) in &list_blocks {
511 if start_line > 1 {
513 let prev_line_actual_idx_0 = start_line - 2;
514 let prev_line_actual_idx_1 = start_line - 1;
515 let is_prev_excluded = structure.is_in_code_block(prev_line_actual_idx_1)
516 || structure.is_in_front_matter(prev_line_actual_idx_1);
517 let prev_prefix = BLOCKQUOTE_PREFIX_RE
518 .find(lines[prev_line_actual_idx_0])
519 .map_or(String::new(), |m| m.as_str().to_string());
520
521 let should_require = self.should_require_blank_line_before(
522 lines[prev_line_actual_idx_0],
523 ctx,
524 structure,
525 prev_line_actual_idx_1,
526 start_line,
527 );
528 if !is_prev_excluded
529 && !is_blank_in_context(lines[prev_line_actual_idx_0])
530 && prev_prefix == *prefix
531 && should_require
532 {
533 insertions.insert(start_line, prefix.clone());
534 }
535 }
536
537 if end_line < num_lines {
539 let after_block_line_idx_0 = end_line;
540 let after_block_line_idx_1 = end_line + 1;
541 let line_after_block_content_str = lines[after_block_line_idx_0];
542 let is_line_after_excluded = structure.is_in_code_block(after_block_line_idx_1)
545 || structure.is_in_front_matter(after_block_line_idx_1)
546 || (after_block_line_idx_0 < ctx.lines.len()
547 && ctx.lines[after_block_line_idx_0].in_code_block
548 && ctx.lines[after_block_line_idx_0].indent >= 2
549 && (ctx.lines[after_block_line_idx_0].content.trim().starts_with("```")
550 || ctx.lines[after_block_line_idx_0].content.trim().starts_with("~~~")));
551 let after_prefix = BLOCKQUOTE_PREFIX_RE
552 .find(line_after_block_content_str)
553 .map_or(String::new(), |m| m.as_str().to_string());
554
555 if !is_line_after_excluded
556 && !is_blank_in_context(line_after_block_content_str)
557 && after_prefix == *prefix
558 {
559 insertions.insert(after_block_line_idx_1, prefix.clone());
560 }
561 }
562 }
563
564 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
566 for (i, line) in lines.iter().enumerate() {
567 let current_line_num = i + 1;
568 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
569 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
570 {
571 result_lines.push(prefix_to_insert.clone());
572 }
573 result_lines.push(line.to_string());
574 }
575
576 let mut result = result_lines.join("\n");
578 if ctx.content.ends_with('\n') {
579 result.push('\n');
580 }
581 Ok(result)
582 }
583}
584
585impl DocumentStructureExtensions for MD032BlanksAroundLists {
586 fn has_relevant_elements(
587 &self,
588 ctx: &crate::lint_context::LintContext,
589 _doc_structure: &DocumentStructure,
590 ) -> bool {
591 let content = ctx.content;
592
593 if content.is_empty() {
595 return false;
596 }
597
598 if !content.contains('-')
600 && !content.contains('*')
601 && !content.contains('+')
602 && !content.chars().any(|c| c.is_numeric())
603 {
604 return false;
605 }
606
607 !ctx.list_blocks.is_empty()
609 }
610}
611
612fn is_blank_in_context(line: &str) -> bool {
614 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
617 line[m.end()..].trim().is_empty()
619 } else {
620 line.trim().is_empty()
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::lint_context::LintContext;
629 use crate::rule::Rule;
630
631 fn lint(content: &str) -> Vec<LintWarning> {
632 let rule = MD032BlanksAroundLists::default();
633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
634 rule.check(&ctx).expect("Lint check failed")
635 }
636
637 fn fix(content: &str) -> String {
638 let rule = MD032BlanksAroundLists::default();
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
640 rule.fix(&ctx).expect("Lint fix failed")
641 }
642
643 fn check_warnings_have_fixes(content: &str) {
645 let warnings = lint(content);
646 for warning in &warnings {
647 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
648 }
649 }
650
651 #[test]
652 fn test_list_at_start() {
653 let content = "- Item 1\n- Item 2\nText";
654 let warnings = lint(content);
655 assert_eq!(
656 warnings.len(),
657 1,
658 "Expected 1 warning for list at start without trailing blank line"
659 );
660 assert_eq!(
661 warnings[0].line, 2,
662 "Warning should be on the last line of the list (line 2)"
663 );
664 assert!(warnings[0].message.contains("followed by blank line"));
665
666 check_warnings_have_fixes(content);
668
669 let fixed_content = fix(content);
670 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
671
672 let warnings_after_fix = lint(&fixed_content);
674 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
675 }
676
677 #[test]
678 fn test_list_at_end() {
679 let content = "Text\n- Item 1\n- Item 2";
680 let warnings = lint(content);
681 assert_eq!(
682 warnings.len(),
683 1,
684 "Expected 1 warning for list at end without preceding blank line"
685 );
686 assert_eq!(
687 warnings[0].line, 2,
688 "Warning should be on the first line of the list (line 2)"
689 );
690 assert!(warnings[0].message.contains("preceded by blank line"));
691
692 check_warnings_have_fixes(content);
694
695 let fixed_content = fix(content);
696 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
697
698 let warnings_after_fix = lint(&fixed_content);
700 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
701 }
702
703 #[test]
704 fn test_list_in_middle() {
705 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
706 let warnings = lint(content);
707 assert_eq!(
708 warnings.len(),
709 2,
710 "Expected 2 warnings for list in middle without surrounding blank lines"
711 );
712 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
713 assert!(warnings[0].message.contains("preceded by blank line"));
714 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
715 assert!(warnings[1].message.contains("followed by blank line"));
716
717 check_warnings_have_fixes(content);
719
720 let fixed_content = fix(content);
721 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
722
723 let warnings_after_fix = lint(&fixed_content);
725 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
726 }
727
728 #[test]
729 fn test_correct_spacing() {
730 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
731 let warnings = lint(content);
732 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
733
734 let fixed_content = fix(content);
735 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
736 }
737
738 #[test]
739 fn test_list_with_content() {
740 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
741 let warnings = lint(content);
742 assert_eq!(
743 warnings.len(),
744 2,
745 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
746 );
747 if warnings.len() == 2 {
748 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
749 assert!(warnings[0].message.contains("preceded by blank line"));
750 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
751 assert!(warnings[1].message.contains("followed by blank line"));
752 }
753
754 check_warnings_have_fixes(content);
756
757 let fixed_content = fix(content);
758 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
759 assert_eq!(
760 fixed_content, expected_fixed,
761 "Fix did not produce the expected output. Got:\n{fixed_content}"
762 );
763
764 let warnings_after_fix = lint(&fixed_content);
766 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
767 }
768
769 #[test]
770 fn test_nested_list() {
771 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
772 let warnings = lint(content);
773 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
775 assert_eq!(warnings[0].line, 2);
776 assert_eq!(warnings[1].line, 4);
777 }
778
779 check_warnings_have_fixes(content);
781
782 let fixed_content = fix(content);
783 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
784
785 let warnings_after_fix = lint(&fixed_content);
787 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
788 }
789
790 #[test]
791 fn test_list_with_internal_blanks() {
792 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
793 let warnings = lint(content);
794 assert_eq!(
795 warnings.len(),
796 2,
797 "List with internal blanks warnings. Got: {warnings:?}"
798 );
799 if warnings.len() == 2 {
800 assert_eq!(warnings[0].line, 2);
801 assert_eq!(warnings[1].line, 5); }
803
804 check_warnings_have_fixes(content);
806
807 let fixed_content = fix(content);
808 assert_eq!(
809 fixed_content,
810 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
811 );
812
813 let warnings_after_fix = lint(&fixed_content);
815 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
816 }
817
818 #[test]
819 fn test_ignore_code_blocks() {
820 let content = "```\n- Not a list item\n```\nText";
821 let warnings = lint(content);
822 assert_eq!(warnings.len(), 0);
823 let fixed_content = fix(content);
824 assert_eq!(fixed_content, content);
825 }
826
827 #[test]
828 fn test_ignore_front_matter() {
829 let content = "---\ntitle: Test\n---\n- List Item\nText";
830 let warnings = lint(content);
831 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
832 if !warnings.is_empty() {
833 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
835 }
836
837 check_warnings_have_fixes(content);
839
840 let fixed_content = fix(content);
841 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
842
843 let warnings_after_fix = lint(&fixed_content);
845 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
846 }
847
848 #[test]
849 fn test_multiple_lists() {
850 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
851 let warnings = lint(content);
852 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
853
854 check_warnings_have_fixes(content);
856
857 let fixed_content = fix(content);
858 assert_eq!(
859 fixed_content,
860 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
861 );
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_adjacent_lists() {
870 let content = "- List 1\n\n* List 2";
871 let warnings = lint(content);
872 assert_eq!(warnings.len(), 0);
873 let fixed_content = fix(content);
874 assert_eq!(fixed_content, content);
875 }
876
877 #[test]
878 fn test_list_in_blockquote() {
879 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
880 let warnings = lint(content);
881 assert_eq!(
882 warnings.len(),
883 2,
884 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
885 );
886 if warnings.len() == 2 {
887 assert_eq!(warnings[0].line, 2);
888 assert_eq!(warnings[1].line, 3);
889 }
890
891 check_warnings_have_fixes(content);
893
894 let fixed_content = fix(content);
895 assert_eq!(
897 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
898 "Fix for blockquoted list failed. Got:\n{fixed_content}"
899 );
900
901 let warnings_after_fix = lint(&fixed_content);
903 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
904 }
905
906 #[test]
907 fn test_ordered_list() {
908 let content = "Text\n1. Item 1\n2. Item 2\nText";
909 let warnings = lint(content);
910 assert_eq!(warnings.len(), 2);
911
912 check_warnings_have_fixes(content);
914
915 let fixed_content = fix(content);
916 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
917
918 let warnings_after_fix = lint(&fixed_content);
920 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
921 }
922
923 #[test]
924 fn test_no_double_blank_fix() {
925 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
927 assert_eq!(warnings.len(), 1);
928 if !warnings.is_empty() {
929 assert_eq!(
930 warnings[0].line, 4,
931 "Warning line for missing blank after should be the last line of the block"
932 );
933 }
934
935 check_warnings_have_fixes(content);
937
938 let fixed_content = fix(content);
939 assert_eq!(
940 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
941 "Fix added extra blank after. Got:\n{fixed_content}"
942 );
943
944 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
946 assert_eq!(warnings2.len(), 1);
947 if !warnings2.is_empty() {
948 assert_eq!(
949 warnings2[0].line, 2,
950 "Warning line for missing blank before should be the first line of the block"
951 );
952 }
953
954 check_warnings_have_fixes(content2);
956
957 let fixed_content2 = fix(content2);
958 assert_eq!(
959 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
960 "Fix added extra blank before. Got:\n{fixed_content2}"
961 );
962 }
963
964 #[test]
965 fn test_empty_input() {
966 let content = "";
967 let warnings = lint(content);
968 assert_eq!(warnings.len(), 0);
969 let fixed_content = fix(content);
970 assert_eq!(fixed_content, "");
971 }
972
973 #[test]
974 fn test_only_list() {
975 let content = "- Item 1\n- Item 2";
976 let warnings = lint(content);
977 assert_eq!(warnings.len(), 0);
978 let fixed_content = fix(content);
979 assert_eq!(fixed_content, content);
980 }
981
982 #[test]
985 fn test_fix_complex_nested_blockquote() {
986 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
987 let warnings = lint(content);
988 assert_eq!(
991 warnings.len(),
992 6,
993 "Should warn for missing blanks around each blockquoted list item"
994 );
995
996 check_warnings_have_fixes(content);
998
999 let fixed_content = fix(content);
1000 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
1001 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1002
1003 let warnings_after_fix = lint(&fixed_content);
1007 assert!(
1008 warnings_after_fix.len() < warnings.len(),
1009 "Fix should reduce the number of warnings"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_fix_mixed_list_markers() {
1015 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1016 let warnings = lint(content);
1017 assert_eq!(
1018 warnings.len(),
1019 2,
1020 "Should warn for missing blanks around mixed marker list"
1021 );
1022
1023 check_warnings_have_fixes(content);
1025
1026 let fixed_content = fix(content);
1027 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
1028 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
1029
1030 let warnings_after_fix = lint(&fixed_content);
1032 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1033 }
1034
1035 #[test]
1036 fn test_fix_ordered_list_with_different_numbers() {
1037 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1038 let warnings = lint(content);
1039 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1040
1041 check_warnings_have_fixes(content);
1043
1044 let fixed_content = fix(content);
1045 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1046 assert_eq!(
1047 fixed_content, expected,
1048 "Fix should handle ordered lists with non-sequential numbers"
1049 );
1050
1051 let warnings_after_fix = lint(&fixed_content);
1053 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1054 }
1055
1056 #[test]
1057 fn test_fix_list_with_code_blocks_inside() {
1058 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1059 let warnings = lint(content);
1060 assert_eq!(
1063 warnings.len(),
1064 3,
1065 "Should warn for missing blanks around list items separated by code blocks"
1066 );
1067
1068 check_warnings_have_fixes(content);
1070
1071 let fixed_content = fix(content);
1072 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n\n- Item 2\n\nText";
1073 assert_eq!(
1074 fixed_content, expected,
1075 "Fix should handle lists with internal code blocks"
1076 );
1077
1078 let warnings_after_fix = lint(&fixed_content);
1080 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1081 }
1082
1083 #[test]
1084 fn test_fix_deeply_nested_lists() {
1085 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1086 let warnings = lint(content);
1087 assert_eq!(
1088 warnings.len(),
1089 2,
1090 "Should warn for missing blanks around deeply nested list"
1091 );
1092
1093 check_warnings_have_fixes(content);
1095
1096 let fixed_content = fix(content);
1097 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1098 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1099
1100 let warnings_after_fix = lint(&fixed_content);
1102 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1103 }
1104
1105 #[test]
1106 fn test_fix_list_with_multiline_items() {
1107 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1108 let warnings = lint(content);
1109 assert_eq!(
1110 warnings.len(),
1111 2,
1112 "Should warn for missing blanks around multiline list"
1113 );
1114
1115 check_warnings_have_fixes(content);
1117
1118 let fixed_content = fix(content);
1119 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1120 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1121
1122 let warnings_after_fix = lint(&fixed_content);
1124 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1125 }
1126
1127 #[test]
1128 fn test_fix_list_at_document_boundaries() {
1129 let content1 = "- Item 1\n- Item 2";
1131 let warnings1 = lint(content1);
1132 assert_eq!(
1133 warnings1.len(),
1134 0,
1135 "List at document start should not need blank before"
1136 );
1137 let fixed1 = fix(content1);
1138 assert_eq!(fixed1, content1, "No fix needed for list at start");
1139
1140 let content2 = "Text\n- Item 1\n- Item 2";
1142 let warnings2 = lint(content2);
1143 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1144 check_warnings_have_fixes(content2);
1145 let fixed2 = fix(content2);
1146 assert_eq!(
1147 fixed2, "Text\n\n- Item 1\n- Item 2",
1148 "Should add blank before list at end"
1149 );
1150 }
1151
1152 #[test]
1153 fn test_fix_preserves_existing_blank_lines() {
1154 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1155 let warnings = lint(content);
1156 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1157 let fixed_content = fix(content);
1158 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1159 }
1160
1161 #[test]
1162 fn test_fix_handles_tabs_and_spaces() {
1163 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1164 let warnings = lint(content);
1165 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1166
1167 check_warnings_have_fixes(content);
1169
1170 let fixed_content = fix(content);
1171 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1172 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1173
1174 let warnings_after_fix = lint(&fixed_content);
1176 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1177 }
1178
1179 #[test]
1180 fn test_fix_warning_objects_have_correct_ranges() {
1181 let content = "Text\n- Item 1\n- Item 2\nText";
1182 let warnings = lint(content);
1183 assert_eq!(warnings.len(), 2);
1184
1185 for warning in &warnings {
1187 assert!(warning.fix.is_some(), "Warning should have fix");
1188 let fix = warning.fix.as_ref().unwrap();
1189 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1190 assert!(
1191 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1192 "Fix should have replacement or be insertion"
1193 );
1194 }
1195 }
1196
1197 #[test]
1198 fn test_fix_idempotent() {
1199 let content = "Text\n- Item 1\n- Item 2\nText";
1200
1201 let fixed_once = fix(content);
1203 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1204
1205 let fixed_twice = fix(&fixed_once);
1207 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1208
1209 let warnings_after_fix = lint(&fixed_once);
1211 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1212 }
1213
1214 #[test]
1215 fn test_fix_with_windows_line_endings() {
1216 let content = "Text\r\n- Item 1\r\n- Item 2\r\nText";
1217 let warnings = lint(content);
1218 assert_eq!(warnings.len(), 2, "Should detect issues with Windows 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";
1226 assert_eq!(fixed_content, expected, "Fix should handle Windows line endings");
1227 }
1228
1229 #[test]
1230 fn test_fix_preserves_final_newline() {
1231 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1233 let fixed_with_newline = fix(content_with_newline);
1234 assert!(
1235 fixed_with_newline.ends_with('\n'),
1236 "Fix should preserve final newline when present"
1237 );
1238 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1239
1240 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1242 let fixed_without_newline = fix(content_without_newline);
1243 assert!(
1244 !fixed_without_newline.ends_with('\n'),
1245 "Fix should not add final newline when not present"
1246 );
1247 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1248 }
1249
1250 #[test]
1251 fn test_fix_multiline_list_items_no_indent() {
1252 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";
1253
1254 let warnings = lint(content);
1255 assert_eq!(
1257 warnings.len(),
1258 0,
1259 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1260 );
1261
1262 let fixed_content = fix(content);
1263 assert_eq!(
1265 fixed_content, content,
1266 "Should not modify correctly formatted multi-line list items"
1267 );
1268 }
1269}