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;
6
7mod md032_config;
8pub use md032_config::MD032Config;
9
10static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
12
13fn is_thematic_break(line: &str) -> bool {
16 let leading_spaces = line.len() - line.trim_start_matches(' ').len();
17 if leading_spaces > 3 || line.starts_with('\t') {
18 return false;
19 }
20
21 let trimmed = line.trim();
22 if trimmed.len() < 3 {
23 return false;
24 }
25
26 let chars: Vec<char> = trimmed.chars().collect();
27 let first_non_space = chars.iter().find(|&&c| c != ' ');
28
29 if let Some(&marker) = first_non_space {
30 if marker != '-' && marker != '*' && marker != '_' {
31 return false;
32 }
33 let marker_count = chars.iter().filter(|&&c| c == marker).count();
34 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
35 marker_count >= 3 && other_count == 0
36 } else {
37 false
38 }
39}
40
41#[derive(Debug, Clone, Default)]
111pub struct MD032BlanksAroundLists {
112 config: MD032Config,
113}
114
115impl MD032BlanksAroundLists {
116 pub fn from_config_struct(config: MD032Config) -> Self {
117 Self { config }
118 }
119}
120
121impl MD032BlanksAroundLists {
122 fn should_require_blank_line_before(
124 ctx: &crate::lint_context::LintContext,
125 prev_line_num: usize,
126 current_line_num: usize,
127 ) -> bool {
128 if ctx
130 .line_info(prev_line_num)
131 .is_some_and(|info| info.in_code_block || info.in_front_matter)
132 {
133 return true;
134 }
135
136 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
138 return false;
139 }
140
141 true
143 }
144
145 fn is_nested_list(
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_standalone_code_fence = false;
187
188 let min_indent_for_content = if block.is_ordered {
190 3 } else {
194 2 };
197
198 for check_line in (prev_item_line + 1)..item_line {
199 if check_line - 1 < ctx.lines.len() {
200 let line = &ctx.lines[check_line - 1];
201 let line_content = line.content(ctx.content);
202 if line.in_code_block
203 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
204 {
205 if line.indent < min_indent_for_content {
208 has_standalone_code_fence = true;
209 break;
210 }
211 }
212 }
213 }
214
215 if has_standalone_code_fence {
216 segments.push((current_start, prev_item_line));
218 current_start = item_line;
219 }
220 }
221 prev_item_line = item_line;
222 }
223
224 if prev_item_line > 0 {
227 segments.push((current_start, prev_item_line));
228 }
229
230 let has_code_fence_splits = segments.len() > 1 && {
232 let mut found_fence = false;
234 for i in 0..segments.len() - 1 {
235 let seg_end = segments[i].1;
236 let next_start = segments[i + 1].0;
237 for check_line in (seg_end + 1)..next_start {
239 if check_line - 1 < ctx.lines.len() {
240 let line = &ctx.lines[check_line - 1];
241 let line_content = line.content(ctx.content);
242 if line.in_code_block
243 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
244 {
245 found_fence = true;
246 break;
247 }
248 }
249 }
250 if found_fence {
251 break;
252 }
253 }
254 found_fence
255 };
256
257 for (start, end) in segments.iter() {
259 let mut actual_end = *end;
261
262 if !has_code_fence_splits && *end < block.end_line {
265 for check_line in (*end + 1)..=block.end_line {
266 if check_line - 1 < ctx.lines.len() {
267 let line = &ctx.lines[check_line - 1];
268 let line_content = line.content(ctx.content);
269 if block.item_lines.contains(&check_line) || line.heading.is_some() {
271 break;
272 }
273 if line.in_code_block {
275 break;
276 }
277 if line.indent >= 2 {
279 actual_end = check_line;
280 }
281 else if self.config.allow_lazy_continuation
286 && !line.is_blank
287 && line.heading.is_none()
288 && !block.item_lines.contains(&check_line)
289 && !is_thematic_break(line_content)
290 {
291 actual_end = check_line;
294 } else if !line.is_blank {
295 break;
297 }
298 }
299 }
300 }
301
302 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
303 }
304 }
305
306 blocks
307 }
308
309 fn perform_checks(
310 &self,
311 ctx: &crate::lint_context::LintContext,
312 lines: &[&str],
313 list_blocks: &[(usize, usize, String)],
314 line_index: &LineIndex,
315 ) -> LintResult {
316 let mut warnings = Vec::new();
317 let num_lines = lines.len();
318
319 for (line_idx, line) in lines.iter().enumerate() {
322 let line_num = line_idx + 1;
323
324 let is_in_list = list_blocks
326 .iter()
327 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
328 if is_in_list {
329 continue;
330 }
331
332 if ctx
334 .line_info(line_num)
335 .is_some_and(|info| info.in_code_block || info.in_front_matter)
336 {
337 continue;
338 }
339
340 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
342 if line_idx > 0 {
344 let prev_line = lines[line_idx - 1];
345 let prev_is_blank = is_blank_in_context(prev_line);
346 let prev_excluded = ctx
347 .line_info(line_idx)
348 .is_some_and(|info| info.in_code_block || info.in_front_matter);
349
350 if !prev_is_blank && !prev_excluded {
351 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
353
354 warnings.push(LintWarning {
355 line: start_line,
356 column: start_col,
357 end_line,
358 end_column: end_col,
359 severity: Severity::Warning,
360 rule_name: Some(self.name().to_string()),
361 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
362 fix: Some(Fix {
363 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
364 replacement: "\n".to_string(),
365 }),
366 });
367 }
368 }
369 }
370 }
371
372 for &(start_line, end_line, ref prefix) in list_blocks {
373 if start_line > 1 {
374 let prev_line_actual_idx_0 = start_line - 2;
375 let prev_line_actual_idx_1 = start_line - 1;
376 let prev_line_str = lines[prev_line_actual_idx_0];
377 let is_prev_excluded = ctx
378 .line_info(prev_line_actual_idx_1)
379 .is_some_and(|info| info.in_code_block || info.in_front_matter);
380 let prev_prefix = BLOCKQUOTE_PREFIX_RE
381 .find(prev_line_str)
382 .map_or(String::new(), |m| m.as_str().to_string());
383 let prev_is_blank = is_blank_in_context(prev_line_str);
384 let prefixes_match = prev_prefix.trim() == prefix.trim();
385
386 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
389 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
390 let (start_line, start_col, end_line, end_col) =
392 calculate_line_range(start_line, lines[start_line - 1]);
393
394 warnings.push(LintWarning {
395 line: start_line,
396 column: start_col,
397 end_line,
398 end_column: end_col,
399 severity: Severity::Warning,
400 rule_name: Some(self.name().to_string()),
401 message: "List should be preceded by blank line".to_string(),
402 fix: Some(Fix {
403 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
404 replacement: format!("{prefix}\n"),
405 }),
406 });
407 }
408 }
409
410 if end_line < num_lines {
411 let next_line_idx_0 = end_line;
412 let next_line_idx_1 = end_line + 1;
413 let next_line_str = lines[next_line_idx_0];
414 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
417 || (next_line_idx_0 < ctx.lines.len()
418 && ctx.lines[next_line_idx_0].in_code_block
419 && ctx.lines[next_line_idx_0].indent >= 2);
420 let next_prefix = BLOCKQUOTE_PREFIX_RE
421 .find(next_line_str)
422 .map_or(String::new(), |m| m.as_str().to_string());
423 let next_is_blank = is_blank_in_context(next_line_str);
424 let prefixes_match = next_prefix.trim() == prefix.trim();
425
426 if !is_next_excluded && !next_is_blank && prefixes_match {
428 let (start_line_last, start_col_last, end_line_last, end_col_last) =
430 calculate_line_range(end_line, lines[end_line - 1]);
431
432 warnings.push(LintWarning {
433 line: start_line_last,
434 column: start_col_last,
435 end_line: end_line_last,
436 end_column: end_col_last,
437 severity: Severity::Warning,
438 rule_name: Some(self.name().to_string()),
439 message: "List should be followed by blank line".to_string(),
440 fix: Some(Fix {
441 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
442 replacement: format!("{prefix}\n"),
443 }),
444 });
445 }
446 }
447 }
448 Ok(warnings)
449 }
450}
451
452impl Rule for MD032BlanksAroundLists {
453 fn name(&self) -> &'static str {
454 "MD032"
455 }
456
457 fn description(&self) -> &'static str {
458 "Lists should be surrounded by blank lines"
459 }
460
461 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
462 let content = ctx.content;
463 let lines: Vec<&str> = content.lines().collect();
464 let line_index = &ctx.line_index;
465
466 if lines.is_empty() {
468 return Ok(Vec::new());
469 }
470
471 let list_blocks = self.convert_list_blocks(ctx);
472
473 if list_blocks.is_empty() {
474 return Ok(Vec::new());
475 }
476
477 self.perform_checks(ctx, &lines, &list_blocks, line_index)
478 }
479
480 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
481 self.fix_with_structure_impl(ctx)
482 }
483
484 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
485 if ctx.content.is_empty() || !ctx.likely_has_lists() {
487 return true;
488 }
489 ctx.list_blocks.is_empty()
491 }
492
493 fn category(&self) -> RuleCategory {
494 RuleCategory::List
495 }
496
497 fn as_any(&self) -> &dyn std::any::Any {
498 self
499 }
500
501 fn default_config_section(&self) -> Option<(String, toml::Value)> {
502 use crate::rule_config_serde::RuleConfig;
503 let default_config = MD032Config::default();
504 let json_value = serde_json::to_value(&default_config).ok()?;
505 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
506
507 if let toml::Value::Table(table) = toml_value {
508 if !table.is_empty() {
509 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
510 } else {
511 None
512 }
513 } else {
514 None
515 }
516 }
517
518 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
519 where
520 Self: Sized,
521 {
522 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
523 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
524 }
525}
526
527impl MD032BlanksAroundLists {
528 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
530 let lines: Vec<&str> = ctx.content.lines().collect();
531 let num_lines = lines.len();
532 if num_lines == 0 {
533 return Ok(String::new());
534 }
535
536 let list_blocks = self.convert_list_blocks(ctx);
537 if list_blocks.is_empty() {
538 return Ok(ctx.content.to_string());
539 }
540
541 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
542
543 for &(start_line, end_line, ref prefix) in &list_blocks {
545 if start_line > 1 {
547 let prev_line_actual_idx_0 = start_line - 2;
548 let prev_line_actual_idx_1 = start_line - 1;
549 let is_prev_excluded = ctx
550 .line_info(prev_line_actual_idx_1)
551 .is_some_and(|info| info.in_code_block || info.in_front_matter);
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(ctx, prev_line_actual_idx_1, start_line);
557 if !is_prev_excluded
558 && !is_blank_in_context(lines[prev_line_actual_idx_0])
559 && prev_prefix == *prefix
560 && should_require
561 {
562 insertions.insert(start_line, prefix.clone());
563 }
564 }
565
566 if end_line < num_lines {
568 let after_block_line_idx_0 = end_line;
569 let after_block_line_idx_1 = end_line + 1;
570 let line_after_block_content_str = lines[after_block_line_idx_0];
571 let is_line_after_excluded = ctx
574 .line_info(after_block_line_idx_1)
575 .is_some_and(|info| info.in_code_block || info.in_front_matter)
576 || (after_block_line_idx_0 < ctx.lines.len()
577 && ctx.lines[after_block_line_idx_0].in_code_block
578 && ctx.lines[after_block_line_idx_0].indent >= 2
579 && (ctx.lines[after_block_line_idx_0]
580 .content(ctx.content)
581 .trim()
582 .starts_with("```")
583 || ctx.lines[after_block_line_idx_0]
584 .content(ctx.content)
585 .trim()
586 .starts_with("~~~")));
587 let after_prefix = BLOCKQUOTE_PREFIX_RE
588 .find(line_after_block_content_str)
589 .map_or(String::new(), |m| m.as_str().to_string());
590
591 if !is_line_after_excluded
592 && !is_blank_in_context(line_after_block_content_str)
593 && after_prefix == *prefix
594 {
595 insertions.insert(after_block_line_idx_1, prefix.clone());
596 }
597 }
598 }
599
600 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
602 for (i, line) in lines.iter().enumerate() {
603 let current_line_num = i + 1;
604 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
605 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
606 {
607 result_lines.push(prefix_to_insert.clone());
608 }
609 result_lines.push(line.to_string());
610 }
611
612 let mut result = result_lines.join("\n");
614 if ctx.content.ends_with('\n') {
615 result.push('\n');
616 }
617 Ok(result)
618 }
619}
620
621fn is_blank_in_context(line: &str) -> bool {
623 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
626 line[m.end()..].trim().is_empty()
628 } else {
629 line.trim().is_empty()
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::lint_context::LintContext;
638 use crate::rule::Rule;
639
640 fn lint(content: &str) -> Vec<LintWarning> {
641 let rule = MD032BlanksAroundLists::default();
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 rule.check(&ctx).expect("Lint check failed")
644 }
645
646 fn fix(content: &str) -> String {
647 let rule = MD032BlanksAroundLists::default();
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 rule.fix(&ctx).expect("Lint fix failed")
650 }
651
652 fn check_warnings_have_fixes(content: &str) {
654 let warnings = lint(content);
655 for warning in &warnings {
656 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
657 }
658 }
659
660 #[test]
661 fn test_list_at_start() {
662 let content = "- Item 1\n- Item 2\nText";
665 let warnings = lint(content);
666 assert_eq!(
667 warnings.len(),
668 0,
669 "Trailing text is lazy continuation per CommonMark - no warning expected"
670 );
671 }
672
673 #[test]
674 fn test_list_at_end() {
675 let content = "Text\n- Item 1\n- Item 2";
676 let warnings = lint(content);
677 assert_eq!(
678 warnings.len(),
679 1,
680 "Expected 1 warning for list at end without preceding blank line"
681 );
682 assert_eq!(
683 warnings[0].line, 2,
684 "Warning should be on the first line of the list (line 2)"
685 );
686 assert!(warnings[0].message.contains("preceded by blank line"));
687
688 check_warnings_have_fixes(content);
690
691 let fixed_content = fix(content);
692 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
693
694 let warnings_after_fix = lint(&fixed_content);
696 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
697 }
698
699 #[test]
700 fn test_list_in_middle() {
701 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
704 let warnings = lint(content);
705 assert_eq!(
706 warnings.len(),
707 1,
708 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
709 );
710 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
711 assert!(warnings[0].message.contains("preceded by blank line"));
712
713 check_warnings_have_fixes(content);
715
716 let fixed_content = fix(content);
717 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
718
719 let warnings_after_fix = lint(&fixed_content);
721 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
722 }
723
724 #[test]
725 fn test_correct_spacing() {
726 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
727 let warnings = lint(content);
728 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
729
730 let fixed_content = fix(content);
731 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
732 }
733
734 #[test]
735 fn test_list_with_content() {
736 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
739 let warnings = lint(content);
740 assert_eq!(
741 warnings.len(),
742 1,
743 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
744 );
745 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
746 assert!(warnings[0].message.contains("preceded by blank line"));
747
748 check_warnings_have_fixes(content);
750
751 let fixed_content = fix(content);
752 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
753 assert_eq!(
754 fixed_content, expected_fixed,
755 "Fix did not produce the expected output. Got:\n{fixed_content}"
756 );
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_nested_list() {
765 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
767 let warnings = lint(content);
768 assert_eq!(
769 warnings.len(),
770 1,
771 "Nested list block needs preceding blank only. Got: {warnings:?}"
772 );
773 assert_eq!(warnings[0].line, 2);
774 assert!(warnings[0].message.contains("preceded by blank line"));
775
776 check_warnings_have_fixes(content);
778
779 let fixed_content = fix(content);
780 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
781
782 let warnings_after_fix = lint(&fixed_content);
784 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
785 }
786
787 #[test]
788 fn test_list_with_internal_blanks() {
789 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
791 let warnings = lint(content);
792 assert_eq!(
793 warnings.len(),
794 1,
795 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
796 );
797 assert_eq!(warnings[0].line, 2);
798 assert!(warnings[0].message.contains("preceded by blank line"));
799
800 check_warnings_have_fixes(content);
802
803 let fixed_content = fix(content);
804 assert_eq!(
805 fixed_content,
806 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
807 );
808
809 let warnings_after_fix = lint(&fixed_content);
811 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
812 }
813
814 #[test]
815 fn test_ignore_code_blocks() {
816 let content = "```\n- Not a list item\n```\nText";
817 let warnings = lint(content);
818 assert_eq!(warnings.len(), 0);
819 let fixed_content = fix(content);
820 assert_eq!(fixed_content, content);
821 }
822
823 #[test]
824 fn test_ignore_front_matter() {
825 let content = "---\ntitle: Test\n---\n- List Item\nText";
827 let warnings = lint(content);
828 assert_eq!(
829 warnings.len(),
830 0,
831 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
832 );
833
834 let fixed_content = fix(content);
836 assert_eq!(fixed_content, content, "No changes when no warnings");
837 }
838
839 #[test]
840 fn test_multiple_lists() {
841 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
846 let warnings = lint(content);
847 assert!(
849 !warnings.is_empty(),
850 "Should have at least one warning for missing blank line. Got: {warnings:?}"
851 );
852
853 check_warnings_have_fixes(content);
855
856 let fixed_content = fix(content);
857 let warnings_after_fix = lint(&fixed_content);
859 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
860 }
861
862 #[test]
863 fn test_adjacent_lists() {
864 let content = "- List 1\n\n* List 2";
865 let warnings = lint(content);
866 assert_eq!(warnings.len(), 0);
867 let fixed_content = fix(content);
868 assert_eq!(fixed_content, content);
869 }
870
871 #[test]
872 fn test_list_in_blockquote() {
873 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
875 let warnings = lint(content);
876 assert_eq!(
877 warnings.len(),
878 1,
879 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
880 );
881 assert_eq!(warnings[0].line, 2);
882
883 check_warnings_have_fixes(content);
885
886 let fixed_content = fix(content);
887 assert_eq!(
889 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
890 "Fix for blockquoted list failed. Got:\n{fixed_content}"
891 );
892
893 let warnings_after_fix = lint(&fixed_content);
895 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
896 }
897
898 #[test]
899 fn test_ordered_list() {
900 let content = "Text\n1. Item 1\n2. Item 2\nText";
902 let warnings = lint(content);
903 assert_eq!(warnings.len(), 1);
904
905 check_warnings_have_fixes(content);
907
908 let fixed_content = fix(content);
909 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
910
911 let warnings_after_fix = lint(&fixed_content);
913 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
914 }
915
916 #[test]
917 fn test_no_double_blank_fix() {
918 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
921 assert_eq!(
922 warnings.len(),
923 0,
924 "Should have no warnings - properly preceded, trailing is lazy"
925 );
926
927 let fixed_content = fix(content);
928 assert_eq!(
929 fixed_content, content,
930 "No fix needed when no warnings. 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";
977 let warnings = lint(content);
978 assert_eq!(
979 warnings.len(),
980 1,
981 "Should warn for missing preceding blank only. Got: {warnings:?}"
982 );
983
984 check_warnings_have_fixes(content);
986
987 let fixed_content = fix(content);
988 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
989 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
990
991 let warnings_after_fix = lint(&fixed_content);
992 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
993 }
994
995 #[test]
996 fn test_fix_mixed_list_markers() {
997 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1000 let warnings = lint(content);
1001 assert!(
1003 !warnings.is_empty(),
1004 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1005 );
1006
1007 check_warnings_have_fixes(content);
1009
1010 let fixed_content = fix(content);
1011 assert!(
1013 fixed_content.contains("Text\n\n-"),
1014 "Fix should add blank line before first list item"
1015 );
1016
1017 let warnings_after_fix = lint(&fixed_content);
1019 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1020 }
1021
1022 #[test]
1023 fn test_fix_ordered_list_with_different_numbers() {
1024 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1026 let warnings = lint(content);
1027 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1028
1029 check_warnings_have_fixes(content);
1031
1032 let fixed_content = fix(content);
1033 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1034 assert_eq!(
1035 fixed_content, expected,
1036 "Fix should handle ordered lists with non-sequential numbers"
1037 );
1038
1039 let warnings_after_fix = lint(&fixed_content);
1041 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1042 }
1043
1044 #[test]
1045 fn test_fix_list_with_code_blocks_inside() {
1046 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1048 let warnings = lint(content);
1049 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1050
1051 check_warnings_have_fixes(content);
1053
1054 let fixed_content = fix(content);
1055 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1056 assert_eq!(
1057 fixed_content, expected,
1058 "Fix should handle lists with internal code blocks"
1059 );
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_deeply_nested_lists() {
1068 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1070 let warnings = lint(content);
1071 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1072
1073 check_warnings_have_fixes(content);
1075
1076 let fixed_content = fix(content);
1077 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1078 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1079
1080 let warnings_after_fix = lint(&fixed_content);
1082 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1083 }
1084
1085 #[test]
1086 fn test_fix_list_with_multiline_items() {
1087 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 1,
1094 "Should only warn for missing blank before list (trailing text is lazy continuation)"
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\nText";
1102 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
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";
1147 let warnings = lint(content);
1148 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1151
1152 check_warnings_have_fixes(content);
1154
1155 let fixed_content = fix(content);
1156 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\nText";
1158 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1159
1160 let warnings_after_fix = lint(&fixed_content);
1162 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1163 }
1164
1165 #[test]
1166 fn test_fix_warning_objects_have_correct_ranges() {
1167 let content = "Text\n- Item 1\n- Item 2\nText";
1169 let warnings = lint(content);
1170 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1171
1172 for warning in &warnings {
1174 assert!(warning.fix.is_some(), "Warning should have fix");
1175 let fix = warning.fix.as_ref().unwrap();
1176 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1177 assert!(
1178 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1179 "Fix should have replacement or be insertion"
1180 );
1181 }
1182 }
1183
1184 #[test]
1185 fn test_fix_idempotent() {
1186 let content = "Text\n- Item 1\n- Item 2\nText";
1188
1189 let fixed_once = fix(content);
1191 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1192
1193 let fixed_twice = fix(&fixed_once);
1195 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1196
1197 let warnings_after_fix = lint(&fixed_once);
1199 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1200 }
1201
1202 #[test]
1203 fn test_fix_with_normalized_line_endings() {
1204 let content = "Text\n- Item 1\n- Item 2\nText";
1208 let warnings = lint(content);
1209 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1210
1211 check_warnings_have_fixes(content);
1213
1214 let fixed_content = fix(content);
1215 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1217 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1218 }
1219
1220 #[test]
1221 fn test_fix_preserves_final_newline() {
1222 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1225 let fixed_with_newline = fix(content_with_newline);
1226 assert!(
1227 fixed_with_newline.ends_with('\n'),
1228 "Fix should preserve final newline when present"
1229 );
1230 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1232
1233 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1235 let fixed_without_newline = fix(content_without_newline);
1236 assert!(
1237 !fixed_without_newline.ends_with('\n'),
1238 "Fix should not add final newline when not present"
1239 );
1240 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1242 }
1243
1244 #[test]
1245 fn test_fix_multiline_list_items_no_indent() {
1246 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";
1247
1248 let warnings = lint(content);
1249 assert_eq!(
1251 warnings.len(),
1252 0,
1253 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1254 );
1255
1256 let fixed_content = fix(content);
1257 assert_eq!(
1259 fixed_content, content,
1260 "Should not modify correctly formatted multi-line list items"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_nested_list_with_lazy_continuation() {
1266 let content = r#"# Test
1272
1273- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1274 1. Switch/case dispatcher statements (original Phase 3.2)
1275 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1276`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1277 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1278 references"#;
1279
1280 let warnings = lint(content);
1281 let md032_warnings: Vec<_> = warnings
1284 .iter()
1285 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1286 .collect();
1287 assert_eq!(
1288 md032_warnings.len(),
1289 0,
1290 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_pipes_in_code_spans_not_detected_as_table() {
1296 let content = r#"# Test
1298
1299- Item with `a | b` inline code
1300 - Nested item should work
1301
1302"#;
1303
1304 let warnings = lint(content);
1305 let md032_warnings: Vec<_> = warnings
1306 .iter()
1307 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1308 .collect();
1309 assert_eq!(
1310 md032_warnings.len(),
1311 0,
1312 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_multiple_code_spans_with_pipes() {
1318 let content = r#"# Test
1320
1321- Item with `a | b` and `c || d` operators
1322 - Nested item should work
1323
1324"#;
1325
1326 let warnings = lint(content);
1327 let md032_warnings: Vec<_> = warnings
1328 .iter()
1329 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1330 .collect();
1331 assert_eq!(
1332 md032_warnings.len(),
1333 0,
1334 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_actual_table_breaks_list() {
1340 let content = r#"# Test
1342
1343- Item before table
1344
1345| Col1 | Col2 |
1346|------|------|
1347| A | B |
1348
1349- Item after table
1350
1351"#;
1352
1353 let warnings = lint(content);
1354 let md032_warnings: Vec<_> = warnings
1356 .iter()
1357 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1358 .collect();
1359 assert_eq!(
1360 md032_warnings.len(),
1361 0,
1362 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_thematic_break_not_lazy_continuation() {
1368 let content = r#"- Item 1
1371- Item 2
1372***
1373
1374More text.
1375"#;
1376
1377 let warnings = lint(content);
1378 let md032_warnings: Vec<_> = warnings
1379 .iter()
1380 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1381 .collect();
1382 assert_eq!(
1383 md032_warnings.len(),
1384 1,
1385 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1386 );
1387 assert!(
1388 md032_warnings[0].message.contains("followed by blank line"),
1389 "Warning should be about missing blank after list"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_thematic_break_with_blank_line() {
1395 let content = r#"- Item 1
1397- Item 2
1398
1399***
1400
1401More text.
1402"#;
1403
1404 let warnings = lint(content);
1405 let md032_warnings: Vec<_> = warnings
1406 .iter()
1407 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1408 .collect();
1409 assert_eq!(
1410 md032_warnings.len(),
1411 0,
1412 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1413 );
1414 }
1415
1416 #[test]
1417 fn test_various_thematic_break_styles() {
1418 for hr in ["---", "***", "___"] {
1423 let content = format!(
1424 r#"- Item 1
1425- Item 2
1426{hr}
1427
1428More text.
1429"#
1430 );
1431
1432 let warnings = lint(&content);
1433 let md032_warnings: Vec<_> = warnings
1434 .iter()
1435 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1436 .collect();
1437 assert_eq!(
1438 md032_warnings.len(),
1439 1,
1440 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1441 );
1442 }
1443 }
1444
1445 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1448 let rule = MD032BlanksAroundLists::from_config_struct(config);
1449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450 rule.check(&ctx).expect("Lint check failed")
1451 }
1452
1453 fn fix_with_config(content: &str, config: MD032Config) -> String {
1454 let rule = MD032BlanksAroundLists::from_config_struct(config);
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 rule.fix(&ctx).expect("Lint fix failed")
1457 }
1458
1459 #[test]
1460 fn test_lazy_continuation_allowed_by_default() {
1461 let content = "# Heading\n\n1. List\nSome text.";
1463 let warnings = lint(content);
1464 assert_eq!(
1465 warnings.len(),
1466 0,
1467 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_lazy_continuation_disallowed() {
1473 let content = "# Heading\n\n1. List\nSome text.";
1475 let config = MD032Config {
1476 allow_lazy_continuation: false,
1477 };
1478 let warnings = lint_with_config(content, config);
1479 assert_eq!(
1480 warnings.len(),
1481 1,
1482 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1483 );
1484 assert!(
1485 warnings[0].message.contains("followed by blank line"),
1486 "Warning message should mention blank line"
1487 );
1488 }
1489
1490 #[test]
1491 fn test_lazy_continuation_fix() {
1492 let content = "# Heading\n\n1. List\nSome text.";
1494 let config = MD032Config {
1495 allow_lazy_continuation: false,
1496 };
1497 let fixed = fix_with_config(content, config.clone());
1498 assert_eq!(
1499 fixed, "# Heading\n\n1. List\n\nSome text.",
1500 "Fix should insert blank line before lazy continuation"
1501 );
1502
1503 let warnings_after = lint_with_config(&fixed, config);
1505 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1506 }
1507
1508 #[test]
1509 fn test_lazy_continuation_multiple_lines() {
1510 let content = "- Item 1\nLine 2\nLine 3";
1512 let config = MD032Config {
1513 allow_lazy_continuation: false,
1514 };
1515 let warnings = lint_with_config(content, config.clone());
1516 assert_eq!(
1517 warnings.len(),
1518 1,
1519 "Should warn for lazy continuation. Got: {warnings:?}"
1520 );
1521
1522 let fixed = fix_with_config(content, config.clone());
1523 assert_eq!(
1524 fixed, "- Item 1\n\nLine 2\nLine 3",
1525 "Fix should insert blank line after list"
1526 );
1527
1528 let warnings_after = lint_with_config(&fixed, config);
1530 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1531 }
1532
1533 #[test]
1534 fn test_lazy_continuation_with_indented_content() {
1535 let content = "- Item 1\n Indented content\nLazy text";
1537 let config = MD032Config {
1538 allow_lazy_continuation: false,
1539 };
1540 let warnings = lint_with_config(content, config);
1541 assert_eq!(
1542 warnings.len(),
1543 1,
1544 "Should warn for lazy text after indented content. Got: {warnings:?}"
1545 );
1546 }
1547
1548 #[test]
1549 fn test_lazy_continuation_properly_separated() {
1550 let content = "- Item 1\n\nSome text.";
1552 let config = MD032Config {
1553 allow_lazy_continuation: false,
1554 };
1555 let warnings = lint_with_config(content, config);
1556 assert_eq!(
1557 warnings.len(),
1558 0,
1559 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1560 );
1561 }
1562
1563 #[test]
1566 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1567 let content = "1) First item\nLazy continuation";
1569 let config = MD032Config {
1570 allow_lazy_continuation: false,
1571 };
1572 let warnings = lint_with_config(content, config.clone());
1573 assert_eq!(
1574 warnings.len(),
1575 1,
1576 "Should warn for lazy continuation with parenthesis marker"
1577 );
1578
1579 let fixed = fix_with_config(content, config);
1580 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1581 }
1582
1583 #[test]
1584 fn test_lazy_continuation_followed_by_another_list() {
1585 let content = "- Item 1\nSome text\n- Item 2";
1590 let config = MD032Config {
1591 allow_lazy_continuation: false,
1592 };
1593 let warnings = lint_with_config(content, config);
1594 assert_eq!(
1597 warnings.len(),
1598 0,
1599 "Valid list structure should not trigger lazy continuation warning"
1600 );
1601 }
1602
1603 #[test]
1604 fn test_lazy_continuation_multiple_in_document() {
1605 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1611 let config = MD032Config {
1612 allow_lazy_continuation: false,
1613 };
1614 let warnings = lint_with_config(content, config.clone());
1615 assert_eq!(
1616 warnings.len(),
1617 1,
1618 "Should warn for second list (not followed by blank). Got: {warnings:?}"
1619 );
1620
1621 let fixed = fix_with_config(content, config.clone());
1622 let warnings_after = lint_with_config(&fixed, config);
1623 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1624 }
1625
1626 #[test]
1627 fn test_lazy_continuation_end_of_document_no_newline() {
1628 let content = "- Item\nNo trailing newline";
1630 let config = MD032Config {
1631 allow_lazy_continuation: false,
1632 };
1633 let warnings = lint_with_config(content, config.clone());
1634 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1635
1636 let fixed = fix_with_config(content, config);
1637 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1638 }
1639
1640 #[test]
1641 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1642 let content = "- Item 1\n---";
1645 let config = MD032Config {
1646 allow_lazy_continuation: false,
1647 };
1648 let warnings = lint_with_config(content, config.clone());
1649 assert_eq!(
1651 warnings.len(),
1652 1,
1653 "List should need blank line before thematic break. Got: {warnings:?}"
1654 );
1655
1656 let fixed = fix_with_config(content, config);
1658 assert_eq!(fixed, "- Item 1\n\n---");
1659 }
1660
1661 #[test]
1662 fn test_lazy_continuation_heading_not_flagged() {
1663 let content = "- Item 1\n# Heading";
1666 let config = MD032Config {
1667 allow_lazy_continuation: false,
1668 };
1669 let warnings = lint_with_config(content, config);
1670 assert!(
1673 warnings.iter().all(|w| !w.message.contains("lazy")),
1674 "Heading should not trigger lazy continuation warning"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_lazy_continuation_mixed_list_types() {
1680 let content = "- Unordered\n1. Ordered\nLazy text";
1682 let config = MD032Config {
1683 allow_lazy_continuation: false,
1684 };
1685 let warnings = lint_with_config(content, config.clone());
1686 assert!(!warnings.is_empty(), "Should warn about structure issues");
1687 }
1688
1689 #[test]
1690 fn test_lazy_continuation_deep_nesting() {
1691 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1693 let config = MD032Config {
1694 allow_lazy_continuation: false,
1695 };
1696 let warnings = lint_with_config(content, config.clone());
1697 assert!(
1698 !warnings.is_empty(),
1699 "Should warn about lazy continuation after nested list"
1700 );
1701
1702 let fixed = fix_with_config(content, config.clone());
1703 let warnings_after = lint_with_config(&fixed, config);
1704 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1705 }
1706
1707 #[test]
1708 fn test_lazy_continuation_with_emphasis_in_text() {
1709 let content = "- Item\n*emphasized* continuation";
1711 let config = MD032Config {
1712 allow_lazy_continuation: false,
1713 };
1714 let warnings = lint_with_config(content, config.clone());
1715 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1716
1717 let fixed = fix_with_config(content, config);
1718 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1719 }
1720
1721 #[test]
1722 fn test_lazy_continuation_with_code_span() {
1723 let content = "- Item\n`code` continuation";
1725 let config = MD032Config {
1726 allow_lazy_continuation: false,
1727 };
1728 let warnings = lint_with_config(content, config.clone());
1729 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1730
1731 let fixed = fix_with_config(content, config);
1732 assert_eq!(fixed, "- Item\n\n`code` continuation");
1733 }
1734
1735 #[test]
1736 fn test_lazy_continuation_whitespace_only_line() {
1737 let content = "- Item\n \nText after whitespace-only line";
1740 let config = MD032Config {
1741 allow_lazy_continuation: false,
1742 };
1743 let warnings = lint_with_config(content, config.clone());
1744 assert_eq!(
1746 warnings.len(),
1747 1,
1748 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1749 );
1750
1751 let fixed = fix_with_config(content, config);
1753 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1754 }
1755
1756 #[test]
1757 fn test_lazy_continuation_blockquote_context() {
1758 let content = "> - Item\n> Lazy in quote";
1760 let config = MD032Config {
1761 allow_lazy_continuation: false,
1762 };
1763 let warnings = lint_with_config(content, config);
1764 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1767 }
1768
1769 #[test]
1770 fn test_lazy_continuation_fix_preserves_content() {
1771 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1773 let config = MD032Config {
1774 allow_lazy_continuation: false,
1775 };
1776 let fixed = fix_with_config(content, config);
1777 assert!(fixed.contains("<>&"), "Should preserve special chars");
1778 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1779 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1780 }
1781
1782 #[test]
1783 fn test_lazy_continuation_fix_idempotent() {
1784 let content = "- Item\nLazy";
1786 let config = MD032Config {
1787 allow_lazy_continuation: false,
1788 };
1789 let fixed_once = fix_with_config(content, config.clone());
1790 let fixed_twice = fix_with_config(&fixed_once, config);
1791 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1792 }
1793
1794 #[test]
1795 fn test_lazy_continuation_config_default_allows() {
1796 let content = "- Item\nLazy text that continues";
1798 let default_config = MD032Config::default();
1799 assert!(
1800 default_config.allow_lazy_continuation,
1801 "Default should allow lazy continuation"
1802 );
1803 let warnings = lint_with_config(content, default_config);
1804 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1805 }
1806
1807 #[test]
1808 fn test_lazy_continuation_after_multi_line_item() {
1809 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
1811 let config = MD032Config {
1812 allow_lazy_continuation: false,
1813 };
1814 let warnings = lint_with_config(content, config.clone());
1815 assert_eq!(
1816 warnings.len(),
1817 1,
1818 "Should warn only for the lazy line, not the indented line"
1819 );
1820 }
1821}