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";
1149 let warnings = lint(content);
1150 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\t- Item with tab\n\n - Item with spaces\nText";
1160 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1161
1162 let warnings_after_fix = lint(&fixed_content);
1164 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1165 }
1166
1167 #[test]
1168 fn test_fix_warning_objects_have_correct_ranges() {
1169 let content = "Text\n- Item 1\n- Item 2\nText";
1171 let warnings = lint(content);
1172 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1173
1174 for warning in &warnings {
1176 assert!(warning.fix.is_some(), "Warning should have fix");
1177 let fix = warning.fix.as_ref().unwrap();
1178 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1179 assert!(
1180 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1181 "Fix should have replacement or be insertion"
1182 );
1183 }
1184 }
1185
1186 #[test]
1187 fn test_fix_idempotent() {
1188 let content = "Text\n- Item 1\n- Item 2\nText";
1190
1191 let fixed_once = fix(content);
1193 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1194
1195 let fixed_twice = fix(&fixed_once);
1197 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1198
1199 let warnings_after_fix = lint(&fixed_once);
1201 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1202 }
1203
1204 #[test]
1205 fn test_fix_with_normalized_line_endings() {
1206 let content = "Text\n- Item 1\n- Item 2\nText";
1210 let warnings = lint(content);
1211 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1212
1213 check_warnings_have_fixes(content);
1215
1216 let fixed_content = fix(content);
1217 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1219 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1220 }
1221
1222 #[test]
1223 fn test_fix_preserves_final_newline() {
1224 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1227 let fixed_with_newline = fix(content_with_newline);
1228 assert!(
1229 fixed_with_newline.ends_with('\n'),
1230 "Fix should preserve final newline when present"
1231 );
1232 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1234
1235 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1237 let fixed_without_newline = fix(content_without_newline);
1238 assert!(
1239 !fixed_without_newline.ends_with('\n'),
1240 "Fix should not add final newline when not present"
1241 );
1242 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1244 }
1245
1246 #[test]
1247 fn test_fix_multiline_list_items_no_indent() {
1248 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";
1249
1250 let warnings = lint(content);
1251 assert_eq!(
1253 warnings.len(),
1254 0,
1255 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1256 );
1257
1258 let fixed_content = fix(content);
1259 assert_eq!(
1261 fixed_content, content,
1262 "Should not modify correctly formatted multi-line list items"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_nested_list_with_lazy_continuation() {
1268 let content = r#"# Test
1274
1275- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1276 1. Switch/case dispatcher statements (original Phase 3.2)
1277 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1278`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1279 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1280 references"#;
1281
1282 let warnings = lint(content);
1283 let md032_warnings: Vec<_> = warnings
1286 .iter()
1287 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1288 .collect();
1289 assert_eq!(
1290 md032_warnings.len(),
1291 0,
1292 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1293 );
1294 }
1295
1296 #[test]
1297 fn test_pipes_in_code_spans_not_detected_as_table() {
1298 let content = r#"# Test
1300
1301- Item with `a | b` inline code
1302 - Nested item should work
1303
1304"#;
1305
1306 let warnings = lint(content);
1307 let md032_warnings: Vec<_> = warnings
1308 .iter()
1309 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1310 .collect();
1311 assert_eq!(
1312 md032_warnings.len(),
1313 0,
1314 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_multiple_code_spans_with_pipes() {
1320 let content = r#"# Test
1322
1323- Item with `a | b` and `c || d` operators
1324 - Nested item should work
1325
1326"#;
1327
1328 let warnings = lint(content);
1329 let md032_warnings: Vec<_> = warnings
1330 .iter()
1331 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1332 .collect();
1333 assert_eq!(
1334 md032_warnings.len(),
1335 0,
1336 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_actual_table_breaks_list() {
1342 let content = r#"# Test
1344
1345- Item before table
1346
1347| Col1 | Col2 |
1348|------|------|
1349| A | B |
1350
1351- Item after table
1352
1353"#;
1354
1355 let warnings = lint(content);
1356 let md032_warnings: Vec<_> = warnings
1358 .iter()
1359 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1360 .collect();
1361 assert_eq!(
1362 md032_warnings.len(),
1363 0,
1364 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_thematic_break_not_lazy_continuation() {
1370 let content = r#"- Item 1
1373- Item 2
1374***
1375
1376More text.
1377"#;
1378
1379 let warnings = lint(content);
1380 let md032_warnings: Vec<_> = warnings
1381 .iter()
1382 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1383 .collect();
1384 assert_eq!(
1385 md032_warnings.len(),
1386 1,
1387 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1388 );
1389 assert!(
1390 md032_warnings[0].message.contains("followed by blank line"),
1391 "Warning should be about missing blank after list"
1392 );
1393 }
1394
1395 #[test]
1396 fn test_thematic_break_with_blank_line() {
1397 let content = r#"- Item 1
1399- Item 2
1400
1401***
1402
1403More text.
1404"#;
1405
1406 let warnings = lint(content);
1407 let md032_warnings: Vec<_> = warnings
1408 .iter()
1409 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1410 .collect();
1411 assert_eq!(
1412 md032_warnings.len(),
1413 0,
1414 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1415 );
1416 }
1417
1418 #[test]
1419 fn test_various_thematic_break_styles() {
1420 for hr in ["---", "***", "___"] {
1425 let content = format!(
1426 r#"- Item 1
1427- Item 2
1428{hr}
1429
1430More text.
1431"#
1432 );
1433
1434 let warnings = lint(&content);
1435 let md032_warnings: Vec<_> = warnings
1436 .iter()
1437 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1438 .collect();
1439 assert_eq!(
1440 md032_warnings.len(),
1441 1,
1442 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1443 );
1444 }
1445 }
1446
1447 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1450 let rule = MD032BlanksAroundLists::from_config_struct(config);
1451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1452 rule.check(&ctx).expect("Lint check failed")
1453 }
1454
1455 fn fix_with_config(content: &str, config: MD032Config) -> String {
1456 let rule = MD032BlanksAroundLists::from_config_struct(config);
1457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458 rule.fix(&ctx).expect("Lint fix failed")
1459 }
1460
1461 #[test]
1462 fn test_lazy_continuation_allowed_by_default() {
1463 let content = "# Heading\n\n1. List\nSome text.";
1465 let warnings = lint(content);
1466 assert_eq!(
1467 warnings.len(),
1468 0,
1469 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1470 );
1471 }
1472
1473 #[test]
1474 fn test_lazy_continuation_disallowed() {
1475 let content = "# Heading\n\n1. List\nSome text.";
1477 let config = MD032Config {
1478 allow_lazy_continuation: false,
1479 };
1480 let warnings = lint_with_config(content, config);
1481 assert_eq!(
1482 warnings.len(),
1483 1,
1484 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1485 );
1486 assert!(
1487 warnings[0].message.contains("followed by blank line"),
1488 "Warning message should mention blank line"
1489 );
1490 }
1491
1492 #[test]
1493 fn test_lazy_continuation_fix() {
1494 let content = "# Heading\n\n1. List\nSome text.";
1496 let config = MD032Config {
1497 allow_lazy_continuation: false,
1498 };
1499 let fixed = fix_with_config(content, config.clone());
1500 assert_eq!(
1501 fixed, "# Heading\n\n1. List\n\nSome text.",
1502 "Fix should insert blank line before lazy continuation"
1503 );
1504
1505 let warnings_after = lint_with_config(&fixed, config);
1507 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1508 }
1509
1510 #[test]
1511 fn test_lazy_continuation_multiple_lines() {
1512 let content = "- Item 1\nLine 2\nLine 3";
1514 let config = MD032Config {
1515 allow_lazy_continuation: false,
1516 };
1517 let warnings = lint_with_config(content, config.clone());
1518 assert_eq!(
1519 warnings.len(),
1520 1,
1521 "Should warn for lazy continuation. Got: {warnings:?}"
1522 );
1523
1524 let fixed = fix_with_config(content, config.clone());
1525 assert_eq!(
1526 fixed, "- Item 1\n\nLine 2\nLine 3",
1527 "Fix should insert blank line after list"
1528 );
1529
1530 let warnings_after = lint_with_config(&fixed, config);
1532 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1533 }
1534
1535 #[test]
1536 fn test_lazy_continuation_with_indented_content() {
1537 let content = "- Item 1\n Indented content\nLazy text";
1539 let config = MD032Config {
1540 allow_lazy_continuation: false,
1541 };
1542 let warnings = lint_with_config(content, config);
1543 assert_eq!(
1544 warnings.len(),
1545 1,
1546 "Should warn for lazy text after indented content. Got: {warnings:?}"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_lazy_continuation_properly_separated() {
1552 let content = "- Item 1\n\nSome text.";
1554 let config = MD032Config {
1555 allow_lazy_continuation: false,
1556 };
1557 let warnings = lint_with_config(content, config);
1558 assert_eq!(
1559 warnings.len(),
1560 0,
1561 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1562 );
1563 }
1564
1565 #[test]
1568 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1569 let content = "1) First item\nLazy continuation";
1571 let config = MD032Config {
1572 allow_lazy_continuation: false,
1573 };
1574 let warnings = lint_with_config(content, config.clone());
1575 assert_eq!(
1576 warnings.len(),
1577 1,
1578 "Should warn for lazy continuation with parenthesis marker"
1579 );
1580
1581 let fixed = fix_with_config(content, config);
1582 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1583 }
1584
1585 #[test]
1586 fn test_lazy_continuation_followed_by_another_list() {
1587 let content = "- Item 1\nSome text\n- Item 2";
1592 let config = MD032Config {
1593 allow_lazy_continuation: false,
1594 };
1595 let warnings = lint_with_config(content, config);
1596 assert_eq!(
1599 warnings.len(),
1600 0,
1601 "Valid list structure should not trigger lazy continuation warning"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_lazy_continuation_multiple_in_document() {
1607 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1613 let config = MD032Config {
1614 allow_lazy_continuation: false,
1615 };
1616 let warnings = lint_with_config(content, config.clone());
1617 assert_eq!(
1618 warnings.len(),
1619 1,
1620 "Should warn for second list (not followed by blank). Got: {warnings:?}"
1621 );
1622
1623 let fixed = fix_with_config(content, config.clone());
1624 let warnings_after = lint_with_config(&fixed, config);
1625 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1626 }
1627
1628 #[test]
1629 fn test_lazy_continuation_end_of_document_no_newline() {
1630 let content = "- Item\nNo trailing newline";
1632 let config = MD032Config {
1633 allow_lazy_continuation: false,
1634 };
1635 let warnings = lint_with_config(content, config.clone());
1636 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1637
1638 let fixed = fix_with_config(content, config);
1639 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1640 }
1641
1642 #[test]
1643 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1644 let content = "- Item 1\n---";
1647 let config = MD032Config {
1648 allow_lazy_continuation: false,
1649 };
1650 let warnings = lint_with_config(content, config.clone());
1651 assert_eq!(
1653 warnings.len(),
1654 1,
1655 "List should need blank line before thematic break. Got: {warnings:?}"
1656 );
1657
1658 let fixed = fix_with_config(content, config);
1660 assert_eq!(fixed, "- Item 1\n\n---");
1661 }
1662
1663 #[test]
1664 fn test_lazy_continuation_heading_not_flagged() {
1665 let content = "- Item 1\n# Heading";
1668 let config = MD032Config {
1669 allow_lazy_continuation: false,
1670 };
1671 let warnings = lint_with_config(content, config);
1672 assert!(
1675 warnings.iter().all(|w| !w.message.contains("lazy")),
1676 "Heading should not trigger lazy continuation warning"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_lazy_continuation_mixed_list_types() {
1682 let content = "- Unordered\n1. Ordered\nLazy text";
1684 let config = MD032Config {
1685 allow_lazy_continuation: false,
1686 };
1687 let warnings = lint_with_config(content, config.clone());
1688 assert!(!warnings.is_empty(), "Should warn about structure issues");
1689 }
1690
1691 #[test]
1692 fn test_lazy_continuation_deep_nesting() {
1693 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1695 let config = MD032Config {
1696 allow_lazy_continuation: false,
1697 };
1698 let warnings = lint_with_config(content, config.clone());
1699 assert!(
1700 !warnings.is_empty(),
1701 "Should warn about lazy continuation after nested list"
1702 );
1703
1704 let fixed = fix_with_config(content, config.clone());
1705 let warnings_after = lint_with_config(&fixed, config);
1706 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1707 }
1708
1709 #[test]
1710 fn test_lazy_continuation_with_emphasis_in_text() {
1711 let content = "- Item\n*emphasized* continuation";
1713 let config = MD032Config {
1714 allow_lazy_continuation: false,
1715 };
1716 let warnings = lint_with_config(content, config.clone());
1717 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1718
1719 let fixed = fix_with_config(content, config);
1720 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1721 }
1722
1723 #[test]
1724 fn test_lazy_continuation_with_code_span() {
1725 let content = "- Item\n`code` continuation";
1727 let config = MD032Config {
1728 allow_lazy_continuation: false,
1729 };
1730 let warnings = lint_with_config(content, config.clone());
1731 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1732
1733 let fixed = fix_with_config(content, config);
1734 assert_eq!(fixed, "- Item\n\n`code` continuation");
1735 }
1736
1737 #[test]
1738 fn test_lazy_continuation_whitespace_only_line() {
1739 let content = "- Item\n \nText after whitespace-only line";
1742 let config = MD032Config {
1743 allow_lazy_continuation: false,
1744 };
1745 let warnings = lint_with_config(content, config.clone());
1746 assert_eq!(
1748 warnings.len(),
1749 1,
1750 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1751 );
1752
1753 let fixed = fix_with_config(content, config);
1755 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1756 }
1757
1758 #[test]
1759 fn test_lazy_continuation_blockquote_context() {
1760 let content = "> - Item\n> Lazy in quote";
1762 let config = MD032Config {
1763 allow_lazy_continuation: false,
1764 };
1765 let warnings = lint_with_config(content, config);
1766 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1769 }
1770
1771 #[test]
1772 fn test_lazy_continuation_fix_preserves_content() {
1773 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1775 let config = MD032Config {
1776 allow_lazy_continuation: false,
1777 };
1778 let fixed = fix_with_config(content, config);
1779 assert!(fixed.contains("<>&"), "Should preserve special chars");
1780 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1781 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1782 }
1783
1784 #[test]
1785 fn test_lazy_continuation_fix_idempotent() {
1786 let content = "- Item\nLazy";
1788 let config = MD032Config {
1789 allow_lazy_continuation: false,
1790 };
1791 let fixed_once = fix_with_config(content, config.clone());
1792 let fixed_twice = fix_with_config(&fixed_once, config);
1793 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1794 }
1795
1796 #[test]
1797 fn test_lazy_continuation_config_default_allows() {
1798 let content = "- Item\nLazy text that continues";
1800 let default_config = MD032Config::default();
1801 assert!(
1802 default_config.allow_lazy_continuation,
1803 "Default should allow lazy continuation"
1804 );
1805 let warnings = lint_with_config(content, default_config);
1806 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1807 }
1808
1809 #[test]
1810 fn test_lazy_continuation_after_multi_line_item() {
1811 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
1813 let config = MD032Config {
1814 allow_lazy_continuation: false,
1815 };
1816 let warnings = lint_with_config(content, config.clone());
1817 assert_eq!(
1818 warnings.len(),
1819 1,
1820 "Should warn only for the lazy line, not the indented line"
1821 );
1822 }
1823
1824 #[test]
1826 fn test_blockquote_list_with_continuation_and_nested() {
1827 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
1830 let warnings = lint(content);
1831 assert_eq!(
1832 warnings.len(),
1833 0,
1834 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_blockquote_list_simple() {
1840 let content = "> - item 1\n> - item 2";
1842 let warnings = lint(content);
1843 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
1844 }
1845
1846 #[test]
1847 fn test_blockquote_list_with_continuation_only() {
1848 let content = "> - item 1\n> continuation\n> - item 2";
1850 let warnings = lint(content);
1851 assert_eq!(
1852 warnings.len(),
1853 0,
1854 "Blockquoted list with continuation should have no warnings"
1855 );
1856 }
1857
1858 #[test]
1859 fn test_blockquote_list_with_lazy_continuation() {
1860 let content = "> - item 1\n> lazy continuation\n> - item 2";
1862 let warnings = lint(content);
1863 assert_eq!(
1864 warnings.len(),
1865 0,
1866 "Blockquoted list with lazy continuation should have no warnings"
1867 );
1868 }
1869
1870 #[test]
1871 fn test_nested_blockquote_list() {
1872 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
1874 let warnings = lint(content);
1875 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
1876 }
1877
1878 #[test]
1879 fn test_blockquote_list_needs_preceding_blank() {
1880 let content = "> Text before\n> - item 1\n> - item 2";
1882 let warnings = lint(content);
1883 assert_eq!(
1884 warnings.len(),
1885 1,
1886 "Should warn for missing blank before blockquoted list"
1887 );
1888 }
1889
1890 #[test]
1891 fn test_blockquote_list_properly_separated() {
1892 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
1894 let warnings = lint(content);
1895 assert_eq!(
1896 warnings.len(),
1897 0,
1898 "Properly separated blockquoted list should have no warnings"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_blockquote_ordered_list() {
1904 let content = "> 1. item 1\n> continuation\n> 2. item 2";
1906 let warnings = lint(content);
1907 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
1908 }
1909
1910 #[test]
1911 fn test_blockquote_list_with_empty_blockquote_line() {
1912 let content = "> - item 1\n>\n> - item 2";
1914 let warnings = lint(content);
1915 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
1916 }
1917
1918 #[test]
1919 fn test_blockquote_list_varying_spaces_after_marker() {
1920 let content = "> - item 1\n> continuation with more indent\n> - item 2";
1922 let warnings = lint(content);
1923 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
1924 }
1925
1926 #[test]
1927 fn test_deeply_nested_blockquote_list() {
1928 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
1930 let warnings = lint(content);
1931 assert_eq!(
1932 warnings.len(),
1933 0,
1934 "Deeply nested blockquote list should have no warnings"
1935 );
1936 }
1937
1938 #[test]
1939 #[ignore = "rumdl doesn't yet detect blockquote level changes between list items as list-breaking"]
1940 fn test_blockquote_level_change_in_list() {
1941 let content = "> - item 1\n>> - deeper item\n> - item 2";
1945 let warnings = lint(content);
1948 assert!(
1949 !warnings.is_empty(),
1950 "Blockquote level change should break list and trigger warnings"
1951 );
1952 }
1953
1954 #[test]
1955 fn test_blockquote_list_with_code_span() {
1956 let content = "> - item with `code`\n> continuation\n> - item 2";
1958 let warnings = lint(content);
1959 assert_eq!(
1960 warnings.len(),
1961 0,
1962 "Blockquote list with code span should have no warnings"
1963 );
1964 }
1965
1966 #[test]
1967 fn test_blockquote_list_at_document_end() {
1968 let content = "> Some text\n>\n> - item 1\n> - item 2";
1970 let warnings = lint(content);
1971 assert_eq!(
1972 warnings.len(),
1973 0,
1974 "Blockquote list at document end should have no warnings"
1975 );
1976 }
1977}