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_code_fence = false;
183 for check_line in (prev_item_line + 1)..item_line {
184 if check_line - 1 < ctx.lines.len() {
185 let line = &ctx.lines[check_line - 1];
186 if line.in_code_block
187 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
188 {
189 has_code_fence = true;
190 break;
191 }
192 }
193 }
194
195 if has_code_fence {
196 segments.push((current_start, prev_item_line));
198 current_start = item_line;
199 }
200 }
201 prev_item_line = item_line;
202 }
203
204 if prev_item_line > 0 {
207 segments.push((current_start, prev_item_line));
208 }
209
210 let has_code_fence_splits = segments.len() > 1 && {
212 let mut found_fence = false;
214 for i in 0..segments.len() - 1 {
215 let seg_end = segments[i].1;
216 let next_start = segments[i + 1].0;
217 for check_line in (seg_end + 1)..next_start {
219 if check_line - 1 < ctx.lines.len() {
220 let line = &ctx.lines[check_line - 1];
221 if line.in_code_block
222 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
223 {
224 found_fence = true;
225 break;
226 }
227 }
228 }
229 if found_fence {
230 break;
231 }
232 }
233 found_fence
234 };
235
236 for (start, end) in segments.iter() {
238 let mut actual_end = *end;
240
241 if !has_code_fence_splits && *end < block.end_line {
244 for check_line in (*end + 1)..=block.end_line {
245 if check_line - 1 < ctx.lines.len() {
246 let line = &ctx.lines[check_line - 1];
247 if block.item_lines.contains(&check_line) || line.heading.is_some() {
249 break;
250 }
251 if line.in_code_block {
253 break;
254 }
255 if line.indent >= 2 {
257 actual_end = check_line;
258 }
259 else if !line.is_blank
261 && line.heading.is_none()
262 && !block.item_lines.contains(&check_line)
263 {
264 actual_end = check_line;
267 } else if !line.is_blank {
268 break;
270 }
271 }
272 }
273 }
274
275 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
276 }
277 }
278
279 blocks
280 }
281
282 fn perform_checks(
283 &self,
284 ctx: &crate::lint_context::LintContext,
285 lines: &[&str],
286 list_blocks: &[(usize, usize, String)],
287 line_index: &LineIndex,
288 ) -> LintResult {
289 let mut warnings = Vec::new();
290 let num_lines = lines.len();
291
292 for (line_idx, line) in lines.iter().enumerate() {
295 let line_num = line_idx + 1;
296
297 let is_in_list = list_blocks
299 .iter()
300 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
301 if is_in_list {
302 continue;
303 }
304
305 if ctx.is_in_code_block(line_num) || ctx.is_in_front_matter(line_num) {
307 continue;
308 }
309
310 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
312 if line_idx > 0 {
314 let prev_line = lines[line_idx - 1];
315 let prev_is_blank = is_blank_in_context(prev_line);
316 let prev_excluded = ctx.is_in_code_block(line_idx) || ctx.is_in_front_matter(line_idx);
317
318 if !prev_is_blank && !prev_excluded {
319 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
321
322 warnings.push(LintWarning {
323 line: start_line,
324 column: start_col,
325 end_line,
326 end_column: end_col,
327 severity: Severity::Error,
328 rule_name: Some(self.name()),
329 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
330 fix: Some(Fix {
331 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
332 replacement: "\n".to_string(),
333 }),
334 });
335 }
336 }
337 }
338 }
339
340 for &(start_line, end_line, ref prefix) in list_blocks {
341 if start_line > 1 {
342 let prev_line_actual_idx_0 = start_line - 2;
343 let prev_line_actual_idx_1 = start_line - 1;
344 let prev_line_str = lines[prev_line_actual_idx_0];
345 let is_prev_excluded =
346 ctx.is_in_code_block(prev_line_actual_idx_1) || ctx.is_in_front_matter(prev_line_actual_idx_1);
347 let prev_prefix = BLOCKQUOTE_PREFIX_RE
348 .find(prev_line_str)
349 .map_or(String::new(), |m| m.as_str().to_string());
350 let prev_is_blank = is_blank_in_context(prev_line_str);
351 let prefixes_match = prev_prefix.trim() == prefix.trim();
352
353 let should_require =
356 self.should_require_blank_line_before(prev_line_str, ctx, prev_line_actual_idx_1, start_line);
357 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
358 let (start_line, start_col, end_line, end_col) =
360 calculate_line_range(start_line, lines[start_line - 1]);
361
362 warnings.push(LintWarning {
363 line: start_line,
364 column: start_col,
365 end_line,
366 end_column: end_col,
367 severity: Severity::Error,
368 rule_name: Some(self.name()),
369 message: "List should be preceded by blank line".to_string(),
370 fix: Some(Fix {
371 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
372 replacement: format!("{prefix}\n"),
373 }),
374 });
375 }
376 }
377
378 if end_line < num_lines {
379 let next_line_idx_0 = end_line;
380 let next_line_idx_1 = end_line + 1;
381 let next_line_str = lines[next_line_idx_0];
382 let is_next_excluded = ctx.is_in_front_matter(next_line_idx_1)
385 || (next_line_idx_0 < ctx.lines.len()
386 && ctx.lines[next_line_idx_0].in_code_block
387 && ctx.lines[next_line_idx_0].indent >= 2);
388 let next_prefix = BLOCKQUOTE_PREFIX_RE
389 .find(next_line_str)
390 .map_or(String::new(), |m| m.as_str().to_string());
391 let next_is_blank = is_blank_in_context(next_line_str);
392 let prefixes_match = next_prefix.trim() == prefix.trim();
393
394 if !is_next_excluded && !next_is_blank && prefixes_match {
396 let (start_line_last, start_col_last, end_line_last, end_col_last) =
398 calculate_line_range(end_line, lines[end_line - 1]);
399
400 warnings.push(LintWarning {
401 line: start_line_last,
402 column: start_col_last,
403 end_line: end_line_last,
404 end_column: end_col_last,
405 severity: Severity::Error,
406 rule_name: Some(self.name()),
407 message: "List should be followed by blank line".to_string(),
408 fix: Some(Fix {
409 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
410 replacement: format!("{prefix}\n"),
411 }),
412 });
413 }
414 }
415 }
416 Ok(warnings)
417 }
418}
419
420impl Rule for MD032BlanksAroundLists {
421 fn name(&self) -> &'static str {
422 "MD032"
423 }
424
425 fn description(&self) -> &'static str {
426 "Lists should be surrounded by blank lines"
427 }
428
429 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
430 let content = ctx.content;
431 let lines: Vec<&str> = content.lines().collect();
432 let line_index = LineIndex::new(content.to_string());
433
434 if lines.is_empty() {
436 return Ok(Vec::new());
437 }
438
439 let list_blocks = self.convert_list_blocks(ctx);
440
441 if list_blocks.is_empty() {
442 return Ok(Vec::new());
443 }
444
445 self.perform_checks(ctx, &lines, &list_blocks, &line_index)
446 }
447
448 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
449 self.fix_with_structure_impl(ctx)
450 }
451
452 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
453 ctx.content.is_empty() || ctx.list_blocks.is_empty()
454 }
455
456 fn category(&self) -> RuleCategory {
457 RuleCategory::List
458 }
459
460 fn as_any(&self) -> &dyn std::any::Any {
461 self
462 }
463
464 fn default_config_section(&self) -> Option<(String, toml::Value)> {
465 let mut map = toml::map::Map::new();
466 map.insert(
467 "allow_after_headings".to_string(),
468 toml::Value::Boolean(self.allow_after_headings),
469 );
470 map.insert(
471 "allow_after_colons".to_string(),
472 toml::Value::Boolean(self.allow_after_colons),
473 );
474 Some((self.name().to_string(), toml::Value::Table(map)))
475 }
476
477 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
478 where
479 Self: Sized,
480 {
481 let allow_after_headings =
482 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_headings").unwrap_or(false); let allow_after_colons =
485 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_colons").unwrap_or(false); Box::new(MD032BlanksAroundLists {
488 allow_after_headings,
489 allow_after_colons,
490 })
491 }
492}
493
494impl MD032BlanksAroundLists {
495 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
497 let lines: Vec<&str> = ctx.content.lines().collect();
498 let num_lines = lines.len();
499 if num_lines == 0 {
500 return Ok(String::new());
501 }
502
503 let list_blocks = self.convert_list_blocks(ctx);
504 if list_blocks.is_empty() {
505 return Ok(ctx.content.to_string());
506 }
507
508 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
509
510 for &(start_line, end_line, ref prefix) in &list_blocks {
512 if start_line > 1 {
514 let prev_line_actual_idx_0 = start_line - 2;
515 let prev_line_actual_idx_1 = start_line - 1;
516 let is_prev_excluded =
517 ctx.is_in_code_block(prev_line_actual_idx_1) || ctx.is_in_front_matter(prev_line_actual_idx_1);
518 let prev_prefix = BLOCKQUOTE_PREFIX_RE
519 .find(lines[prev_line_actual_idx_0])
520 .map_or(String::new(), |m| m.as_str().to_string());
521
522 let should_require = self.should_require_blank_line_before(
523 lines[prev_line_actual_idx_0],
524 ctx,
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 = ctx.is_in_code_block(after_block_line_idx_1)
545 || ctx.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
585fn is_blank_in_context(line: &str) -> bool {
587 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
590 line[m.end()..].trim().is_empty()
592 } else {
593 line.trim().is_empty()
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use crate::lint_context::LintContext;
602 use crate::rule::Rule;
603
604 fn lint(content: &str) -> Vec<LintWarning> {
605 let rule = MD032BlanksAroundLists::default();
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607 rule.check(&ctx).expect("Lint check failed")
608 }
609
610 fn fix(content: &str) -> String {
611 let rule = MD032BlanksAroundLists::default();
612 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
613 rule.fix(&ctx).expect("Lint fix failed")
614 }
615
616 fn check_warnings_have_fixes(content: &str) {
618 let warnings = lint(content);
619 for warning in &warnings {
620 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
621 }
622 }
623
624 #[test]
625 fn test_list_at_start() {
626 let content = "- Item 1\n- Item 2\nText";
627 let warnings = lint(content);
628 assert_eq!(
629 warnings.len(),
630 1,
631 "Expected 1 warning for list at start without trailing blank line"
632 );
633 assert_eq!(
634 warnings[0].line, 2,
635 "Warning should be on the last line of the list (line 2)"
636 );
637 assert!(warnings[0].message.contains("followed by blank line"));
638
639 check_warnings_have_fixes(content);
641
642 let fixed_content = fix(content);
643 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
644
645 let warnings_after_fix = lint(&fixed_content);
647 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
648 }
649
650 #[test]
651 fn test_list_at_end() {
652 let content = "Text\n- Item 1\n- Item 2";
653 let warnings = lint(content);
654 assert_eq!(
655 warnings.len(),
656 1,
657 "Expected 1 warning for list at end without preceding blank line"
658 );
659 assert_eq!(
660 warnings[0].line, 2,
661 "Warning should be on the first line of the list (line 2)"
662 );
663 assert!(warnings[0].message.contains("preceded by blank line"));
664
665 check_warnings_have_fixes(content);
667
668 let fixed_content = fix(content);
669 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
670
671 let warnings_after_fix = lint(&fixed_content);
673 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
674 }
675
676 #[test]
677 fn test_list_in_middle() {
678 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
679 let warnings = lint(content);
680 assert_eq!(
681 warnings.len(),
682 2,
683 "Expected 2 warnings for list in middle without surrounding blank lines"
684 );
685 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
686 assert!(warnings[0].message.contains("preceded by blank line"));
687 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
688 assert!(warnings[1].message.contains("followed by blank line"));
689
690 check_warnings_have_fixes(content);
692
693 let fixed_content = fix(content);
694 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
695
696 let warnings_after_fix = lint(&fixed_content);
698 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
699 }
700
701 #[test]
702 fn test_correct_spacing() {
703 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
704 let warnings = lint(content);
705 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
706
707 let fixed_content = fix(content);
708 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
709 }
710
711 #[test]
712 fn test_list_with_content() {
713 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
714 let warnings = lint(content);
715 assert_eq!(
716 warnings.len(),
717 2,
718 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
719 );
720 if warnings.len() == 2 {
721 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
722 assert!(warnings[0].message.contains("preceded by blank line"));
723 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
724 assert!(warnings[1].message.contains("followed by blank line"));
725 }
726
727 check_warnings_have_fixes(content);
729
730 let fixed_content = fix(content);
731 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
732 assert_eq!(
733 fixed_content, expected_fixed,
734 "Fix did not produce the expected output. Got:\n{fixed_content}"
735 );
736
737 let warnings_after_fix = lint(&fixed_content);
739 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
740 }
741
742 #[test]
743 fn test_nested_list() {
744 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
745 let warnings = lint(content);
746 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
748 assert_eq!(warnings[0].line, 2);
749 assert_eq!(warnings[1].line, 4);
750 }
751
752 check_warnings_have_fixes(content);
754
755 let fixed_content = fix(content);
756 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
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_list_with_internal_blanks() {
765 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
766 let warnings = lint(content);
767 assert_eq!(
768 warnings.len(),
769 2,
770 "List with internal blanks warnings. Got: {warnings:?}"
771 );
772 if warnings.len() == 2 {
773 assert_eq!(warnings[0].line, 2);
774 assert_eq!(warnings[1].line, 5); }
776
777 check_warnings_have_fixes(content);
779
780 let fixed_content = fix(content);
781 assert_eq!(
782 fixed_content,
783 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
784 );
785
786 let warnings_after_fix = lint(&fixed_content);
788 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
789 }
790
791 #[test]
792 fn test_ignore_code_blocks() {
793 let content = "```\n- Not a list item\n```\nText";
794 let warnings = lint(content);
795 assert_eq!(warnings.len(), 0);
796 let fixed_content = fix(content);
797 assert_eq!(fixed_content, content);
798 }
799
800 #[test]
801 fn test_ignore_front_matter() {
802 let content = "---\ntitle: Test\n---\n- List Item\nText";
803 let warnings = lint(content);
804 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
805 if !warnings.is_empty() {
806 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
808 }
809
810 check_warnings_have_fixes(content);
812
813 let fixed_content = fix(content);
814 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
815
816 let warnings_after_fix = lint(&fixed_content);
818 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
819 }
820
821 #[test]
822 fn test_multiple_lists() {
823 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
824 let warnings = lint(content);
825 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
826
827 check_warnings_have_fixes(content);
829
830 let fixed_content = fix(content);
831 assert_eq!(
832 fixed_content,
833 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
834 );
835
836 let warnings_after_fix = lint(&fixed_content);
838 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
839 }
840
841 #[test]
842 fn test_adjacent_lists() {
843 let content = "- List 1\n\n* List 2";
844 let warnings = lint(content);
845 assert_eq!(warnings.len(), 0);
846 let fixed_content = fix(content);
847 assert_eq!(fixed_content, content);
848 }
849
850 #[test]
851 fn test_list_in_blockquote() {
852 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
853 let warnings = lint(content);
854 assert_eq!(
855 warnings.len(),
856 2,
857 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
858 );
859 if warnings.len() == 2 {
860 assert_eq!(warnings[0].line, 2);
861 assert_eq!(warnings[1].line, 3);
862 }
863
864 check_warnings_have_fixes(content);
866
867 let fixed_content = fix(content);
868 assert_eq!(
870 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
871 "Fix for blockquoted list failed. Got:\n{fixed_content}"
872 );
873
874 let warnings_after_fix = lint(&fixed_content);
876 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
877 }
878
879 #[test]
880 fn test_ordered_list() {
881 let content = "Text\n1. Item 1\n2. Item 2\nText";
882 let warnings = lint(content);
883 assert_eq!(warnings.len(), 2);
884
885 check_warnings_have_fixes(content);
887
888 let fixed_content = fix(content);
889 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
890
891 let warnings_after_fix = lint(&fixed_content);
893 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
894 }
895
896 #[test]
897 fn test_no_double_blank_fix() {
898 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
900 assert_eq!(warnings.len(), 1);
901 if !warnings.is_empty() {
902 assert_eq!(
903 warnings[0].line, 4,
904 "Warning line for missing blank after should be the last line of the block"
905 );
906 }
907
908 check_warnings_have_fixes(content);
910
911 let fixed_content = fix(content);
912 assert_eq!(
913 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
914 "Fix added extra blank after. Got:\n{fixed_content}"
915 );
916
917 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
919 assert_eq!(warnings2.len(), 1);
920 if !warnings2.is_empty() {
921 assert_eq!(
922 warnings2[0].line, 2,
923 "Warning line for missing blank before should be the first line of the block"
924 );
925 }
926
927 check_warnings_have_fixes(content2);
929
930 let fixed_content2 = fix(content2);
931 assert_eq!(
932 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
933 "Fix added extra blank before. Got:\n{fixed_content2}"
934 );
935 }
936
937 #[test]
938 fn test_empty_input() {
939 let content = "";
940 let warnings = lint(content);
941 assert_eq!(warnings.len(), 0);
942 let fixed_content = fix(content);
943 assert_eq!(fixed_content, "");
944 }
945
946 #[test]
947 fn test_only_list() {
948 let content = "- Item 1\n- Item 2";
949 let warnings = lint(content);
950 assert_eq!(warnings.len(), 0);
951 let fixed_content = fix(content);
952 assert_eq!(fixed_content, content);
953 }
954
955 #[test]
958 fn test_fix_complex_nested_blockquote() {
959 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
960 let warnings = lint(content);
961 assert_eq!(
965 warnings.len(),
966 2,
967 "Should warn for missing blanks around the entire list block"
968 );
969
970 check_warnings_have_fixes(content);
972
973 let fixed_content = fix(content);
974 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
975 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
976
977 let warnings_after_fix = lint(&fixed_content);
979 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
980 }
981
982 #[test]
983 fn test_fix_mixed_list_markers() {
984 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
985 let warnings = lint(content);
986 assert_eq!(
987 warnings.len(),
988 2,
989 "Should warn for missing blanks around mixed marker list"
990 );
991
992 check_warnings_have_fixes(content);
994
995 let fixed_content = fix(content);
996 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
997 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
998
999 let warnings_after_fix = lint(&fixed_content);
1001 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1002 }
1003
1004 #[test]
1005 fn test_fix_ordered_list_with_different_numbers() {
1006 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1007 let warnings = lint(content);
1008 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1009
1010 check_warnings_have_fixes(content);
1012
1013 let fixed_content = fix(content);
1014 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1015 assert_eq!(
1016 fixed_content, expected,
1017 "Fix should handle ordered lists with non-sequential numbers"
1018 );
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_list_with_code_blocks_inside() {
1027 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1028 let warnings = lint(content);
1029 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around list");
1033
1034 check_warnings_have_fixes(content);
1036
1037 let fixed_content = fix(content);
1038 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\n\nText";
1039 assert_eq!(
1040 fixed_content, expected,
1041 "Fix should handle lists with internal code blocks"
1042 );
1043
1044 let warnings_after_fix = lint(&fixed_content);
1046 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1047 }
1048
1049 #[test]
1050 fn test_fix_deeply_nested_lists() {
1051 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1052 let warnings = lint(content);
1053 assert_eq!(
1054 warnings.len(),
1055 2,
1056 "Should warn for missing blanks around deeply nested list"
1057 );
1058
1059 check_warnings_have_fixes(content);
1061
1062 let fixed_content = fix(content);
1063 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1064 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1065
1066 let warnings_after_fix = lint(&fixed_content);
1068 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1069 }
1070
1071 #[test]
1072 fn test_fix_list_with_multiline_items() {
1073 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1074 let warnings = lint(content);
1075 assert_eq!(
1076 warnings.len(),
1077 2,
1078 "Should warn for missing blanks around multiline list"
1079 );
1080
1081 check_warnings_have_fixes(content);
1083
1084 let fixed_content = fix(content);
1085 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1086 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1087
1088 let warnings_after_fix = lint(&fixed_content);
1090 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1091 }
1092
1093 #[test]
1094 fn test_fix_list_at_document_boundaries() {
1095 let content1 = "- Item 1\n- Item 2";
1097 let warnings1 = lint(content1);
1098 assert_eq!(
1099 warnings1.len(),
1100 0,
1101 "List at document start should not need blank before"
1102 );
1103 let fixed1 = fix(content1);
1104 assert_eq!(fixed1, content1, "No fix needed for list at start");
1105
1106 let content2 = "Text\n- Item 1\n- Item 2";
1108 let warnings2 = lint(content2);
1109 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1110 check_warnings_have_fixes(content2);
1111 let fixed2 = fix(content2);
1112 assert_eq!(
1113 fixed2, "Text\n\n- Item 1\n- Item 2",
1114 "Should add blank before list at end"
1115 );
1116 }
1117
1118 #[test]
1119 fn test_fix_preserves_existing_blank_lines() {
1120 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1121 let warnings = lint(content);
1122 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1123 let fixed_content = fix(content);
1124 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1125 }
1126
1127 #[test]
1128 fn test_fix_handles_tabs_and_spaces() {
1129 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1130 let warnings = lint(content);
1131 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1132
1133 check_warnings_have_fixes(content);
1135
1136 let fixed_content = fix(content);
1137 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1138 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1139
1140 let warnings_after_fix = lint(&fixed_content);
1142 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1143 }
1144
1145 #[test]
1146 fn test_fix_warning_objects_have_correct_ranges() {
1147 let content = "Text\n- Item 1\n- Item 2\nText";
1148 let warnings = lint(content);
1149 assert_eq!(warnings.len(), 2);
1150
1151 for warning in &warnings {
1153 assert!(warning.fix.is_some(), "Warning should have fix");
1154 let fix = warning.fix.as_ref().unwrap();
1155 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1156 assert!(
1157 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1158 "Fix should have replacement or be insertion"
1159 );
1160 }
1161 }
1162
1163 #[test]
1164 fn test_fix_idempotent() {
1165 let content = "Text\n- Item 1\n- Item 2\nText";
1166
1167 let fixed_once = fix(content);
1169 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1170
1171 let fixed_twice = fix(&fixed_once);
1173 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1174
1175 let warnings_after_fix = lint(&fixed_once);
1177 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1178 }
1179
1180 #[test]
1181 fn test_fix_with_windows_line_endings() {
1182 let content = "Text\r\n- Item 1\r\n- Item 2\r\nText";
1183 let warnings = lint(content);
1184 assert_eq!(warnings.len(), 2, "Should detect issues with Windows line endings");
1185
1186 check_warnings_have_fixes(content);
1188
1189 let fixed_content = fix(content);
1190 let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1192 assert_eq!(fixed_content, expected, "Fix should handle Windows line endings");
1193 }
1194
1195 #[test]
1196 fn test_fix_preserves_final_newline() {
1197 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1199 let fixed_with_newline = fix(content_with_newline);
1200 assert!(
1201 fixed_with_newline.ends_with('\n'),
1202 "Fix should preserve final newline when present"
1203 );
1204 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1205
1206 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1208 let fixed_without_newline = fix(content_without_newline);
1209 assert!(
1210 !fixed_without_newline.ends_with('\n'),
1211 "Fix should not add final newline when not present"
1212 );
1213 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1214 }
1215
1216 #[test]
1217 fn test_fix_multiline_list_items_no_indent() {
1218 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";
1219
1220 let warnings = lint(content);
1221 assert_eq!(
1223 warnings.len(),
1224 0,
1225 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1226 );
1227
1228 let fixed_content = fix(content);
1229 assert_eq!(
1231 fixed_content, content,
1232 "Should not modify correctly formatted multi-line list items"
1233 );
1234 }
1235}