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