1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::range_utils::{LineIndex, calculate_line_range};
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use regex::Regex;
6use std::sync::LazyLock;
7
8mod md032_config;
9pub use md032_config::MD032Config;
10
11static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
13
14fn is_thematic_break(line: &str) -> bool {
17 if ElementCache::calculate_indentation_width_default(line) > 3 {
19 return false;
20 }
21
22 let trimmed = line.trim();
23 if trimmed.len() < 3 {
24 return false;
25 }
26
27 let chars: Vec<char> = trimmed.chars().collect();
28 let first_non_space = chars.iter().find(|&&c| c != ' ');
29
30 if let Some(&marker) = first_non_space {
31 if marker != '-' && marker != '*' && marker != '_' {
32 return false;
33 }
34 let marker_count = chars.iter().filter(|&&c| c == marker).count();
35 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
36 marker_count >= 3 && other_count == 0
37 } else {
38 false
39 }
40}
41
42#[derive(Debug, Clone, Default)]
112pub struct MD032BlanksAroundLists {
113 config: MD032Config,
114}
115
116impl MD032BlanksAroundLists {
117 pub fn from_config_struct(config: MD032Config) -> Self {
118 Self { config }
119 }
120}
121
122impl MD032BlanksAroundLists {
123 fn should_require_blank_line_before(
125 ctx: &crate::lint_context::LintContext,
126 prev_line_num: usize,
127 current_line_num: usize,
128 ) -> bool {
129 if ctx
131 .line_info(prev_line_num)
132 .is_some_and(|info| info.in_code_block || info.in_front_matter)
133 {
134 return true;
135 }
136
137 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
139 return false;
140 }
141
142 true
144 }
145
146 fn is_nested_list(
148 ctx: &crate::lint_context::LintContext,
149 prev_line_num: usize, current_line_num: usize, ) -> bool {
152 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
154 let current_line = &ctx.lines[current_line_num - 1];
155 if current_line.indent >= 2 {
156 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
158 let prev_line = &ctx.lines[prev_line_num - 1];
159 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
161 return true;
162 }
163 }
164 }
165 }
166 false
167 }
168
169 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
171 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
172
173 for block in &ctx.list_blocks {
174 let mut segments: Vec<(usize, usize)> = Vec::new();
180 let mut current_start = block.start_line;
181 let mut prev_item_line = 0;
182
183 for &item_line in &block.item_lines {
184 if prev_item_line > 0 {
185 let mut has_standalone_code_fence = false;
188
189 let min_indent_for_content = if block.is_ordered {
191 3 } else {
195 2 };
198
199 for check_line in (prev_item_line + 1)..item_line {
200 if check_line - 1 < ctx.lines.len() {
201 let line = &ctx.lines[check_line - 1];
202 let line_content = line.content(ctx.content);
203 if line.in_code_block
204 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
205 {
206 if line.indent < min_indent_for_content {
209 has_standalone_code_fence = true;
210 break;
211 }
212 }
213 }
214 }
215
216 if has_standalone_code_fence {
217 segments.push((current_start, prev_item_line));
219 current_start = item_line;
220 }
221 }
222 prev_item_line = item_line;
223 }
224
225 if prev_item_line > 0 {
228 segments.push((current_start, prev_item_line));
229 }
230
231 let has_code_fence_splits = segments.len() > 1 && {
233 let mut found_fence = false;
235 for i in 0..segments.len() - 1 {
236 let seg_end = segments[i].1;
237 let next_start = segments[i + 1].0;
238 for check_line in (seg_end + 1)..next_start {
240 if check_line - 1 < ctx.lines.len() {
241 let line = &ctx.lines[check_line - 1];
242 let line_content = line.content(ctx.content);
243 if line.in_code_block
244 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
245 {
246 found_fence = true;
247 break;
248 }
249 }
250 }
251 if found_fence {
252 break;
253 }
254 }
255 found_fence
256 };
257
258 for (start, end) in segments.iter() {
260 let mut actual_end = *end;
262
263 if !has_code_fence_splits && *end < block.end_line {
266 for check_line in (*end + 1)..=block.end_line {
267 if check_line - 1 < ctx.lines.len() {
268 let line = &ctx.lines[check_line - 1];
269 let line_content = line.content(ctx.content);
270 if block.item_lines.contains(&check_line) || line.heading.is_some() {
272 break;
273 }
274 if line.in_code_block {
276 break;
277 }
278 if line.indent >= 2 {
280 actual_end = check_line;
281 }
282 else if self.config.allow_lazy_continuation
287 && !line.is_blank
288 && line.heading.is_none()
289 && !block.item_lines.contains(&check_line)
290 && !is_thematic_break(line_content)
291 {
292 actual_end = check_line;
295 } else if !line.is_blank {
296 break;
298 }
299 }
300 }
301 }
302
303 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
304 }
305 }
306
307 blocks
308 }
309
310 fn perform_checks(
311 &self,
312 ctx: &crate::lint_context::LintContext,
313 lines: &[&str],
314 list_blocks: &[(usize, usize, String)],
315 line_index: &LineIndex,
316 ) -> LintResult {
317 let mut warnings = Vec::new();
318 let num_lines = lines.len();
319
320 for (line_idx, line) in lines.iter().enumerate() {
323 let line_num = line_idx + 1;
324
325 let is_in_list = list_blocks
327 .iter()
328 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
329 if is_in_list {
330 continue;
331 }
332
333 if ctx
335 .line_info(line_num)
336 .is_some_and(|info| info.in_code_block || info.in_front_matter)
337 {
338 continue;
339 }
340
341 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
343 if line_idx > 0 {
345 let prev_line = lines[line_idx - 1];
346 let prev_is_blank = is_blank_in_context(prev_line);
347 let prev_excluded = ctx
348 .line_info(line_idx)
349 .is_some_and(|info| info.in_code_block || info.in_front_matter);
350
351 if !prev_is_blank && !prev_excluded {
352 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
354
355 warnings.push(LintWarning {
356 line: start_line,
357 column: start_col,
358 end_line,
359 end_column: end_col,
360 severity: Severity::Warning,
361 rule_name: Some(self.name().to_string()),
362 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
363 fix: Some(Fix {
364 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
365 replacement: "\n".to_string(),
366 }),
367 });
368 }
369 }
370 }
371 }
372
373 for &(start_line, end_line, ref prefix) in list_blocks {
374 if start_line > 1 {
375 let prev_line_actual_idx_0 = start_line - 2;
376 let prev_line_actual_idx_1 = start_line - 1;
377 let prev_line_str = lines[prev_line_actual_idx_0];
378 let is_prev_excluded = ctx
379 .line_info(prev_line_actual_idx_1)
380 .is_some_and(|info| info.in_code_block || info.in_front_matter);
381 let prev_prefix = BLOCKQUOTE_PREFIX_RE
382 .find(prev_line_str)
383 .map_or(String::new(), |m| m.as_str().to_string());
384 let prev_is_blank = is_blank_in_context(prev_line_str);
385 let prefixes_match = prev_prefix.trim() == prefix.trim();
386
387 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
390 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
391 let (start_line, start_col, end_line, end_col) =
393 calculate_line_range(start_line, lines[start_line - 1]);
394
395 warnings.push(LintWarning {
396 line: start_line,
397 column: start_col,
398 end_line,
399 end_column: end_col,
400 severity: Severity::Warning,
401 rule_name: Some(self.name().to_string()),
402 message: "List should be preceded by blank line".to_string(),
403 fix: Some(Fix {
404 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
405 replacement: format!("{prefix}\n"),
406 }),
407 });
408 }
409 }
410
411 if end_line < num_lines {
412 let next_line_idx_0 = end_line;
413 let next_line_idx_1 = end_line + 1;
414 let next_line_str = lines[next_line_idx_0];
415 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
418 || (next_line_idx_0 < ctx.lines.len()
419 && ctx.lines[next_line_idx_0].in_code_block
420 && ctx.lines[next_line_idx_0].indent >= 2);
421 let next_prefix = BLOCKQUOTE_PREFIX_RE
422 .find(next_line_str)
423 .map_or(String::new(), |m| m.as_str().to_string());
424 let next_is_blank = is_blank_in_context(next_line_str);
425 let prefixes_match = next_prefix.trim() == prefix.trim();
426
427 if !is_next_excluded && !next_is_blank && prefixes_match {
429 let (start_line_last, start_col_last, end_line_last, end_col_last) =
431 calculate_line_range(end_line, lines[end_line - 1]);
432
433 warnings.push(LintWarning {
434 line: start_line_last,
435 column: start_col_last,
436 end_line: end_line_last,
437 end_column: end_col_last,
438 severity: Severity::Warning,
439 rule_name: Some(self.name().to_string()),
440 message: "List should be followed by blank line".to_string(),
441 fix: Some(Fix {
442 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
443 replacement: format!("{prefix}\n"),
444 }),
445 });
446 }
447 }
448 }
449 Ok(warnings)
450 }
451}
452
453impl Rule for MD032BlanksAroundLists {
454 fn name(&self) -> &'static str {
455 "MD032"
456 }
457
458 fn description(&self) -> &'static str {
459 "Lists should be surrounded by blank lines"
460 }
461
462 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
463 let content = ctx.content;
464 let lines: Vec<&str> = content.lines().collect();
465 let line_index = &ctx.line_index;
466
467 if lines.is_empty() {
469 return Ok(Vec::new());
470 }
471
472 let list_blocks = self.convert_list_blocks(ctx);
473
474 if list_blocks.is_empty() {
475 return Ok(Vec::new());
476 }
477
478 self.perform_checks(ctx, &lines, &list_blocks, line_index)
479 }
480
481 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
482 self.fix_with_structure_impl(ctx)
483 }
484
485 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
486 if ctx.content.is_empty() || !ctx.likely_has_lists() {
488 return true;
489 }
490 ctx.list_blocks.is_empty()
492 }
493
494 fn category(&self) -> RuleCategory {
495 RuleCategory::List
496 }
497
498 fn as_any(&self) -> &dyn std::any::Any {
499 self
500 }
501
502 fn default_config_section(&self) -> Option<(String, toml::Value)> {
503 use crate::rule_config_serde::RuleConfig;
504 let default_config = MD032Config::default();
505 let json_value = serde_json::to_value(&default_config).ok()?;
506 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
507
508 if let toml::Value::Table(table) = toml_value {
509 if !table.is_empty() {
510 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
511 } else {
512 None
513 }
514 } else {
515 None
516 }
517 }
518
519 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
520 where
521 Self: Sized,
522 {
523 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
524 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
525 }
526}
527
528impl MD032BlanksAroundLists {
529 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> 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 = ctx
551 .line_info(prev_line_actual_idx_1)
552 .is_some_and(|info| info.in_code_block || info.in_front_matter);
553 let prev_prefix = BLOCKQUOTE_PREFIX_RE
554 .find(lines[prev_line_actual_idx_0])
555 .map_or(String::new(), |m| m.as_str().to_string());
556
557 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
558 if !is_prev_excluded
559 && !is_blank_in_context(lines[prev_line_actual_idx_0])
560 && prev_prefix == *prefix
561 && should_require
562 {
563 insertions.insert(start_line, prefix.clone());
564 }
565 }
566
567 if end_line < num_lines {
569 let after_block_line_idx_0 = end_line;
570 let after_block_line_idx_1 = end_line + 1;
571 let line_after_block_content_str = lines[after_block_line_idx_0];
572 let is_line_after_excluded = ctx
575 .line_info(after_block_line_idx_1)
576 .is_some_and(|info| info.in_code_block || info.in_front_matter)
577 || (after_block_line_idx_0 < ctx.lines.len()
578 && ctx.lines[after_block_line_idx_0].in_code_block
579 && ctx.lines[after_block_line_idx_0].indent >= 2
580 && (ctx.lines[after_block_line_idx_0]
581 .content(ctx.content)
582 .trim()
583 .starts_with("```")
584 || ctx.lines[after_block_line_idx_0]
585 .content(ctx.content)
586 .trim()
587 .starts_with("~~~")));
588 let after_prefix = BLOCKQUOTE_PREFIX_RE
589 .find(line_after_block_content_str)
590 .map_or(String::new(), |m| m.as_str().to_string());
591
592 if !is_line_after_excluded
593 && !is_blank_in_context(line_after_block_content_str)
594 && after_prefix == *prefix
595 {
596 insertions.insert(after_block_line_idx_1, prefix.clone());
597 }
598 }
599 }
600
601 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
603 for (i, line) in lines.iter().enumerate() {
604 let current_line_num = i + 1;
605 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
606 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
607 {
608 result_lines.push(prefix_to_insert.clone());
609 }
610 result_lines.push(line.to_string());
611 }
612
613 let mut result = result_lines.join("\n");
615 if ctx.content.ends_with('\n') {
616 result.push('\n');
617 }
618 Ok(result)
619 }
620}
621
622fn is_blank_in_context(line: &str) -> bool {
624 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
627 line[m.end()..].trim().is_empty()
629 } else {
630 line.trim().is_empty()
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::lint_context::LintContext;
639 use crate::rule::Rule;
640
641 fn lint(content: &str) -> Vec<LintWarning> {
642 let rule = MD032BlanksAroundLists::default();
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 rule.check(&ctx).expect("Lint check failed")
645 }
646
647 fn fix(content: &str) -> String {
648 let rule = MD032BlanksAroundLists::default();
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 rule.fix(&ctx).expect("Lint fix failed")
651 }
652
653 fn check_warnings_have_fixes(content: &str) {
655 let warnings = lint(content);
656 for warning in &warnings {
657 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
658 }
659 }
660
661 #[test]
662 fn test_list_at_start() {
663 let content = "- Item 1\n- Item 2\nText";
666 let warnings = lint(content);
667 assert_eq!(
668 warnings.len(),
669 0,
670 "Trailing text is lazy continuation per CommonMark - no warning expected"
671 );
672 }
673
674 #[test]
675 fn test_list_at_end() {
676 let content = "Text\n- Item 1\n- Item 2";
677 let warnings = lint(content);
678 assert_eq!(
679 warnings.len(),
680 1,
681 "Expected 1 warning for list at end without preceding blank line"
682 );
683 assert_eq!(
684 warnings[0].line, 2,
685 "Warning should be on the first line of the list (line 2)"
686 );
687 assert!(warnings[0].message.contains("preceded by blank line"));
688
689 check_warnings_have_fixes(content);
691
692 let fixed_content = fix(content);
693 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
694
695 let warnings_after_fix = lint(&fixed_content);
697 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
698 }
699
700 #[test]
701 fn test_list_in_middle() {
702 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
705 let warnings = lint(content);
706 assert_eq!(
707 warnings.len(),
708 1,
709 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
710 );
711 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
712 assert!(warnings[0].message.contains("preceded by blank line"));
713
714 check_warnings_have_fixes(content);
716
717 let fixed_content = fix(content);
718 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
719
720 let warnings_after_fix = lint(&fixed_content);
722 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
723 }
724
725 #[test]
726 fn test_correct_spacing() {
727 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
728 let warnings = lint(content);
729 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
730
731 let fixed_content = fix(content);
732 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
733 }
734
735 #[test]
736 fn test_list_with_content() {
737 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
740 let warnings = lint(content);
741 assert_eq!(
742 warnings.len(),
743 1,
744 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
745 );
746 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
747 assert!(warnings[0].message.contains("preceded by blank line"));
748
749 check_warnings_have_fixes(content);
751
752 let fixed_content = fix(content);
753 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
754 assert_eq!(
755 fixed_content, expected_fixed,
756 "Fix did not produce the expected output. Got:\n{fixed_content}"
757 );
758
759 let warnings_after_fix = lint(&fixed_content);
761 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
762 }
763
764 #[test]
765 fn test_nested_list() {
766 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
768 let warnings = lint(content);
769 assert_eq!(
770 warnings.len(),
771 1,
772 "Nested list block needs preceding blank only. Got: {warnings:?}"
773 );
774 assert_eq!(warnings[0].line, 2);
775 assert!(warnings[0].message.contains("preceded by blank line"));
776
777 check_warnings_have_fixes(content);
779
780 let fixed_content = fix(content);
781 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
782
783 let warnings_after_fix = lint(&fixed_content);
785 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
786 }
787
788 #[test]
789 fn test_list_with_internal_blanks() {
790 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
792 let warnings = lint(content);
793 assert_eq!(
794 warnings.len(),
795 1,
796 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
797 );
798 assert_eq!(warnings[0].line, 2);
799 assert!(warnings[0].message.contains("preceded by blank line"));
800
801 check_warnings_have_fixes(content);
803
804 let fixed_content = fix(content);
805 assert_eq!(
806 fixed_content,
807 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
808 );
809
810 let warnings_after_fix = lint(&fixed_content);
812 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
813 }
814
815 #[test]
816 fn test_ignore_code_blocks() {
817 let content = "```\n- Not a list item\n```\nText";
818 let warnings = lint(content);
819 assert_eq!(warnings.len(), 0);
820 let fixed_content = fix(content);
821 assert_eq!(fixed_content, content);
822 }
823
824 #[test]
825 fn test_ignore_front_matter() {
826 let content = "---\ntitle: Test\n---\n- List Item\nText";
828 let warnings = lint(content);
829 assert_eq!(
830 warnings.len(),
831 0,
832 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
833 );
834
835 let fixed_content = fix(content);
837 assert_eq!(fixed_content, content, "No changes when no warnings");
838 }
839
840 #[test]
841 fn test_multiple_lists() {
842 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
847 let warnings = lint(content);
848 assert!(
850 !warnings.is_empty(),
851 "Should have at least one warning for missing blank line. Got: {warnings:?}"
852 );
853
854 check_warnings_have_fixes(content);
856
857 let fixed_content = fix(content);
858 let warnings_after_fix = lint(&fixed_content);
860 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
861 }
862
863 #[test]
864 fn test_adjacent_lists() {
865 let content = "- List 1\n\n* List 2";
866 let warnings = lint(content);
867 assert_eq!(warnings.len(), 0);
868 let fixed_content = fix(content);
869 assert_eq!(fixed_content, content);
870 }
871
872 #[test]
873 fn test_list_in_blockquote() {
874 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
876 let warnings = lint(content);
877 assert_eq!(
878 warnings.len(),
879 1,
880 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
881 );
882 assert_eq!(warnings[0].line, 2);
883
884 check_warnings_have_fixes(content);
886
887 let fixed_content = fix(content);
888 assert_eq!(
890 fixed_content, "> Quote line 1\n> \n> - List item 1\n> - List item 2\n> Quote line 2",
891 "Fix for blockquoted list failed. Got:\n{fixed_content}"
892 );
893
894 let warnings_after_fix = lint(&fixed_content);
896 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
897 }
898
899 #[test]
900 fn test_ordered_list() {
901 let content = "Text\n1. Item 1\n2. Item 2\nText";
903 let warnings = lint(content);
904 assert_eq!(warnings.len(), 1);
905
906 check_warnings_have_fixes(content);
908
909 let fixed_content = fix(content);
910 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
911
912 let warnings_after_fix = lint(&fixed_content);
914 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
915 }
916
917 #[test]
918 fn test_no_double_blank_fix() {
919 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
922 assert_eq!(
923 warnings.len(),
924 0,
925 "Should have no warnings - properly preceded, trailing is lazy"
926 );
927
928 let fixed_content = fix(content);
929 assert_eq!(
930 fixed_content, content,
931 "No fix needed when no warnings. Got:\n{fixed_content}"
932 );
933
934 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
936 assert_eq!(warnings2.len(), 1);
937 if !warnings2.is_empty() {
938 assert_eq!(
939 warnings2[0].line, 2,
940 "Warning line for missing blank before should be the first line of the block"
941 );
942 }
943
944 check_warnings_have_fixes(content2);
946
947 let fixed_content2 = fix(content2);
948 assert_eq!(
949 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
950 "Fix added extra blank before. Got:\n{fixed_content2}"
951 );
952 }
953
954 #[test]
955 fn test_empty_input() {
956 let content = "";
957 let warnings = lint(content);
958 assert_eq!(warnings.len(), 0);
959 let fixed_content = fix(content);
960 assert_eq!(fixed_content, "");
961 }
962
963 #[test]
964 fn test_only_list() {
965 let content = "- Item 1\n- Item 2";
966 let warnings = lint(content);
967 assert_eq!(warnings.len(), 0);
968 let fixed_content = fix(content);
969 assert_eq!(fixed_content, content);
970 }
971
972 #[test]
975 fn test_fix_complex_nested_blockquote() {
976 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
978 let warnings = lint(content);
979 assert_eq!(
980 warnings.len(),
981 1,
982 "Should warn for missing preceding blank only. Got: {warnings:?}"
983 );
984
985 check_warnings_have_fixes(content);
987
988 let fixed_content = fix(content);
989 let expected = "> Text before\n> \n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
990 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
991
992 let warnings_after_fix = lint(&fixed_content);
993 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
994 }
995
996 #[test]
997 fn test_fix_mixed_list_markers() {
998 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1001 let warnings = lint(content);
1002 assert!(
1004 !warnings.is_empty(),
1005 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1006 );
1007
1008 check_warnings_have_fixes(content);
1010
1011 let fixed_content = fix(content);
1012 assert!(
1014 fixed_content.contains("Text\n\n-"),
1015 "Fix should add blank line before first list item"
1016 );
1017
1018 let warnings_after_fix = lint(&fixed_content);
1020 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1021 }
1022
1023 #[test]
1024 fn test_fix_ordered_list_with_different_numbers() {
1025 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1027 let warnings = lint(content);
1028 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1029
1030 check_warnings_have_fixes(content);
1032
1033 let fixed_content = fix(content);
1034 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1035 assert_eq!(
1036 fixed_content, expected,
1037 "Fix should handle ordered lists with non-sequential numbers"
1038 );
1039
1040 let warnings_after_fix = lint(&fixed_content);
1042 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1043 }
1044
1045 #[test]
1046 fn test_fix_list_with_code_blocks_inside() {
1047 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1049 let warnings = lint(content);
1050 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1051
1052 check_warnings_have_fixes(content);
1054
1055 let fixed_content = fix(content);
1056 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1057 assert_eq!(
1058 fixed_content, expected,
1059 "Fix should handle lists with internal code blocks"
1060 );
1061
1062 let warnings_after_fix = lint(&fixed_content);
1064 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1065 }
1066
1067 #[test]
1068 fn test_fix_deeply_nested_lists() {
1069 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1071 let warnings = lint(content);
1072 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1073
1074 check_warnings_have_fixes(content);
1076
1077 let fixed_content = fix(content);
1078 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1079 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1080
1081 let warnings_after_fix = lint(&fixed_content);
1083 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1084 }
1085
1086 #[test]
1087 fn test_fix_list_with_multiline_items() {
1088 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1091 let warnings = lint(content);
1092 assert_eq!(
1093 warnings.len(),
1094 1,
1095 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1096 );
1097
1098 check_warnings_have_fixes(content);
1100
1101 let fixed_content = fix(content);
1102 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1103 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1104
1105 let warnings_after_fix = lint(&fixed_content);
1107 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1108 }
1109
1110 #[test]
1111 fn test_fix_list_at_document_boundaries() {
1112 let content1 = "- Item 1\n- Item 2";
1114 let warnings1 = lint(content1);
1115 assert_eq!(
1116 warnings1.len(),
1117 0,
1118 "List at document start should not need blank before"
1119 );
1120 let fixed1 = fix(content1);
1121 assert_eq!(fixed1, content1, "No fix needed for list at start");
1122
1123 let content2 = "Text\n- Item 1\n- Item 2";
1125 let warnings2 = lint(content2);
1126 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1127 check_warnings_have_fixes(content2);
1128 let fixed2 = fix(content2);
1129 assert_eq!(
1130 fixed2, "Text\n\n- Item 1\n- Item 2",
1131 "Should add blank before list at end"
1132 );
1133 }
1134
1135 #[test]
1136 fn test_fix_preserves_existing_blank_lines() {
1137 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1138 let warnings = lint(content);
1139 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1140 let fixed_content = fix(content);
1141 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1142 }
1143
1144 #[test]
1145 fn test_fix_handles_tabs_and_spaces() {
1146 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1148 let warnings = lint(content);
1149 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1152
1153 check_warnings_have_fixes(content);
1155
1156 let fixed_content = fix(content);
1157 let expected = "Text\n\n\t- Item with tab\n - Item with spaces\nText";
1159 assert_eq!(fixed_content, expected, "Fix should preserve original indentation");
1160
1161 let warnings_after_fix = lint(&fixed_content);
1163 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1164 }
1165
1166 #[test]
1167 fn test_fix_warning_objects_have_correct_ranges() {
1168 let content = "Text\n- Item 1\n- Item 2\nText";
1170 let warnings = lint(content);
1171 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1172
1173 for warning in &warnings {
1175 assert!(warning.fix.is_some(), "Warning should have fix");
1176 let fix = warning.fix.as_ref().unwrap();
1177 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1178 assert!(
1179 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1180 "Fix should have replacement or be insertion"
1181 );
1182 }
1183 }
1184
1185 #[test]
1186 fn test_fix_idempotent() {
1187 let content = "Text\n- Item 1\n- Item 2\nText";
1189
1190 let fixed_once = fix(content);
1192 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1193
1194 let fixed_twice = fix(&fixed_once);
1196 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1197
1198 let warnings_after_fix = lint(&fixed_once);
1200 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1201 }
1202
1203 #[test]
1204 fn test_fix_with_normalized_line_endings() {
1205 let content = "Text\n- Item 1\n- Item 2\nText";
1209 let warnings = lint(content);
1210 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1211
1212 check_warnings_have_fixes(content);
1214
1215 let fixed_content = fix(content);
1216 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1218 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1219 }
1220
1221 #[test]
1222 fn test_fix_preserves_final_newline() {
1223 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1226 let fixed_with_newline = fix(content_with_newline);
1227 assert!(
1228 fixed_with_newline.ends_with('\n'),
1229 "Fix should preserve final newline when present"
1230 );
1231 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1233
1234 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1236 let fixed_without_newline = fix(content_without_newline);
1237 assert!(
1238 !fixed_without_newline.ends_with('\n'),
1239 "Fix should not add final newline when not present"
1240 );
1241 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\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
1265 #[test]
1266 fn test_nested_list_with_lazy_continuation() {
1267 let content = r#"# Test
1273
1274- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1275 1. Switch/case dispatcher statements (original Phase 3.2)
1276 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1277`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1278 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1279 references"#;
1280
1281 let warnings = lint(content);
1282 let md032_warnings: Vec<_> = warnings
1285 .iter()
1286 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1287 .collect();
1288 assert_eq!(
1289 md032_warnings.len(),
1290 0,
1291 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1292 );
1293 }
1294
1295 #[test]
1296 fn test_pipes_in_code_spans_not_detected_as_table() {
1297 let content = r#"# Test
1299
1300- Item with `a | b` inline code
1301 - Nested item should work
1302
1303"#;
1304
1305 let warnings = lint(content);
1306 let md032_warnings: Vec<_> = warnings
1307 .iter()
1308 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1309 .collect();
1310 assert_eq!(
1311 md032_warnings.len(),
1312 0,
1313 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1314 );
1315 }
1316
1317 #[test]
1318 fn test_multiple_code_spans_with_pipes() {
1319 let content = r#"# Test
1321
1322- Item with `a | b` and `c || d` operators
1323 - Nested item should work
1324
1325"#;
1326
1327 let warnings = lint(content);
1328 let md032_warnings: Vec<_> = warnings
1329 .iter()
1330 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1331 .collect();
1332 assert_eq!(
1333 md032_warnings.len(),
1334 0,
1335 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1336 );
1337 }
1338
1339 #[test]
1340 fn test_actual_table_breaks_list() {
1341 let content = r#"# Test
1343
1344- Item before table
1345
1346| Col1 | Col2 |
1347|------|------|
1348| A | B |
1349
1350- Item after table
1351
1352"#;
1353
1354 let warnings = lint(content);
1355 let md032_warnings: Vec<_> = warnings
1357 .iter()
1358 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1359 .collect();
1360 assert_eq!(
1361 md032_warnings.len(),
1362 0,
1363 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1364 );
1365 }
1366
1367 #[test]
1368 fn test_thematic_break_not_lazy_continuation() {
1369 let content = r#"- Item 1
1372- Item 2
1373***
1374
1375More text.
1376"#;
1377
1378 let warnings = lint(content);
1379 let md032_warnings: Vec<_> = warnings
1380 .iter()
1381 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1382 .collect();
1383 assert_eq!(
1384 md032_warnings.len(),
1385 1,
1386 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1387 );
1388 assert!(
1389 md032_warnings[0].message.contains("followed by blank line"),
1390 "Warning should be about missing blank after list"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_thematic_break_with_blank_line() {
1396 let content = r#"- Item 1
1398- Item 2
1399
1400***
1401
1402More text.
1403"#;
1404
1405 let warnings = lint(content);
1406 let md032_warnings: Vec<_> = warnings
1407 .iter()
1408 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1409 .collect();
1410 assert_eq!(
1411 md032_warnings.len(),
1412 0,
1413 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_various_thematic_break_styles() {
1419 for hr in ["---", "***", "___"] {
1424 let content = format!(
1425 r#"- Item 1
1426- Item 2
1427{hr}
1428
1429More text.
1430"#
1431 );
1432
1433 let warnings = lint(&content);
1434 let md032_warnings: Vec<_> = warnings
1435 .iter()
1436 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1437 .collect();
1438 assert_eq!(
1439 md032_warnings.len(),
1440 1,
1441 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1442 );
1443 }
1444 }
1445
1446 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1449 let rule = MD032BlanksAroundLists::from_config_struct(config);
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 rule.check(&ctx).expect("Lint check failed")
1452 }
1453
1454 fn fix_with_config(content: &str, config: MD032Config) -> String {
1455 let rule = MD032BlanksAroundLists::from_config_struct(config);
1456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1457 rule.fix(&ctx).expect("Lint fix failed")
1458 }
1459
1460 #[test]
1461 fn test_lazy_continuation_allowed_by_default() {
1462 let content = "# Heading\n\n1. List\nSome text.";
1464 let warnings = lint(content);
1465 assert_eq!(
1466 warnings.len(),
1467 0,
1468 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_lazy_continuation_disallowed() {
1474 let content = "# Heading\n\n1. List\nSome text.";
1476 let config = MD032Config {
1477 allow_lazy_continuation: false,
1478 };
1479 let warnings = lint_with_config(content, config);
1480 assert_eq!(
1481 warnings.len(),
1482 1,
1483 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1484 );
1485 assert!(
1486 warnings[0].message.contains("followed by blank line"),
1487 "Warning message should mention blank line"
1488 );
1489 }
1490
1491 #[test]
1492 fn test_lazy_continuation_fix() {
1493 let content = "# Heading\n\n1. List\nSome text.";
1495 let config = MD032Config {
1496 allow_lazy_continuation: false,
1497 };
1498 let fixed = fix_with_config(content, config.clone());
1499 assert_eq!(
1500 fixed, "# Heading\n\n1. List\n\nSome text.",
1501 "Fix should insert blank line before lazy continuation"
1502 );
1503
1504 let warnings_after = lint_with_config(&fixed, config);
1506 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1507 }
1508
1509 #[test]
1510 fn test_lazy_continuation_multiple_lines() {
1511 let content = "- Item 1\nLine 2\nLine 3";
1513 let config = MD032Config {
1514 allow_lazy_continuation: false,
1515 };
1516 let warnings = lint_with_config(content, config.clone());
1517 assert_eq!(
1518 warnings.len(),
1519 1,
1520 "Should warn for lazy continuation. Got: {warnings:?}"
1521 );
1522
1523 let fixed = fix_with_config(content, config.clone());
1524 assert_eq!(
1525 fixed, "- Item 1\n\nLine 2\nLine 3",
1526 "Fix should insert blank line after list"
1527 );
1528
1529 let warnings_after = lint_with_config(&fixed, config);
1531 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1532 }
1533
1534 #[test]
1535 fn test_lazy_continuation_with_indented_content() {
1536 let content = "- Item 1\n Indented content\nLazy text";
1538 let config = MD032Config {
1539 allow_lazy_continuation: false,
1540 };
1541 let warnings = lint_with_config(content, config);
1542 assert_eq!(
1543 warnings.len(),
1544 1,
1545 "Should warn for lazy text after indented content. Got: {warnings:?}"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_lazy_continuation_properly_separated() {
1551 let content = "- Item 1\n\nSome text.";
1553 let config = MD032Config {
1554 allow_lazy_continuation: false,
1555 };
1556 let warnings = lint_with_config(content, config);
1557 assert_eq!(
1558 warnings.len(),
1559 0,
1560 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1561 );
1562 }
1563
1564 #[test]
1567 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1568 let content = "1) First item\nLazy continuation";
1570 let config = MD032Config {
1571 allow_lazy_continuation: false,
1572 };
1573 let warnings = lint_with_config(content, config.clone());
1574 assert_eq!(
1575 warnings.len(),
1576 1,
1577 "Should warn for lazy continuation with parenthesis marker"
1578 );
1579
1580 let fixed = fix_with_config(content, config);
1581 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1582 }
1583
1584 #[test]
1585 fn test_lazy_continuation_followed_by_another_list() {
1586 let content = "- Item 1\nSome text\n- Item 2";
1591 let config = MD032Config {
1592 allow_lazy_continuation: false,
1593 };
1594 let warnings = lint_with_config(content, config);
1595 assert_eq!(
1598 warnings.len(),
1599 0,
1600 "Valid list structure should not trigger lazy continuation warning"
1601 );
1602 }
1603
1604 #[test]
1605 fn test_lazy_continuation_multiple_in_document() {
1606 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1612 let config = MD032Config {
1613 allow_lazy_continuation: false,
1614 };
1615 let warnings = lint_with_config(content, config.clone());
1616 assert_eq!(
1617 warnings.len(),
1618 1,
1619 "Should warn for second list (not followed by blank). Got: {warnings:?}"
1620 );
1621
1622 let fixed = fix_with_config(content, config.clone());
1623 let warnings_after = lint_with_config(&fixed, config);
1624 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1625 }
1626
1627 #[test]
1628 fn test_lazy_continuation_end_of_document_no_newline() {
1629 let content = "- Item\nNo trailing newline";
1631 let config = MD032Config {
1632 allow_lazy_continuation: false,
1633 };
1634 let warnings = lint_with_config(content, config.clone());
1635 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1636
1637 let fixed = fix_with_config(content, config);
1638 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1639 }
1640
1641 #[test]
1642 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1643 let content = "- Item 1\n---";
1646 let config = MD032Config {
1647 allow_lazy_continuation: false,
1648 };
1649 let warnings = lint_with_config(content, config.clone());
1650 assert_eq!(
1652 warnings.len(),
1653 1,
1654 "List should need blank line before thematic break. Got: {warnings:?}"
1655 );
1656
1657 let fixed = fix_with_config(content, config);
1659 assert_eq!(fixed, "- Item 1\n\n---");
1660 }
1661
1662 #[test]
1663 fn test_lazy_continuation_heading_not_flagged() {
1664 let content = "- Item 1\n# Heading";
1667 let config = MD032Config {
1668 allow_lazy_continuation: false,
1669 };
1670 let warnings = lint_with_config(content, config);
1671 assert!(
1674 warnings.iter().all(|w| !w.message.contains("lazy")),
1675 "Heading should not trigger lazy continuation warning"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_lazy_continuation_mixed_list_types() {
1681 let content = "- Unordered\n1. Ordered\nLazy text";
1683 let config = MD032Config {
1684 allow_lazy_continuation: false,
1685 };
1686 let warnings = lint_with_config(content, config.clone());
1687 assert!(!warnings.is_empty(), "Should warn about structure issues");
1688 }
1689
1690 #[test]
1691 fn test_lazy_continuation_deep_nesting() {
1692 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1694 let config = MD032Config {
1695 allow_lazy_continuation: false,
1696 };
1697 let warnings = lint_with_config(content, config.clone());
1698 assert!(
1699 !warnings.is_empty(),
1700 "Should warn about lazy continuation after nested list"
1701 );
1702
1703 let fixed = fix_with_config(content, config.clone());
1704 let warnings_after = lint_with_config(&fixed, config);
1705 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1706 }
1707
1708 #[test]
1709 fn test_lazy_continuation_with_emphasis_in_text() {
1710 let content = "- Item\n*emphasized* continuation";
1712 let config = MD032Config {
1713 allow_lazy_continuation: false,
1714 };
1715 let warnings = lint_with_config(content, config.clone());
1716 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1717
1718 let fixed = fix_with_config(content, config);
1719 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1720 }
1721
1722 #[test]
1723 fn test_lazy_continuation_with_code_span() {
1724 let content = "- Item\n`code` continuation";
1726 let config = MD032Config {
1727 allow_lazy_continuation: false,
1728 };
1729 let warnings = lint_with_config(content, config.clone());
1730 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1731
1732 let fixed = fix_with_config(content, config);
1733 assert_eq!(fixed, "- Item\n\n`code` continuation");
1734 }
1735
1736 #[test]
1737 fn test_lazy_continuation_whitespace_only_line() {
1738 let content = "- Item\n \nText after whitespace-only line";
1741 let config = MD032Config {
1742 allow_lazy_continuation: false,
1743 };
1744 let warnings = lint_with_config(content, config.clone());
1745 assert_eq!(
1747 warnings.len(),
1748 1,
1749 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1750 );
1751
1752 let fixed = fix_with_config(content, config);
1754 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1755 }
1756
1757 #[test]
1758 fn test_lazy_continuation_blockquote_context() {
1759 let content = "> - Item\n> Lazy in quote";
1761 let config = MD032Config {
1762 allow_lazy_continuation: false,
1763 };
1764 let warnings = lint_with_config(content, config);
1765 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1768 }
1769
1770 #[test]
1771 fn test_lazy_continuation_fix_preserves_content() {
1772 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1774 let config = MD032Config {
1775 allow_lazy_continuation: false,
1776 };
1777 let fixed = fix_with_config(content, config);
1778 assert!(fixed.contains("<>&"), "Should preserve special chars");
1779 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1780 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1781 }
1782
1783 #[test]
1784 fn test_lazy_continuation_fix_idempotent() {
1785 let content = "- Item\nLazy";
1787 let config = MD032Config {
1788 allow_lazy_continuation: false,
1789 };
1790 let fixed_once = fix_with_config(content, config.clone());
1791 let fixed_twice = fix_with_config(&fixed_once, config);
1792 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1793 }
1794
1795 #[test]
1796 fn test_lazy_continuation_config_default_allows() {
1797 let content = "- Item\nLazy text that continues";
1799 let default_config = MD032Config::default();
1800 assert!(
1801 default_config.allow_lazy_continuation,
1802 "Default should allow lazy continuation"
1803 );
1804 let warnings = lint_with_config(content, default_config);
1805 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1806 }
1807
1808 #[test]
1809 fn test_lazy_continuation_after_multi_line_item() {
1810 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
1812 let config = MD032Config {
1813 allow_lazy_continuation: false,
1814 };
1815 let warnings = lint_with_config(content, config.clone());
1816 assert_eq!(
1817 warnings.len(),
1818 1,
1819 "Should warn only for the lazy line, not the indented line"
1820 );
1821 }
1822}