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 ctx.content.is_empty() || ctx.list_blocks.is_empty()
470 }
471
472 fn category(&self) -> RuleCategory {
473 RuleCategory::List
474 }
475
476 fn as_any(&self) -> &dyn std::any::Any {
477 self
478 }
479
480 fn default_config_section(&self) -> Option<(String, toml::Value)> {
481 let mut map = toml::map::Map::new();
482 map.insert(
483 "allow_after_headings".to_string(),
484 toml::Value::Boolean(self.allow_after_headings),
485 );
486 map.insert(
487 "allow_after_colons".to_string(),
488 toml::Value::Boolean(self.allow_after_colons),
489 );
490 Some((self.name().to_string(), toml::Value::Table(map)))
491 }
492
493 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
494 where
495 Self: Sized,
496 {
497 let allow_after_headings =
498 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_headings").unwrap_or(false); let allow_after_colons =
501 crate::config::get_rule_config_value::<bool>(config, "MD032", "allow_after_colons").unwrap_or(false); Box::new(MD032BlanksAroundLists {
504 allow_after_headings,
505 allow_after_colons,
506 })
507 }
508}
509
510impl MD032BlanksAroundLists {
511 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
513 let lines: Vec<&str> = ctx.content.lines().collect();
514 let num_lines = lines.len();
515 if num_lines == 0 {
516 return Ok(String::new());
517 }
518
519 let list_blocks = self.convert_list_blocks(ctx);
520 if list_blocks.is_empty() {
521 return Ok(ctx.content.to_string());
522 }
523
524 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
525
526 for &(start_line, end_line, ref prefix) in &list_blocks {
528 if start_line > 1 {
530 let prev_line_actual_idx_0 = start_line - 2;
531 let prev_line_actual_idx_1 = start_line - 1;
532 let is_prev_excluded =
533 ctx.is_in_code_block(prev_line_actual_idx_1) || ctx.is_in_front_matter(prev_line_actual_idx_1);
534 let prev_prefix = BLOCKQUOTE_PREFIX_RE
535 .find(lines[prev_line_actual_idx_0])
536 .map_or(String::new(), |m| m.as_str().to_string());
537
538 let should_require = self.should_require_blank_line_before(
539 lines[prev_line_actual_idx_0],
540 ctx,
541 prev_line_actual_idx_1,
542 start_line,
543 );
544 if !is_prev_excluded
545 && !is_blank_in_context(lines[prev_line_actual_idx_0])
546 && prev_prefix == *prefix
547 && should_require
548 {
549 insertions.insert(start_line, prefix.clone());
550 }
551 }
552
553 if end_line < num_lines {
555 let after_block_line_idx_0 = end_line;
556 let after_block_line_idx_1 = end_line + 1;
557 let line_after_block_content_str = lines[after_block_line_idx_0];
558 let is_line_after_excluded = ctx.is_in_code_block(after_block_line_idx_1)
561 || ctx.is_in_front_matter(after_block_line_idx_1)
562 || (after_block_line_idx_0 < ctx.lines.len()
563 && ctx.lines[after_block_line_idx_0].in_code_block
564 && ctx.lines[after_block_line_idx_0].indent >= 2
565 && (ctx.lines[after_block_line_idx_0].content.trim().starts_with("```")
566 || ctx.lines[after_block_line_idx_0].content.trim().starts_with("~~~")));
567 let after_prefix = BLOCKQUOTE_PREFIX_RE
568 .find(line_after_block_content_str)
569 .map_or(String::new(), |m| m.as_str().to_string());
570
571 if !is_line_after_excluded
572 && !is_blank_in_context(line_after_block_content_str)
573 && after_prefix == *prefix
574 {
575 insertions.insert(after_block_line_idx_1, prefix.clone());
576 }
577 }
578 }
579
580 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
582 for (i, line) in lines.iter().enumerate() {
583 let current_line_num = i + 1;
584 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
585 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
586 {
587 result_lines.push(prefix_to_insert.clone());
588 }
589 result_lines.push(line.to_string());
590 }
591
592 let mut result = result_lines.join("\n");
594 if ctx.content.ends_with('\n') {
595 result.push('\n');
596 }
597 Ok(result)
598 }
599}
600
601fn is_blank_in_context(line: &str) -> bool {
603 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
606 line[m.end()..].trim().is_empty()
608 } else {
609 line.trim().is_empty()
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use crate::lint_context::LintContext;
618 use crate::rule::Rule;
619
620 fn lint(content: &str) -> Vec<LintWarning> {
621 let rule = MD032BlanksAroundLists::default();
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
623 rule.check(&ctx).expect("Lint check failed")
624 }
625
626 fn fix(content: &str) -> String {
627 let rule = MD032BlanksAroundLists::default();
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
629 rule.fix(&ctx).expect("Lint fix failed")
630 }
631
632 fn check_warnings_have_fixes(content: &str) {
634 let warnings = lint(content);
635 for warning in &warnings {
636 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
637 }
638 }
639
640 #[test]
641 fn test_list_at_start() {
642 let content = "- Item 1\n- Item 2\nText";
643 let warnings = lint(content);
644 assert_eq!(
645 warnings.len(),
646 1,
647 "Expected 1 warning for list at start without trailing blank line"
648 );
649 assert_eq!(
650 warnings[0].line, 2,
651 "Warning should be on the last line of the list (line 2)"
652 );
653 assert!(warnings[0].message.contains("followed by blank line"));
654
655 check_warnings_have_fixes(content);
657
658 let fixed_content = fix(content);
659 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
660
661 let warnings_after_fix = lint(&fixed_content);
663 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
664 }
665
666 #[test]
667 fn test_list_at_end() {
668 let content = "Text\n- Item 1\n- Item 2";
669 let warnings = lint(content);
670 assert_eq!(
671 warnings.len(),
672 1,
673 "Expected 1 warning for list at end without preceding blank line"
674 );
675 assert_eq!(
676 warnings[0].line, 2,
677 "Warning should be on the first line of the list (line 2)"
678 );
679 assert!(warnings[0].message.contains("preceded by blank line"));
680
681 check_warnings_have_fixes(content);
683
684 let fixed_content = fix(content);
685 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
686
687 let warnings_after_fix = lint(&fixed_content);
689 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
690 }
691
692 #[test]
693 fn test_list_in_middle() {
694 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
695 let warnings = lint(content);
696 assert_eq!(
697 warnings.len(),
698 2,
699 "Expected 2 warnings for list in middle without surrounding blank lines"
700 );
701 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
702 assert!(warnings[0].message.contains("preceded by blank line"));
703 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
704 assert!(warnings[1].message.contains("followed by blank line"));
705
706 check_warnings_have_fixes(content);
708
709 let fixed_content = fix(content);
710 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
711
712 let warnings_after_fix = lint(&fixed_content);
714 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
715 }
716
717 #[test]
718 fn test_correct_spacing() {
719 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
720 let warnings = lint(content);
721 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
722
723 let fixed_content = fix(content);
724 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
725 }
726
727 #[test]
728 fn test_list_with_content() {
729 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
730 let warnings = lint(content);
731 assert_eq!(
732 warnings.len(),
733 2,
734 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
735 );
736 if warnings.len() == 2 {
737 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
738 assert!(warnings[0].message.contains("preceded by blank line"));
739 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
740 assert!(warnings[1].message.contains("followed by blank line"));
741 }
742
743 check_warnings_have_fixes(content);
745
746 let fixed_content = fix(content);
747 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
748 assert_eq!(
749 fixed_content, expected_fixed,
750 "Fix did not produce the expected output. Got:\n{fixed_content}"
751 );
752
753 let warnings_after_fix = lint(&fixed_content);
755 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
756 }
757
758 #[test]
759 fn test_nested_list() {
760 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
761 let warnings = lint(content);
762 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
764 assert_eq!(warnings[0].line, 2);
765 assert_eq!(warnings[1].line, 4);
766 }
767
768 check_warnings_have_fixes(content);
770
771 let fixed_content = fix(content);
772 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
773
774 let warnings_after_fix = lint(&fixed_content);
776 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
777 }
778
779 #[test]
780 fn test_list_with_internal_blanks() {
781 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
782 let warnings = lint(content);
783 assert_eq!(
784 warnings.len(),
785 2,
786 "List with internal blanks warnings. Got: {warnings:?}"
787 );
788 if warnings.len() == 2 {
789 assert_eq!(warnings[0].line, 2);
790 assert_eq!(warnings[1].line, 5); }
792
793 check_warnings_have_fixes(content);
795
796 let fixed_content = fix(content);
797 assert_eq!(
798 fixed_content,
799 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
800 );
801
802 let warnings_after_fix = lint(&fixed_content);
804 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
805 }
806
807 #[test]
808 fn test_ignore_code_blocks() {
809 let content = "```\n- Not a list item\n```\nText";
810 let warnings = lint(content);
811 assert_eq!(warnings.len(), 0);
812 let fixed_content = fix(content);
813 assert_eq!(fixed_content, content);
814 }
815
816 #[test]
817 fn test_ignore_front_matter() {
818 let content = "---\ntitle: Test\n---\n- List Item\nText";
819 let warnings = lint(content);
820 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
821 if !warnings.is_empty() {
822 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
824 }
825
826 check_warnings_have_fixes(content);
828
829 let fixed_content = fix(content);
830 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
831
832 let warnings_after_fix = lint(&fixed_content);
834 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
835 }
836
837 #[test]
838 fn test_multiple_lists() {
839 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
840 let warnings = lint(content);
841 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
842
843 check_warnings_have_fixes(content);
845
846 let fixed_content = fix(content);
847 assert_eq!(
848 fixed_content,
849 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
850 );
851
852 let warnings_after_fix = lint(&fixed_content);
854 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
855 }
856
857 #[test]
858 fn test_adjacent_lists() {
859 let content = "- List 1\n\n* List 2";
860 let warnings = lint(content);
861 assert_eq!(warnings.len(), 0);
862 let fixed_content = fix(content);
863 assert_eq!(fixed_content, content);
864 }
865
866 #[test]
867 fn test_list_in_blockquote() {
868 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
869 let warnings = lint(content);
870 assert_eq!(
871 warnings.len(),
872 2,
873 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
874 );
875 if warnings.len() == 2 {
876 assert_eq!(warnings[0].line, 2);
877 assert_eq!(warnings[1].line, 3);
878 }
879
880 check_warnings_have_fixes(content);
882
883 let fixed_content = fix(content);
884 assert_eq!(
886 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
887 "Fix for blockquoted list failed. Got:\n{fixed_content}"
888 );
889
890 let warnings_after_fix = lint(&fixed_content);
892 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
893 }
894
895 #[test]
896 fn test_ordered_list() {
897 let content = "Text\n1. Item 1\n2. Item 2\nText";
898 let warnings = lint(content);
899 assert_eq!(warnings.len(), 2);
900
901 check_warnings_have_fixes(content);
903
904 let fixed_content = fix(content);
905 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
906
907 let warnings_after_fix = lint(&fixed_content);
909 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
910 }
911
912 #[test]
913 fn test_no_double_blank_fix() {
914 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
916 assert_eq!(warnings.len(), 1);
917 if !warnings.is_empty() {
918 assert_eq!(
919 warnings[0].line, 4,
920 "Warning line for missing blank after should be the last line of the block"
921 );
922 }
923
924 check_warnings_have_fixes(content);
926
927 let fixed_content = fix(content);
928 assert_eq!(
929 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
930 "Fix added extra blank after. Got:\n{fixed_content}"
931 );
932
933 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
935 assert_eq!(warnings2.len(), 1);
936 if !warnings2.is_empty() {
937 assert_eq!(
938 warnings2[0].line, 2,
939 "Warning line for missing blank before should be the first line of the block"
940 );
941 }
942
943 check_warnings_have_fixes(content2);
945
946 let fixed_content2 = fix(content2);
947 assert_eq!(
948 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
949 "Fix added extra blank before. Got:\n{fixed_content2}"
950 );
951 }
952
953 #[test]
954 fn test_empty_input() {
955 let content = "";
956 let warnings = lint(content);
957 assert_eq!(warnings.len(), 0);
958 let fixed_content = fix(content);
959 assert_eq!(fixed_content, "");
960 }
961
962 #[test]
963 fn test_only_list() {
964 let content = "- Item 1\n- Item 2";
965 let warnings = lint(content);
966 assert_eq!(warnings.len(), 0);
967 let fixed_content = fix(content);
968 assert_eq!(fixed_content, content);
969 }
970
971 #[test]
974 fn test_fix_complex_nested_blockquote() {
975 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
976 let warnings = lint(content);
977 assert_eq!(
981 warnings.len(),
982 2,
983 "Should warn for missing blanks around the entire list block"
984 );
985
986 check_warnings_have_fixes(content);
988
989 let fixed_content = fix(content);
990 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
991 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
992
993 let warnings_after_fix = lint(&fixed_content);
995 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
996 }
997
998 #[test]
999 fn test_fix_mixed_list_markers() {
1000 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1001 let warnings = lint(content);
1002 assert_eq!(
1003 warnings.len(),
1004 2,
1005 "Should warn for missing blanks around mixed marker list"
1006 );
1007
1008 check_warnings_have_fixes(content);
1010
1011 let fixed_content = fix(content);
1012 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
1013 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
1014
1015 let warnings_after_fix = lint(&fixed_content);
1017 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1018 }
1019
1020 #[test]
1021 fn test_fix_ordered_list_with_different_numbers() {
1022 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1023 let warnings = lint(content);
1024 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1025
1026 check_warnings_have_fixes(content);
1028
1029 let fixed_content = fix(content);
1030 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1031 assert_eq!(
1032 fixed_content, expected,
1033 "Fix should handle ordered lists with non-sequential numbers"
1034 );
1035
1036 let warnings_after_fix = lint(&fixed_content);
1038 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1039 }
1040
1041 #[test]
1042 fn test_fix_list_with_code_blocks_inside() {
1043 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1044 let warnings = lint(content);
1045 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around list");
1049
1050 check_warnings_have_fixes(content);
1052
1053 let fixed_content = fix(content);
1054 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\n\nText";
1055 assert_eq!(
1056 fixed_content, expected,
1057 "Fix should handle lists with internal code blocks"
1058 );
1059
1060 let warnings_after_fix = lint(&fixed_content);
1062 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1063 }
1064
1065 #[test]
1066 fn test_fix_deeply_nested_lists() {
1067 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1068 let warnings = lint(content);
1069 assert_eq!(
1070 warnings.len(),
1071 2,
1072 "Should warn for missing blanks around deeply nested list"
1073 );
1074
1075 check_warnings_have_fixes(content);
1077
1078 let fixed_content = fix(content);
1079 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1080 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1081
1082 let warnings_after_fix = lint(&fixed_content);
1084 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1085 }
1086
1087 #[test]
1088 fn test_fix_list_with_multiline_items() {
1089 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1090 let warnings = lint(content);
1091 assert_eq!(
1092 warnings.len(),
1093 2,
1094 "Should warn for missing blanks around multiline list"
1095 );
1096
1097 check_warnings_have_fixes(content);
1099
1100 let fixed_content = fix(content);
1101 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1102 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1103
1104 let warnings_after_fix = lint(&fixed_content);
1106 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1107 }
1108
1109 #[test]
1110 fn test_fix_list_at_document_boundaries() {
1111 let content1 = "- Item 1\n- Item 2";
1113 let warnings1 = lint(content1);
1114 assert_eq!(
1115 warnings1.len(),
1116 0,
1117 "List at document start should not need blank before"
1118 );
1119 let fixed1 = fix(content1);
1120 assert_eq!(fixed1, content1, "No fix needed for list at start");
1121
1122 let content2 = "Text\n- Item 1\n- Item 2";
1124 let warnings2 = lint(content2);
1125 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1126 check_warnings_have_fixes(content2);
1127 let fixed2 = fix(content2);
1128 assert_eq!(
1129 fixed2, "Text\n\n- Item 1\n- Item 2",
1130 "Should add blank before list at end"
1131 );
1132 }
1133
1134 #[test]
1135 fn test_fix_preserves_existing_blank_lines() {
1136 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1137 let warnings = lint(content);
1138 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1139 let fixed_content = fix(content);
1140 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1141 }
1142
1143 #[test]
1144 fn test_fix_handles_tabs_and_spaces() {
1145 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1146 let warnings = lint(content);
1147 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1148
1149 check_warnings_have_fixes(content);
1151
1152 let fixed_content = fix(content);
1153 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1154 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1155
1156 let warnings_after_fix = lint(&fixed_content);
1158 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1159 }
1160
1161 #[test]
1162 fn test_fix_warning_objects_have_correct_ranges() {
1163 let content = "Text\n- Item 1\n- Item 2\nText";
1164 let warnings = lint(content);
1165 assert_eq!(warnings.len(), 2);
1166
1167 for warning in &warnings {
1169 assert!(warning.fix.is_some(), "Warning should have fix");
1170 let fix = warning.fix.as_ref().unwrap();
1171 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1172 assert!(
1173 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1174 "Fix should have replacement or be insertion"
1175 );
1176 }
1177 }
1178
1179 #[test]
1180 fn test_fix_idempotent() {
1181 let content = "Text\n- Item 1\n- Item 2\nText";
1182
1183 let fixed_once = fix(content);
1185 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1186
1187 let fixed_twice = fix(&fixed_once);
1189 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1190
1191 let warnings_after_fix = lint(&fixed_once);
1193 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1194 }
1195
1196 #[test]
1197 fn test_fix_with_windows_line_endings() {
1198 let content = "Text\r\n- Item 1\r\n- Item 2\r\nText";
1199 let warnings = lint(content);
1200 assert_eq!(warnings.len(), 2, "Should detect issues with Windows line endings");
1201
1202 check_warnings_have_fixes(content);
1204
1205 let fixed_content = fix(content);
1206 let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1208 assert_eq!(fixed_content, expected, "Fix should handle Windows line endings");
1209 }
1210
1211 #[test]
1212 fn test_fix_preserves_final_newline() {
1213 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1215 let fixed_with_newline = fix(content_with_newline);
1216 assert!(
1217 fixed_with_newline.ends_with('\n'),
1218 "Fix should preserve final newline when present"
1219 );
1220 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1221
1222 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1224 let fixed_without_newline = fix(content_without_newline);
1225 assert!(
1226 !fixed_without_newline.ends_with('\n'),
1227 "Fix should not add final newline when not present"
1228 );
1229 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1230 }
1231
1232 #[test]
1233 fn test_fix_multiline_list_items_no_indent() {
1234 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";
1235
1236 let warnings = lint(content);
1237 assert_eq!(
1239 warnings.len(),
1240 0,
1241 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1242 );
1243
1244 let fixed_content = fix(content);
1245 assert_eq!(
1247 fixed_content, content,
1248 "Should not modify correctly formatted multi-line list items"
1249 );
1250 }
1251}