1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::document_structure::document_structure_from_str;
3use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
4use crate::utils::range_utils::{LineIndex, calculate_line_range};
5use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
6use lazy_static::lazy_static;
7use regex::Regex;
8
9lazy_static! {
10 static ref BLANK_LINE_RE: Regex = Regex::new(r"^\s*$").unwrap();
11 static ref ORDERED_LIST_NON_ONE_RE: Regex = Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap();
13}
14
15#[derive(Debug, Clone, Default)]
85pub struct MD032BlanksAroundLists {
86 pub allow_after_headings: bool,
88 pub allow_after_colons: bool,
90}
91
92impl MD032BlanksAroundLists {
93 pub fn strict() -> Self {
94 Self {
95 allow_after_headings: false,
96 allow_after_colons: false,
97 }
98 }
99
100 fn should_require_blank_line_before(
102 &self,
103 prev_line: &str,
104 ctx: &crate::lint_context::LintContext,
105 structure: &DocumentStructure,
106 prev_line_num: usize,
107 current_line_num: usize,
108 ) -> bool {
109 let trimmed_prev = prev_line.trim();
110
111 if structure.is_in_code_block(prev_line_num) || structure.is_in_front_matter(prev_line_num) {
113 return true;
114 }
115
116 if self.is_nested_list(ctx, prev_line_num, current_line_num) {
118 return false;
119 }
120
121 if self.allow_after_headings && self.is_heading_line_from_context(ctx, prev_line_num - 1) {
123 return false;
124 }
125
126 if self.allow_after_colons && trimmed_prev.ends_with(':') {
128 return false;
129 }
130
131 true
133 }
134
135 fn is_heading_line_from_context(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool {
137 if line_idx < ctx.lines.len() {
138 ctx.lines[line_idx].heading.is_some()
139 } else {
140 false
141 }
142 }
143
144 fn is_nested_list(
146 &self,
147 ctx: &crate::lint_context::LintContext,
148 prev_line_num: usize, current_line_num: usize, ) -> bool {
151 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
153 let current_line = &ctx.lines[current_line_num - 1];
154 if current_line.indent >= 2 {
155 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
157 let prev_line = &ctx.lines[prev_line_num - 1];
158 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
160 return true;
161 }
162 }
163 }
164 }
165 false
166 }
167
168 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
170 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
171
172 for block in &ctx.list_blocks {
173 let mut segments: Vec<(usize, usize)> = Vec::new();
179 let mut current_start = block.start_line;
180 let mut prev_item_line = 0;
181
182 for &item_line in &block.item_lines {
183 if prev_item_line > 0 {
184 let mut has_code_fence = false;
186 for check_line in (prev_item_line + 1)..item_line {
187 if check_line - 1 < ctx.lines.len() {
188 let line = &ctx.lines[check_line - 1];
189 if line.in_code_block
190 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
191 {
192 has_code_fence = true;
193 break;
194 }
195 }
196 }
197
198 if has_code_fence {
199 segments.push((current_start, prev_item_line));
201 current_start = item_line;
202 }
203 }
204 prev_item_line = item_line;
205 }
206
207 if prev_item_line > 0 {
210 segments.push((current_start, prev_item_line));
211 }
212
213 let has_code_fence_splits = segments.len() > 1 && {
215 let mut found_fence = false;
217 for i in 0..segments.len() - 1 {
218 let seg_end = segments[i].1;
219 let next_start = segments[i + 1].0;
220 for check_line in (seg_end + 1)..next_start {
222 if check_line - 1 < ctx.lines.len() {
223 let line = &ctx.lines[check_line - 1];
224 if line.in_code_block
225 && (line.content.trim().starts_with("```") || line.content.trim().starts_with("~~~"))
226 {
227 found_fence = true;
228 break;
229 }
230 }
231 }
232 if found_fence {
233 break;
234 }
235 }
236 found_fence
237 };
238
239 for (start, end) in segments.iter() {
241 let mut actual_end = *end;
243
244 if !has_code_fence_splits && *end < block.end_line {
247 for check_line in (*end + 1)..=block.end_line {
248 if check_line - 1 < ctx.lines.len() {
249 let line = &ctx.lines[check_line - 1];
250 if block.item_lines.contains(&check_line) || line.heading.is_some() {
252 break;
253 }
254 if line.in_code_block {
256 break;
257 }
258 if line.indent >= 2 {
260 actual_end = check_line;
261 }
262 else if !line.is_blank
264 && line.heading.is_none()
265 && !block.item_lines.contains(&check_line)
266 {
267 actual_end = check_line;
270 } else if !line.is_blank {
271 break;
273 }
274 }
275 }
276 }
277
278 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
279 }
280 }
281
282 blocks
283 }
284
285 fn perform_checks(
286 &self,
287 ctx: &crate::lint_context::LintContext,
288 structure: &DocumentStructure,
289 lines: &[&str],
290 list_blocks: &[(usize, usize, String)],
291 line_index: &LineIndex,
292 ) -> LintResult {
293 let mut warnings = Vec::new();
294 let num_lines = lines.len();
295
296 for (line_idx, line) in lines.iter().enumerate() {
299 let line_num = line_idx + 1;
300
301 let is_in_list = list_blocks
303 .iter()
304 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
305 if is_in_list {
306 continue;
307 }
308
309 if structure.is_in_code_block(line_num) || structure.is_in_front_matter(line_num) {
311 continue;
312 }
313
314 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
316 if line_idx > 0 {
318 let prev_line = lines[line_idx - 1];
319 let prev_is_blank = is_blank_in_context(prev_line);
320 let prev_excluded = structure.is_in_code_block(line_idx) || structure.is_in_front_matter(line_idx);
321
322 if !prev_is_blank && !prev_excluded {
323 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
325
326 warnings.push(LintWarning {
327 line: start_line,
328 column: start_col,
329 end_line,
330 end_column: end_col,
331 severity: Severity::Error,
332 rule_name: Some(self.name()),
333 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
334 fix: Some(Fix {
335 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
336 replacement: "\n".to_string(),
337 }),
338 });
339 }
340 }
341 }
342 }
343
344 for &(start_line, end_line, ref prefix) in list_blocks {
345 if start_line > 1 {
346 let prev_line_actual_idx_0 = start_line - 2;
347 let prev_line_actual_idx_1 = start_line - 1;
348 let prev_line_str = lines[prev_line_actual_idx_0];
349 let is_prev_excluded = structure.is_in_code_block(prev_line_actual_idx_1)
350 || structure.is_in_front_matter(prev_line_actual_idx_1);
351 let prev_prefix = BLOCKQUOTE_PREFIX_RE
352 .find(prev_line_str)
353 .map_or(String::new(), |m| m.as_str().to_string());
354 let prev_is_blank = is_blank_in_context(prev_line_str);
355 let prefixes_match = prev_prefix.trim() == prefix.trim();
356
357 let should_require = self.should_require_blank_line_before(
360 prev_line_str,
361 ctx,
362 structure,
363 prev_line_actual_idx_1,
364 start_line,
365 );
366 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
367 let (start_line, start_col, end_line, end_col) =
369 calculate_line_range(start_line, lines[start_line - 1]);
370
371 warnings.push(LintWarning {
372 line: start_line,
373 column: start_col,
374 end_line,
375 end_column: end_col,
376 severity: Severity::Error,
377 rule_name: Some(self.name()),
378 message: "List should be preceded by blank line".to_string(),
379 fix: Some(Fix {
380 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
381 replacement: format!("{prefix}\n"),
382 }),
383 });
384 }
385 }
386
387 if end_line < num_lines {
388 let next_line_idx_0 = end_line;
389 let next_line_idx_1 = end_line + 1;
390 let next_line_str = lines[next_line_idx_0];
391 let is_next_excluded = structure.is_in_code_block(next_line_idx_1)
394 || structure.is_in_front_matter(next_line_idx_1)
395 || (next_line_idx_0 < ctx.lines.len()
396 && ctx.lines[next_line_idx_0].in_code_block
397 && ctx.lines[next_line_idx_0].indent >= 2
398 && (ctx.lines[next_line_idx_0].content.trim().starts_with("```")
399 || ctx.lines[next_line_idx_0].content.trim().starts_with("~~~")));
400 let next_prefix = BLOCKQUOTE_PREFIX_RE
401 .find(next_line_str)
402 .map_or(String::new(), |m| m.as_str().to_string());
403 let next_is_blank = is_blank_in_context(next_line_str);
404 let prefixes_match = next_prefix.trim() == prefix.trim();
405
406 if !is_next_excluded && !next_is_blank && prefixes_match {
408 let (start_line_last, start_col_last, end_line_last, end_col_last) =
410 calculate_line_range(end_line, lines[end_line - 1]);
411
412 warnings.push(LintWarning {
413 line: start_line_last,
414 column: start_col_last,
415 end_line: end_line_last,
416 end_column: end_col_last,
417 severity: Severity::Error,
418 rule_name: Some(self.name()),
419 message: "List should be followed by blank line".to_string(),
420 fix: Some(Fix {
421 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
422 replacement: format!("{prefix}\n"),
423 }),
424 });
425 }
426 }
427 }
428 Ok(warnings)
429 }
430}
431
432impl Rule for MD032BlanksAroundLists {
433 fn name(&self) -> &'static str {
434 "MD032"
435 }
436
437 fn description(&self) -> &'static str {
438 "Lists should be surrounded by blank lines"
439 }
440
441 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
442 let structure = document_structure_from_str(ctx.content);
445 self.check_with_structure(ctx, &structure)
446 }
447
448 fn check_with_structure(
450 &self,
451 ctx: &crate::lint_context::LintContext,
452 structure: &DocumentStructure,
453 ) -> LintResult {
454 let content = ctx.content;
455 let lines: Vec<&str> = content.lines().collect();
456 let line_index = LineIndex::new(content.to_string());
457
458 if lines.is_empty() {
460 return Ok(Vec::new());
461 }
462
463 let list_blocks = self.convert_list_blocks(ctx);
464
465 if list_blocks.is_empty() {
466 return Ok(Vec::new());
467 }
468
469 self.perform_checks(ctx, structure, &lines, &list_blocks, &line_index)
470 }
471
472 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
473 let structure = document_structure_from_str(ctx.content);
475 self.fix_with_structure(ctx, &structure)
476 }
477
478 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
479 ctx.content.is_empty() || 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 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
520 Some(self)
521 }
522}
523
524impl MD032BlanksAroundLists {
525 fn fix_with_structure(
527 &self,
528 ctx: &crate::lint_context::LintContext,
529 structure: &DocumentStructure,
530 ) -> Result<String, LintError> {
531 let lines: Vec<&str> = ctx.content.lines().collect();
532 let num_lines = lines.len();
533 if num_lines == 0 {
534 return Ok(String::new());
535 }
536
537 let list_blocks = self.convert_list_blocks(ctx);
538 if list_blocks.is_empty() {
539 return Ok(ctx.content.to_string());
540 }
541
542 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
543
544 for &(start_line, end_line, ref prefix) in &list_blocks {
546 if start_line > 1 {
548 let prev_line_actual_idx_0 = start_line - 2;
549 let prev_line_actual_idx_1 = start_line - 1;
550 let is_prev_excluded = structure.is_in_code_block(prev_line_actual_idx_1)
551 || structure.is_in_front_matter(prev_line_actual_idx_1);
552 let prev_prefix = BLOCKQUOTE_PREFIX_RE
553 .find(lines[prev_line_actual_idx_0])
554 .map_or(String::new(), |m| m.as_str().to_string());
555
556 let should_require = self.should_require_blank_line_before(
557 lines[prev_line_actual_idx_0],
558 ctx,
559 structure,
560 prev_line_actual_idx_1,
561 start_line,
562 );
563 if !is_prev_excluded
564 && !is_blank_in_context(lines[prev_line_actual_idx_0])
565 && prev_prefix == *prefix
566 && should_require
567 {
568 insertions.insert(start_line, prefix.clone());
569 }
570 }
571
572 if end_line < num_lines {
574 let after_block_line_idx_0 = end_line;
575 let after_block_line_idx_1 = end_line + 1;
576 let line_after_block_content_str = lines[after_block_line_idx_0];
577 let is_line_after_excluded = structure.is_in_code_block(after_block_line_idx_1)
580 || structure.is_in_front_matter(after_block_line_idx_1)
581 || (after_block_line_idx_0 < ctx.lines.len()
582 && ctx.lines[after_block_line_idx_0].in_code_block
583 && ctx.lines[after_block_line_idx_0].indent >= 2
584 && (ctx.lines[after_block_line_idx_0].content.trim().starts_with("```")
585 || ctx.lines[after_block_line_idx_0].content.trim().starts_with("~~~")));
586 let after_prefix = BLOCKQUOTE_PREFIX_RE
587 .find(line_after_block_content_str)
588 .map_or(String::new(), |m| m.as_str().to_string());
589
590 if !is_line_after_excluded
591 && !is_blank_in_context(line_after_block_content_str)
592 && after_prefix == *prefix
593 {
594 insertions.insert(after_block_line_idx_1, prefix.clone());
595 }
596 }
597 }
598
599 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
601 for (i, line) in lines.iter().enumerate() {
602 let current_line_num = i + 1;
603 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
604 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
605 {
606 result_lines.push(prefix_to_insert.clone());
607 }
608 result_lines.push(line.to_string());
609 }
610
611 let mut result = result_lines.join("\n");
613 if ctx.content.ends_with('\n') {
614 result.push('\n');
615 }
616 Ok(result)
617 }
618}
619
620impl DocumentStructureExtensions for MD032BlanksAroundLists {
621 fn has_relevant_elements(
622 &self,
623 ctx: &crate::lint_context::LintContext,
624 _doc_structure: &DocumentStructure,
625 ) -> bool {
626 let content = ctx.content;
627
628 if content.is_empty() {
630 return false;
631 }
632
633 if !content.contains('-')
635 && !content.contains('*')
636 && !content.contains('+')
637 && !content.chars().any(|c| c.is_numeric())
638 {
639 return false;
640 }
641
642 !ctx.list_blocks.is_empty()
644 }
645}
646
647fn is_blank_in_context(line: &str) -> bool {
649 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
652 line[m.end()..].trim().is_empty()
654 } else {
655 line.trim().is_empty()
657 }
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use crate::lint_context::LintContext;
664 use crate::rule::Rule;
665
666 fn lint(content: &str) -> Vec<LintWarning> {
667 let rule = MD032BlanksAroundLists::default();
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
669 rule.check(&ctx).expect("Lint check failed")
670 }
671
672 fn fix(content: &str) -> String {
673 let rule = MD032BlanksAroundLists::default();
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
675 rule.fix(&ctx).expect("Lint fix failed")
676 }
677
678 fn check_warnings_have_fixes(content: &str) {
680 let warnings = lint(content);
681 for warning in &warnings {
682 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
683 }
684 }
685
686 #[test]
687 fn test_list_at_start() {
688 let content = "- Item 1\n- Item 2\nText";
689 let warnings = lint(content);
690 assert_eq!(
691 warnings.len(),
692 1,
693 "Expected 1 warning for list at start without trailing blank line"
694 );
695 assert_eq!(
696 warnings[0].line, 2,
697 "Warning should be on the last line of the list (line 2)"
698 );
699 assert!(warnings[0].message.contains("followed by blank line"));
700
701 check_warnings_have_fixes(content);
703
704 let fixed_content = fix(content);
705 assert_eq!(fixed_content, "- Item 1\n- Item 2\n\nText");
706
707 let warnings_after_fix = lint(&fixed_content);
709 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
710 }
711
712 #[test]
713 fn test_list_at_end() {
714 let content = "Text\n- Item 1\n- Item 2";
715 let warnings = lint(content);
716 assert_eq!(
717 warnings.len(),
718 1,
719 "Expected 1 warning for list at end without preceding blank line"
720 );
721 assert_eq!(
722 warnings[0].line, 2,
723 "Warning should be on the first line of the list (line 2)"
724 );
725 assert!(warnings[0].message.contains("preceded by blank line"));
726
727 check_warnings_have_fixes(content);
729
730 let fixed_content = fix(content);
731 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
732
733 let warnings_after_fix = lint(&fixed_content);
735 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
736 }
737
738 #[test]
739 fn test_list_in_middle() {
740 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
741 let warnings = lint(content);
742 assert_eq!(
743 warnings.len(),
744 2,
745 "Expected 2 warnings for list in middle without surrounding blank lines"
746 );
747 assert_eq!(warnings[0].line, 2, "First warning on line 2 (start)");
748 assert!(warnings[0].message.contains("preceded by blank line"));
749 assert_eq!(warnings[1].line, 3, "Second warning on line 3 (end)");
750 assert!(warnings[1].message.contains("followed by blank line"));
751
752 check_warnings_have_fixes(content);
754
755 let fixed_content = fix(content);
756 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\n\nText 2");
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_correct_spacing() {
765 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
766 let warnings = lint(content);
767 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
768
769 let fixed_content = fix(content);
770 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
771 }
772
773 #[test]
774 fn test_list_with_content() {
775 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
776 let warnings = lint(content);
777 assert_eq!(
778 warnings.len(),
779 2,
780 "Expected 2 warnings for list block (lines 2-5) missing surrounding blanks. Got: {warnings:?}"
781 );
782 if warnings.len() == 2 {
783 assert_eq!(warnings[0].line, 2, "Warning 1 should be on line 2 (start)");
784 assert!(warnings[0].message.contains("preceded by blank line"));
785 assert_eq!(warnings[1].line, 5, "Warning 2 should be on line 5 (end)");
786 assert!(warnings[1].message.contains("followed by blank line"));
787 }
788
789 check_warnings_have_fixes(content);
791
792 let fixed_content = fix(content);
793 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\n\nText";
794 assert_eq!(
795 fixed_content, expected_fixed,
796 "Fix did not produce the expected output. Got:\n{fixed_content}"
797 );
798
799 let warnings_after_fix = lint(&fixed_content);
801 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
802 }
803
804 #[test]
805 fn test_nested_list() {
806 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
807 let warnings = lint(content);
808 assert_eq!(warnings.len(), 2, "Nested list block warnings. Got: {warnings:?}"); if warnings.len() == 2 {
810 assert_eq!(warnings[0].line, 2);
811 assert_eq!(warnings[1].line, 4);
812 }
813
814 check_warnings_have_fixes(content);
816
817 let fixed_content = fix(content);
818 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\n\nText");
819
820 let warnings_after_fix = lint(&fixed_content);
822 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
823 }
824
825 #[test]
826 fn test_list_with_internal_blanks() {
827 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
828 let warnings = lint(content);
829 assert_eq!(
830 warnings.len(),
831 2,
832 "List with internal blanks warnings. Got: {warnings:?}"
833 );
834 if warnings.len() == 2 {
835 assert_eq!(warnings[0].line, 2);
836 assert_eq!(warnings[1].line, 5); }
838
839 check_warnings_have_fixes(content);
841
842 let fixed_content = fix(content);
843 assert_eq!(
844 fixed_content,
845 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\n\nText"
846 );
847
848 let warnings_after_fix = lint(&fixed_content);
850 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
851 }
852
853 #[test]
854 fn test_ignore_code_blocks() {
855 let content = "```\n- Not a list item\n```\nText";
856 let warnings = lint(content);
857 assert_eq!(warnings.len(), 0);
858 let fixed_content = fix(content);
859 assert_eq!(fixed_content, content);
860 }
861
862 #[test]
863 fn test_ignore_front_matter() {
864 let content = "---\ntitle: Test\n---\n- List Item\nText";
865 let warnings = lint(content);
866 assert_eq!(warnings.len(), 1, "Front matter test warnings. Got: {warnings:?}");
867 if !warnings.is_empty() {
868 assert_eq!(warnings[0].line, 4); assert!(warnings[0].message.contains("followed by blank line"));
870 }
871
872 check_warnings_have_fixes(content);
874
875 let fixed_content = fix(content);
876 assert_eq!(fixed_content, "---\ntitle: Test\n---\n- List Item\n\nText");
877
878 let warnings_after_fix = lint(&fixed_content);
880 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
881 }
882
883 #[test]
884 fn test_multiple_lists() {
885 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
886 let warnings = lint(content);
887 assert_eq!(warnings.len(), 4, "Multiple lists warnings. Got: {warnings:?}");
888
889 check_warnings_have_fixes(content);
891
892 let fixed_content = fix(content);
893 assert_eq!(
894 fixed_content,
895 "Text\n\n- List 1 Item 1\n- List 1 Item 2\n\nText 2\n\n* List 2 Item 1\n\nText 3"
896 );
897
898 let warnings_after_fix = lint(&fixed_content);
900 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
901 }
902
903 #[test]
904 fn test_adjacent_lists() {
905 let content = "- List 1\n\n* List 2";
906 let warnings = lint(content);
907 assert_eq!(warnings.len(), 0);
908 let fixed_content = fix(content);
909 assert_eq!(fixed_content, content);
910 }
911
912 #[test]
913 fn test_list_in_blockquote() {
914 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
915 let warnings = lint(content);
916 assert_eq!(
917 warnings.len(),
918 2,
919 "Expected 2 warnings for blockquoted list. Got: {warnings:?}"
920 );
921 if warnings.len() == 2 {
922 assert_eq!(warnings[0].line, 2);
923 assert_eq!(warnings[1].line, 3);
924 }
925
926 check_warnings_have_fixes(content);
928
929 let fixed_content = fix(content);
930 assert_eq!(
932 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> \n> Quote line 2",
933 "Fix for blockquoted list failed. Got:\n{fixed_content}"
934 );
935
936 let warnings_after_fix = lint(&fixed_content);
938 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
939 }
940
941 #[test]
942 fn test_ordered_list() {
943 let content = "Text\n1. Item 1\n2. Item 2\nText";
944 let warnings = lint(content);
945 assert_eq!(warnings.len(), 2);
946
947 check_warnings_have_fixes(content);
949
950 let fixed_content = fix(content);
951 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\n\nText");
952
953 let warnings_after_fix = lint(&fixed_content);
955 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
956 }
957
958 #[test]
959 fn test_no_double_blank_fix() {
960 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
962 assert_eq!(warnings.len(), 1);
963 if !warnings.is_empty() {
964 assert_eq!(
965 warnings[0].line, 4,
966 "Warning line for missing blank after should be the last line of the block"
967 );
968 }
969
970 check_warnings_have_fixes(content);
972
973 let fixed_content = fix(content);
974 assert_eq!(
975 fixed_content, "Text\n\n- Item 1\n- Item 2\n\nText",
976 "Fix added extra blank after. Got:\n{fixed_content}"
977 );
978
979 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
981 assert_eq!(warnings2.len(), 1);
982 if !warnings2.is_empty() {
983 assert_eq!(
984 warnings2[0].line, 2,
985 "Warning line for missing blank before should be the first line of the block"
986 );
987 }
988
989 check_warnings_have_fixes(content2);
991
992 let fixed_content2 = fix(content2);
993 assert_eq!(
994 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
995 "Fix added extra blank before. Got:\n{fixed_content2}"
996 );
997 }
998
999 #[test]
1000 fn test_empty_input() {
1001 let content = "";
1002 let warnings = lint(content);
1003 assert_eq!(warnings.len(), 0);
1004 let fixed_content = fix(content);
1005 assert_eq!(fixed_content, "");
1006 }
1007
1008 #[test]
1009 fn test_only_list() {
1010 let content = "- Item 1\n- Item 2";
1011 let warnings = lint(content);
1012 assert_eq!(warnings.len(), 0);
1013 let fixed_content = fix(content);
1014 assert_eq!(fixed_content, content);
1015 }
1016
1017 #[test]
1020 fn test_fix_complex_nested_blockquote() {
1021 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1022 let warnings = lint(content);
1023 assert_eq!(
1027 warnings.len(),
1028 2,
1029 "Should warn for missing blanks around the entire list block"
1030 );
1031
1032 check_warnings_have_fixes(content);
1034
1035 let fixed_content = fix(content);
1036 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> \n> Text after";
1037 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1038
1039 let warnings_after_fix = lint(&fixed_content);
1041 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1042 }
1043
1044 #[test]
1045 fn test_fix_mixed_list_markers() {
1046 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1047 let warnings = lint(content);
1048 assert_eq!(
1049 warnings.len(),
1050 2,
1051 "Should warn for missing blanks around mixed marker list"
1052 );
1053
1054 check_warnings_have_fixes(content);
1056
1057 let fixed_content = fix(content);
1058 let expected = "Text\n\n- Item 1\n* Item 2\n+ Item 3\n\nText";
1059 assert_eq!(fixed_content, expected, "Fix should handle mixed list markers");
1060
1061 let warnings_after_fix = lint(&fixed_content);
1063 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1064 }
1065
1066 #[test]
1067 fn test_fix_ordered_list_with_different_numbers() {
1068 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1069 let warnings = lint(content);
1070 assert_eq!(warnings.len(), 2, "Should warn for missing blanks around ordered list");
1071
1072 check_warnings_have_fixes(content);
1074
1075 let fixed_content = fix(content);
1076 let expected = "Text\n\n1. First\n3. Third\n2. Second\n\nText";
1077 assert_eq!(
1078 fixed_content, expected,
1079 "Fix should handle ordered lists with non-sequential numbers"
1080 );
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_code_blocks_inside() {
1089 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1090 let warnings = lint(content);
1091 assert_eq!(
1094 warnings.len(),
1095 3,
1096 "Should warn for missing blanks around list items separated by code blocks"
1097 );
1098
1099 check_warnings_have_fixes(content);
1101
1102 let fixed_content = fix(content);
1103 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n\n- Item 2\n\nText";
1104 assert_eq!(
1105 fixed_content, expected,
1106 "Fix should handle lists with internal code blocks"
1107 );
1108
1109 let warnings_after_fix = lint(&fixed_content);
1111 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1112 }
1113
1114 #[test]
1115 fn test_fix_deeply_nested_lists() {
1116 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1117 let warnings = lint(content);
1118 assert_eq!(
1119 warnings.len(),
1120 2,
1121 "Should warn for missing blanks around deeply nested list"
1122 );
1123
1124 check_warnings_have_fixes(content);
1126
1127 let fixed_content = fix(content);
1128 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\n\nText";
1129 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1130
1131 let warnings_after_fix = lint(&fixed_content);
1133 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1134 }
1135
1136 #[test]
1137 fn test_fix_list_with_multiline_items() {
1138 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1139 let warnings = lint(content);
1140 assert_eq!(
1141 warnings.len(),
1142 2,
1143 "Should warn for missing blanks around multiline list"
1144 );
1145
1146 check_warnings_have_fixes(content);
1148
1149 let fixed_content = fix(content);
1150 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\n\nText";
1151 assert_eq!(fixed_content, expected, "Fix should handle multiline list items");
1152
1153 let warnings_after_fix = lint(&fixed_content);
1155 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1156 }
1157
1158 #[test]
1159 fn test_fix_list_at_document_boundaries() {
1160 let content1 = "- Item 1\n- Item 2";
1162 let warnings1 = lint(content1);
1163 assert_eq!(
1164 warnings1.len(),
1165 0,
1166 "List at document start should not need blank before"
1167 );
1168 let fixed1 = fix(content1);
1169 assert_eq!(fixed1, content1, "No fix needed for list at start");
1170
1171 let content2 = "Text\n- Item 1\n- Item 2";
1173 let warnings2 = lint(content2);
1174 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1175 check_warnings_have_fixes(content2);
1176 let fixed2 = fix(content2);
1177 assert_eq!(
1178 fixed2, "Text\n\n- Item 1\n- Item 2",
1179 "Should add blank before list at end"
1180 );
1181 }
1182
1183 #[test]
1184 fn test_fix_preserves_existing_blank_lines() {
1185 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1186 let warnings = lint(content);
1187 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1188 let fixed_content = fix(content);
1189 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1190 }
1191
1192 #[test]
1193 fn test_fix_handles_tabs_and_spaces() {
1194 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1195 let warnings = lint(content);
1196 assert_eq!(warnings.len(), 2, "Should warn regardless of indentation type");
1197
1198 check_warnings_have_fixes(content);
1200
1201 let fixed_content = fix(content);
1202 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\n\nText";
1203 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1204
1205 let warnings_after_fix = lint(&fixed_content);
1207 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1208 }
1209
1210 #[test]
1211 fn test_fix_warning_objects_have_correct_ranges() {
1212 let content = "Text\n- Item 1\n- Item 2\nText";
1213 let warnings = lint(content);
1214 assert_eq!(warnings.len(), 2);
1215
1216 for warning in &warnings {
1218 assert!(warning.fix.is_some(), "Warning should have fix");
1219 let fix = warning.fix.as_ref().unwrap();
1220 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1221 assert!(
1222 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1223 "Fix should have replacement or be insertion"
1224 );
1225 }
1226 }
1227
1228 #[test]
1229 fn test_fix_idempotent() {
1230 let content = "Text\n- Item 1\n- Item 2\nText";
1231
1232 let fixed_once = fix(content);
1234 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\n\nText");
1235
1236 let fixed_twice = fix(&fixed_once);
1238 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1239
1240 let warnings_after_fix = lint(&fixed_once);
1242 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1243 }
1244
1245 #[test]
1246 fn test_fix_with_windows_line_endings() {
1247 let content = "Text\r\n- Item 1\r\n- Item 2\r\nText";
1248 let warnings = lint(content);
1249 assert_eq!(warnings.len(), 2, "Should detect issues with Windows line endings");
1250
1251 check_warnings_have_fixes(content);
1253
1254 let fixed_content = fix(content);
1255 let expected = "Text\n\n- Item 1\n- Item 2\n\nText";
1257 assert_eq!(fixed_content, expected, "Fix should handle Windows line endings");
1258 }
1259
1260 #[test]
1261 fn test_fix_preserves_final_newline() {
1262 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1264 let fixed_with_newline = fix(content_with_newline);
1265 assert!(
1266 fixed_with_newline.ends_with('\n'),
1267 "Fix should preserve final newline when present"
1268 );
1269 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\n\nText\n");
1270
1271 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1273 let fixed_without_newline = fix(content_without_newline);
1274 assert!(
1275 !fixed_without_newline.ends_with('\n'),
1276 "Fix should not add final newline when not present"
1277 );
1278 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\n\nText");
1279 }
1280
1281 #[test]
1282 fn test_fix_multiline_list_items_no_indent() {
1283 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";
1284
1285 let warnings = lint(content);
1286 assert_eq!(
1288 warnings.len(),
1289 0,
1290 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1291 );
1292
1293 let fixed_content = fix(content);
1294 assert_eq!(
1296 fixed_content, content,
1297 "Should not modify correctly formatted multi-line list items"
1298 );
1299 }
1300}