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 let get_blockquote_level = |line_num: usize| -> usize {
185 if line_num == 0 || line_num > ctx.lines.len() {
186 return 0;
187 }
188 let line_content = ctx.lines[line_num - 1].content(ctx.content);
189 BLOCKQUOTE_PREFIX_RE
190 .find(line_content)
191 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
192 .unwrap_or(0)
193 };
194
195 let mut prev_bq_level = 0;
196
197 for &item_line in &block.item_lines {
198 let current_bq_level = get_blockquote_level(item_line);
199
200 if prev_item_line > 0 {
201 let blockquote_level_changed = prev_bq_level != current_bq_level;
203
204 let mut has_standalone_code_fence = false;
207
208 let min_indent_for_content = if block.is_ordered {
210 3 } else {
214 2 };
217
218 for check_line in (prev_item_line + 1)..item_line {
219 if check_line - 1 < ctx.lines.len() {
220 let line = &ctx.lines[check_line - 1];
221 let line_content = line.content(ctx.content);
222 if line.in_code_block
223 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
224 {
225 if line.indent < min_indent_for_content {
228 has_standalone_code_fence = true;
229 break;
230 }
231 }
232 }
233 }
234
235 if has_standalone_code_fence || blockquote_level_changed {
236 segments.push((current_start, prev_item_line));
238 current_start = item_line;
239 }
240 }
241 prev_item_line = item_line;
242 prev_bq_level = current_bq_level;
243 }
244
245 if prev_item_line > 0 {
248 segments.push((current_start, prev_item_line));
249 }
250
251 let has_code_fence_splits = segments.len() > 1 && {
253 let mut found_fence = false;
255 for i in 0..segments.len() - 1 {
256 let seg_end = segments[i].1;
257 let next_start = segments[i + 1].0;
258 for check_line in (seg_end + 1)..next_start {
260 if check_line - 1 < ctx.lines.len() {
261 let line = &ctx.lines[check_line - 1];
262 let line_content = line.content(ctx.content);
263 if line.in_code_block
264 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
265 {
266 found_fence = true;
267 break;
268 }
269 }
270 }
271 if found_fence {
272 break;
273 }
274 }
275 found_fence
276 };
277
278 for (start, end) in segments.iter() {
280 let mut actual_end = *end;
282
283 if !has_code_fence_splits && *end < block.end_line {
286 let min_continuation_indent = ctx
289 .lines
290 .get(*end - 1)
291 .and_then(|line_info| line_info.list_item.as_ref())
292 .map(|item| item.content_column)
293 .unwrap_or(2);
294
295 for check_line in (*end + 1)..=block.end_line {
296 if check_line - 1 < ctx.lines.len() {
297 let line = &ctx.lines[check_line - 1];
298 let line_content = line.content(ctx.content);
299 if block.item_lines.contains(&check_line) || line.heading.is_some() {
301 break;
302 }
303 if line.in_code_block {
305 break;
306 }
307 if line.indent >= min_continuation_indent {
309 actual_end = check_line;
310 }
311 else if self.config.allow_lazy_continuation
316 && !line.is_blank
317 && line.heading.is_none()
318 && !block.item_lines.contains(&check_line)
319 && !is_thematic_break(line_content)
320 {
321 actual_end = check_line;
324 } else if !line.is_blank {
325 break;
327 }
328 }
329 }
330 }
331
332 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
333 }
334 }
335
336 blocks
337 }
338
339 fn perform_checks(
340 &self,
341 ctx: &crate::lint_context::LintContext,
342 lines: &[&str],
343 list_blocks: &[(usize, usize, String)],
344 line_index: &LineIndex,
345 ) -> LintResult {
346 let mut warnings = Vec::new();
347 let num_lines = lines.len();
348
349 for (line_idx, line) in lines.iter().enumerate() {
352 let line_num = line_idx + 1;
353
354 let is_in_list = list_blocks
356 .iter()
357 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
358 if is_in_list {
359 continue;
360 }
361
362 if ctx
364 .line_info(line_num)
365 .is_some_and(|info| info.in_code_block || info.in_front_matter)
366 {
367 continue;
368 }
369
370 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
372 if line_idx > 0 {
374 let prev_line = lines[line_idx - 1];
375 let prev_is_blank = is_blank_in_context(prev_line);
376 let prev_excluded = ctx
377 .line_info(line_idx)
378 .is_some_and(|info| info.in_code_block || info.in_front_matter);
379
380 if !prev_is_blank && !prev_excluded {
381 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
383
384 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
385 warnings.push(LintWarning {
386 line: start_line,
387 column: start_col,
388 end_line,
389 end_column: end_col,
390 severity: Severity::Warning,
391 rule_name: Some(self.name().to_string()),
392 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
393 fix: Some(Fix {
394 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
395 replacement: format!("{bq_prefix}\n"),
396 }),
397 });
398 }
399 }
400 }
401 }
402
403 for &(start_line, end_line, ref prefix) in list_blocks {
404 if start_line > 1 {
405 let prev_line_actual_idx_0 = start_line - 2;
406 let prev_line_actual_idx_1 = start_line - 1;
407 let prev_line_str = lines[prev_line_actual_idx_0];
408 let is_prev_excluded = ctx
409 .line_info(prev_line_actual_idx_1)
410 .is_some_and(|info| info.in_code_block || info.in_front_matter);
411 let prev_prefix = BLOCKQUOTE_PREFIX_RE
412 .find(prev_line_str)
413 .map_or(String::new(), |m| m.as_str().to_string());
414 let prev_is_blank = is_blank_in_context(prev_line_str);
415 let prefixes_match = prev_prefix.trim() == prefix.trim();
416
417 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
420 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
421 let (start_line, start_col, end_line, end_col) =
423 calculate_line_range(start_line, lines[start_line - 1]);
424
425 warnings.push(LintWarning {
426 line: start_line,
427 column: start_col,
428 end_line,
429 end_column: end_col,
430 severity: Severity::Warning,
431 rule_name: Some(self.name().to_string()),
432 message: "List should be preceded by blank line".to_string(),
433 fix: Some(Fix {
434 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
435 replacement: format!("{prefix}\n"),
436 }),
437 });
438 }
439 }
440
441 if end_line < num_lines {
442 let next_line_idx_0 = end_line;
443 let next_line_idx_1 = end_line + 1;
444 let next_line_str = lines[next_line_idx_0];
445 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
448 || (next_line_idx_0 < ctx.lines.len()
449 && ctx.lines[next_line_idx_0].in_code_block
450 && ctx.lines[next_line_idx_0].indent >= 2);
451 let next_prefix = BLOCKQUOTE_PREFIX_RE
452 .find(next_line_str)
453 .map_or(String::new(), |m| m.as_str().to_string());
454 let next_is_blank = is_blank_in_context(next_line_str);
455 let prefixes_match = next_prefix.trim() == prefix.trim();
456
457 if !is_next_excluded && !next_is_blank && prefixes_match {
459 let (start_line_last, start_col_last, end_line_last, end_col_last) =
461 calculate_line_range(end_line, lines[end_line - 1]);
462
463 warnings.push(LintWarning {
464 line: start_line_last,
465 column: start_col_last,
466 end_line: end_line_last,
467 end_column: end_col_last,
468 severity: Severity::Warning,
469 rule_name: Some(self.name().to_string()),
470 message: "List should be followed by blank line".to_string(),
471 fix: Some(Fix {
472 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
473 replacement: format!("{prefix}\n"),
474 }),
475 });
476 }
477 }
478 }
479 Ok(warnings)
480 }
481}
482
483impl Rule for MD032BlanksAroundLists {
484 fn name(&self) -> &'static str {
485 "MD032"
486 }
487
488 fn description(&self) -> &'static str {
489 "Lists should be surrounded by blank lines"
490 }
491
492 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
493 let content = ctx.content;
494 let lines: Vec<&str> = content.lines().collect();
495 let line_index = &ctx.line_index;
496
497 if lines.is_empty() {
499 return Ok(Vec::new());
500 }
501
502 let list_blocks = self.convert_list_blocks(ctx);
503
504 if list_blocks.is_empty() {
505 return Ok(Vec::new());
506 }
507
508 self.perform_checks(ctx, &lines, &list_blocks, line_index)
509 }
510
511 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
512 self.fix_with_structure_impl(ctx)
513 }
514
515 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
516 if ctx.content.is_empty() || !ctx.likely_has_lists() {
518 return true;
519 }
520 ctx.list_blocks.is_empty()
522 }
523
524 fn category(&self) -> RuleCategory {
525 RuleCategory::List
526 }
527
528 fn as_any(&self) -> &dyn std::any::Any {
529 self
530 }
531
532 fn default_config_section(&self) -> Option<(String, toml::Value)> {
533 use crate::rule_config_serde::RuleConfig;
534 let default_config = MD032Config::default();
535 let json_value = serde_json::to_value(&default_config).ok()?;
536 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
537
538 if let toml::Value::Table(table) = toml_value {
539 if !table.is_empty() {
540 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
541 } else {
542 None
543 }
544 } else {
545 None
546 }
547 }
548
549 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
550 where
551 Self: Sized,
552 {
553 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
554 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
555 }
556}
557
558impl MD032BlanksAroundLists {
559 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
561 let lines: Vec<&str> = ctx.content.lines().collect();
562 let num_lines = lines.len();
563 if num_lines == 0 {
564 return Ok(String::new());
565 }
566
567 let list_blocks = self.convert_list_blocks(ctx);
568 if list_blocks.is_empty() {
569 return Ok(ctx.content.to_string());
570 }
571
572 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
573
574 for &(start_line, end_line, ref prefix) in &list_blocks {
576 if start_line > 1 {
578 let prev_line_actual_idx_0 = start_line - 2;
579 let prev_line_actual_idx_1 = start_line - 1;
580 let is_prev_excluded = ctx
581 .line_info(prev_line_actual_idx_1)
582 .is_some_and(|info| info.in_code_block || info.in_front_matter);
583 let prev_prefix = BLOCKQUOTE_PREFIX_RE
584 .find(lines[prev_line_actual_idx_0])
585 .map_or(String::new(), |m| m.as_str().to_string());
586
587 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
588 if !is_prev_excluded
591 && !is_blank_in_context(lines[prev_line_actual_idx_0])
592 && prev_prefix.trim() == prefix.trim()
593 && should_require
594 {
595 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
597 insertions.insert(start_line, bq_prefix);
598 }
599 }
600
601 if end_line < num_lines {
603 let after_block_line_idx_0 = end_line;
604 let after_block_line_idx_1 = end_line + 1;
605 let line_after_block_content_str = lines[after_block_line_idx_0];
606 let is_line_after_excluded = ctx
609 .line_info(after_block_line_idx_1)
610 .is_some_and(|info| info.in_code_block || info.in_front_matter)
611 || (after_block_line_idx_0 < ctx.lines.len()
612 && ctx.lines[after_block_line_idx_0].in_code_block
613 && ctx.lines[after_block_line_idx_0].indent >= 2
614 && (ctx.lines[after_block_line_idx_0]
615 .content(ctx.content)
616 .trim()
617 .starts_with("```")
618 || ctx.lines[after_block_line_idx_0]
619 .content(ctx.content)
620 .trim()
621 .starts_with("~~~")));
622 let after_prefix = BLOCKQUOTE_PREFIX_RE
623 .find(line_after_block_content_str)
624 .map_or(String::new(), |m| m.as_str().to_string());
625
626 if !is_line_after_excluded
628 && !is_blank_in_context(line_after_block_content_str)
629 && after_prefix.trim() == prefix.trim()
630 {
631 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
633 insertions.insert(after_block_line_idx_1, bq_prefix);
634 }
635 }
636 }
637
638 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
640 for (i, line) in lines.iter().enumerate() {
641 let current_line_num = i + 1;
642 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
643 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
644 {
645 result_lines.push(prefix_to_insert.clone());
646 }
647 result_lines.push(line.to_string());
648 }
649
650 let mut result = result_lines.join("\n");
652 if ctx.content.ends_with('\n') {
653 result.push('\n');
654 }
655 Ok(result)
656 }
657}
658
659fn is_blank_in_context(line: &str) -> bool {
661 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
664 line[m.end()..].trim().is_empty()
666 } else {
667 line.trim().is_empty()
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675 use crate::lint_context::LintContext;
676 use crate::rule::Rule;
677
678 fn lint(content: &str) -> Vec<LintWarning> {
679 let rule = MD032BlanksAroundLists::default();
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 rule.check(&ctx).expect("Lint check failed")
682 }
683
684 fn fix(content: &str) -> String {
685 let rule = MD032BlanksAroundLists::default();
686 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
687 rule.fix(&ctx).expect("Lint fix failed")
688 }
689
690 fn check_warnings_have_fixes(content: &str) {
692 let warnings = lint(content);
693 for warning in &warnings {
694 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
695 }
696 }
697
698 #[test]
699 fn test_list_at_start() {
700 let content = "- Item 1\n- Item 2\nText";
703 let warnings = lint(content);
704 assert_eq!(
705 warnings.len(),
706 0,
707 "Trailing text is lazy continuation per CommonMark - no warning expected"
708 );
709 }
710
711 #[test]
712 fn test_list_at_end() {
713 let content = "Text\n- Item 1\n- Item 2";
714 let warnings = lint(content);
715 assert_eq!(
716 warnings.len(),
717 1,
718 "Expected 1 warning for list at end without preceding blank line"
719 );
720 assert_eq!(
721 warnings[0].line, 2,
722 "Warning should be on the first line of the list (line 2)"
723 );
724 assert!(warnings[0].message.contains("preceded by blank line"));
725
726 check_warnings_have_fixes(content);
728
729 let fixed_content = fix(content);
730 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
731
732 let warnings_after_fix = lint(&fixed_content);
734 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
735 }
736
737 #[test]
738 fn test_list_in_middle() {
739 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
742 let warnings = lint(content);
743 assert_eq!(
744 warnings.len(),
745 1,
746 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
747 );
748 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
749 assert!(warnings[0].message.contains("preceded by blank line"));
750
751 check_warnings_have_fixes(content);
753
754 let fixed_content = fix(content);
755 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
756
757 let warnings_after_fix = lint(&fixed_content);
759 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
760 }
761
762 #[test]
763 fn test_correct_spacing() {
764 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
765 let warnings = lint(content);
766 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
767
768 let fixed_content = fix(content);
769 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
770 }
771
772 #[test]
773 fn test_list_with_content() {
774 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
777 let warnings = lint(content);
778 assert_eq!(
779 warnings.len(),
780 1,
781 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
782 );
783 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
784 assert!(warnings[0].message.contains("preceded by blank line"));
785
786 check_warnings_have_fixes(content);
788
789 let fixed_content = fix(content);
790 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
791 assert_eq!(
792 fixed_content, expected_fixed,
793 "Fix did not produce the expected output. Got:\n{fixed_content}"
794 );
795
796 let warnings_after_fix = lint(&fixed_content);
798 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
799 }
800
801 #[test]
802 fn test_nested_list() {
803 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
805 let warnings = lint(content);
806 assert_eq!(
807 warnings.len(),
808 1,
809 "Nested list block needs preceding blank only. Got: {warnings:?}"
810 );
811 assert_eq!(warnings[0].line, 2);
812 assert!(warnings[0].message.contains("preceded by blank line"));
813
814 check_warnings_have_fixes(content);
816
817 let fixed_content = fix(content);
818 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
819
820 let warnings_after_fix = lint(&fixed_content);
822 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
823 }
824
825 #[test]
826 fn test_list_with_internal_blanks() {
827 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
829 let warnings = lint(content);
830 assert_eq!(
831 warnings.len(),
832 1,
833 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
834 );
835 assert_eq!(warnings[0].line, 2);
836 assert!(warnings[0].message.contains("preceded by blank line"));
837
838 check_warnings_have_fixes(content);
840
841 let fixed_content = fix(content);
842 assert_eq!(
843 fixed_content,
844 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
845 );
846
847 let warnings_after_fix = lint(&fixed_content);
849 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
850 }
851
852 #[test]
853 fn test_ignore_code_blocks() {
854 let content = "```\n- Not a list item\n```\nText";
855 let warnings = lint(content);
856 assert_eq!(warnings.len(), 0);
857 let fixed_content = fix(content);
858 assert_eq!(fixed_content, content);
859 }
860
861 #[test]
862 fn test_ignore_front_matter() {
863 let content = "---\ntitle: Test\n---\n- List Item\nText";
865 let warnings = lint(content);
866 assert_eq!(
867 warnings.len(),
868 0,
869 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
870 );
871
872 let fixed_content = fix(content);
874 assert_eq!(fixed_content, content, "No changes when no warnings");
875 }
876
877 #[test]
878 fn test_multiple_lists() {
879 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
884 let warnings = lint(content);
885 assert!(
887 !warnings.is_empty(),
888 "Should have at least one warning for missing blank line. Got: {warnings:?}"
889 );
890
891 check_warnings_have_fixes(content);
893
894 let fixed_content = fix(content);
895 let warnings_after_fix = lint(&fixed_content);
897 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
898 }
899
900 #[test]
901 fn test_adjacent_lists() {
902 let content = "- List 1\n\n* List 2";
903 let warnings = lint(content);
904 assert_eq!(warnings.len(), 0);
905 let fixed_content = fix(content);
906 assert_eq!(fixed_content, content);
907 }
908
909 #[test]
910 fn test_list_in_blockquote() {
911 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
913 let warnings = lint(content);
914 assert_eq!(
915 warnings.len(),
916 1,
917 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
918 );
919 assert_eq!(warnings[0].line, 2);
920
921 check_warnings_have_fixes(content);
923
924 let fixed_content = fix(content);
925 assert_eq!(
927 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
928 "Fix for blockquoted list failed. Got:\n{fixed_content}"
929 );
930
931 let warnings_after_fix = lint(&fixed_content);
933 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
934 }
935
936 #[test]
937 fn test_ordered_list() {
938 let content = "Text\n1. Item 1\n2. Item 2\nText";
940 let warnings = lint(content);
941 assert_eq!(warnings.len(), 1);
942
943 check_warnings_have_fixes(content);
945
946 let fixed_content = fix(content);
947 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
948
949 let warnings_after_fix = lint(&fixed_content);
951 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
952 }
953
954 #[test]
955 fn test_no_double_blank_fix() {
956 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
959 assert_eq!(
960 warnings.len(),
961 0,
962 "Should have no warnings - properly preceded, trailing is lazy"
963 );
964
965 let fixed_content = fix(content);
966 assert_eq!(
967 fixed_content, content,
968 "No fix needed when no warnings. Got:\n{fixed_content}"
969 );
970
971 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
973 assert_eq!(warnings2.len(), 1);
974 if !warnings2.is_empty() {
975 assert_eq!(
976 warnings2[0].line, 2,
977 "Warning line for missing blank before should be the first line of the block"
978 );
979 }
980
981 check_warnings_have_fixes(content2);
983
984 let fixed_content2 = fix(content2);
985 assert_eq!(
986 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
987 "Fix added extra blank before. Got:\n{fixed_content2}"
988 );
989 }
990
991 #[test]
992 fn test_empty_input() {
993 let content = "";
994 let warnings = lint(content);
995 assert_eq!(warnings.len(), 0);
996 let fixed_content = fix(content);
997 assert_eq!(fixed_content, "");
998 }
999
1000 #[test]
1001 fn test_only_list() {
1002 let content = "- Item 1\n- Item 2";
1003 let warnings = lint(content);
1004 assert_eq!(warnings.len(), 0);
1005 let fixed_content = fix(content);
1006 assert_eq!(fixed_content, content);
1007 }
1008
1009 #[test]
1012 fn test_fix_complex_nested_blockquote() {
1013 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1015 let warnings = lint(content);
1016 assert_eq!(
1017 warnings.len(),
1018 1,
1019 "Should warn for missing preceding blank only. Got: {warnings:?}"
1020 );
1021
1022 check_warnings_have_fixes(content);
1024
1025 let fixed_content = fix(content);
1026 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1028 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1029
1030 let warnings_after_fix = lint(&fixed_content);
1031 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1032 }
1033
1034 #[test]
1035 fn test_fix_mixed_list_markers() {
1036 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1039 let warnings = lint(content);
1040 assert!(
1042 !warnings.is_empty(),
1043 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1044 );
1045
1046 check_warnings_have_fixes(content);
1048
1049 let fixed_content = fix(content);
1050 assert!(
1052 fixed_content.contains("Text\n\n-"),
1053 "Fix should add blank line before first list item"
1054 );
1055
1056 let warnings_after_fix = lint(&fixed_content);
1058 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1059 }
1060
1061 #[test]
1062 fn test_fix_ordered_list_with_different_numbers() {
1063 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1065 let warnings = lint(content);
1066 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1067
1068 check_warnings_have_fixes(content);
1070
1071 let fixed_content = fix(content);
1072 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1073 assert_eq!(
1074 fixed_content, expected,
1075 "Fix should handle ordered lists with non-sequential numbers"
1076 );
1077
1078 let warnings_after_fix = lint(&fixed_content);
1080 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1081 }
1082
1083 #[test]
1084 fn test_fix_list_with_code_blocks_inside() {
1085 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1087 let warnings = lint(content);
1088 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1089
1090 check_warnings_have_fixes(content);
1092
1093 let fixed_content = fix(content);
1094 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1095 assert_eq!(
1096 fixed_content, expected,
1097 "Fix should handle lists with internal code blocks"
1098 );
1099
1100 let warnings_after_fix = lint(&fixed_content);
1102 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1103 }
1104
1105 #[test]
1106 fn test_fix_deeply_nested_lists() {
1107 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1109 let warnings = lint(content);
1110 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1111
1112 check_warnings_have_fixes(content);
1114
1115 let fixed_content = fix(content);
1116 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1117 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1118
1119 let warnings_after_fix = lint(&fixed_content);
1121 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1122 }
1123
1124 #[test]
1125 fn test_fix_list_with_multiline_items() {
1126 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1129 let warnings = lint(content);
1130 assert_eq!(
1131 warnings.len(),
1132 1,
1133 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1134 );
1135
1136 check_warnings_have_fixes(content);
1138
1139 let fixed_content = fix(content);
1140 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1141 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1142
1143 let warnings_after_fix = lint(&fixed_content);
1145 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1146 }
1147
1148 #[test]
1149 fn test_fix_list_at_document_boundaries() {
1150 let content1 = "- Item 1\n- Item 2";
1152 let warnings1 = lint(content1);
1153 assert_eq!(
1154 warnings1.len(),
1155 0,
1156 "List at document start should not need blank before"
1157 );
1158 let fixed1 = fix(content1);
1159 assert_eq!(fixed1, content1, "No fix needed for list at start");
1160
1161 let content2 = "Text\n- Item 1\n- Item 2";
1163 let warnings2 = lint(content2);
1164 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1165 check_warnings_have_fixes(content2);
1166 let fixed2 = fix(content2);
1167 assert_eq!(
1168 fixed2, "Text\n\n- Item 1\n- Item 2",
1169 "Should add blank before list at end"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_fix_preserves_existing_blank_lines() {
1175 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1176 let warnings = lint(content);
1177 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1178 let fixed_content = fix(content);
1179 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1180 }
1181
1182 #[test]
1183 fn test_fix_handles_tabs_and_spaces() {
1184 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1187 let warnings = lint(content);
1188 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1190
1191 check_warnings_have_fixes(content);
1193
1194 let fixed_content = fix(content);
1195 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1198 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1199
1200 let warnings_after_fix = lint(&fixed_content);
1202 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1203 }
1204
1205 #[test]
1206 fn test_fix_warning_objects_have_correct_ranges() {
1207 let content = "Text\n- Item 1\n- Item 2\nText";
1209 let warnings = lint(content);
1210 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1211
1212 for warning in &warnings {
1214 assert!(warning.fix.is_some(), "Warning should have fix");
1215 let fix = warning.fix.as_ref().unwrap();
1216 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1217 assert!(
1218 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1219 "Fix should have replacement or be insertion"
1220 );
1221 }
1222 }
1223
1224 #[test]
1225 fn test_fix_idempotent() {
1226 let content = "Text\n- Item 1\n- Item 2\nText";
1228
1229 let fixed_once = fix(content);
1231 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1232
1233 let fixed_twice = fix(&fixed_once);
1235 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1236
1237 let warnings_after_fix = lint(&fixed_once);
1239 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1240 }
1241
1242 #[test]
1243 fn test_fix_with_normalized_line_endings() {
1244 let content = "Text\n- Item 1\n- Item 2\nText";
1248 let warnings = lint(content);
1249 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1250
1251 check_warnings_have_fixes(content);
1253
1254 let fixed_content = fix(content);
1255 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1257 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1258 }
1259
1260 #[test]
1261 fn test_fix_preserves_final_newline() {
1262 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1265 let fixed_with_newline = fix(content_with_newline);
1266 assert!(
1267 fixed_with_newline.ends_with('\n'),
1268 "Fix should preserve final newline when present"
1269 );
1270 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1272
1273 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1275 let fixed_without_newline = fix(content_without_newline);
1276 assert!(
1277 !fixed_without_newline.ends_with('\n'),
1278 "Fix should not add final newline when not present"
1279 );
1280 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1282 }
1283
1284 #[test]
1285 fn test_fix_multiline_list_items_no_indent() {
1286 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";
1287
1288 let warnings = lint(content);
1289 assert_eq!(
1291 warnings.len(),
1292 0,
1293 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1294 );
1295
1296 let fixed_content = fix(content);
1297 assert_eq!(
1299 fixed_content, content,
1300 "Should not modify correctly formatted multi-line list items"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_nested_list_with_lazy_continuation() {
1306 let content = r#"# Test
1312
1313- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1314 1. Switch/case dispatcher statements (original Phase 3.2)
1315 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1316`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1317 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1318 references"#;
1319
1320 let warnings = lint(content);
1321 let md032_warnings: Vec<_> = warnings
1324 .iter()
1325 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1326 .collect();
1327 assert_eq!(
1328 md032_warnings.len(),
1329 0,
1330 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1331 );
1332 }
1333
1334 #[test]
1335 fn test_pipes_in_code_spans_not_detected_as_table() {
1336 let content = r#"# Test
1338
1339- Item with `a | b` inline code
1340 - Nested item should work
1341
1342"#;
1343
1344 let warnings = lint(content);
1345 let md032_warnings: Vec<_> = warnings
1346 .iter()
1347 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1348 .collect();
1349 assert_eq!(
1350 md032_warnings.len(),
1351 0,
1352 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1353 );
1354 }
1355
1356 #[test]
1357 fn test_multiple_code_spans_with_pipes() {
1358 let content = r#"# Test
1360
1361- Item with `a | b` and `c || d` operators
1362 - Nested item should work
1363
1364"#;
1365
1366 let warnings = lint(content);
1367 let md032_warnings: Vec<_> = warnings
1368 .iter()
1369 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1370 .collect();
1371 assert_eq!(
1372 md032_warnings.len(),
1373 0,
1374 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1375 );
1376 }
1377
1378 #[test]
1379 fn test_actual_table_breaks_list() {
1380 let content = r#"# Test
1382
1383- Item before table
1384
1385| Col1 | Col2 |
1386|------|------|
1387| A | B |
1388
1389- Item after table
1390
1391"#;
1392
1393 let warnings = lint(content);
1394 let md032_warnings: Vec<_> = warnings
1396 .iter()
1397 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1398 .collect();
1399 assert_eq!(
1400 md032_warnings.len(),
1401 0,
1402 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_thematic_break_not_lazy_continuation() {
1408 let content = r#"- Item 1
1411- Item 2
1412***
1413
1414More text.
1415"#;
1416
1417 let warnings = lint(content);
1418 let md032_warnings: Vec<_> = warnings
1419 .iter()
1420 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1421 .collect();
1422 assert_eq!(
1423 md032_warnings.len(),
1424 1,
1425 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1426 );
1427 assert!(
1428 md032_warnings[0].message.contains("followed by blank line"),
1429 "Warning should be about missing blank after list"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_thematic_break_with_blank_line() {
1435 let content = r#"- Item 1
1437- Item 2
1438
1439***
1440
1441More text.
1442"#;
1443
1444 let warnings = lint(content);
1445 let md032_warnings: Vec<_> = warnings
1446 .iter()
1447 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1448 .collect();
1449 assert_eq!(
1450 md032_warnings.len(),
1451 0,
1452 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_various_thematic_break_styles() {
1458 for hr in ["---", "***", "___"] {
1463 let content = format!(
1464 r#"- Item 1
1465- Item 2
1466{hr}
1467
1468More text.
1469"#
1470 );
1471
1472 let warnings = lint(&content);
1473 let md032_warnings: Vec<_> = warnings
1474 .iter()
1475 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1476 .collect();
1477 assert_eq!(
1478 md032_warnings.len(),
1479 1,
1480 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1481 );
1482 }
1483 }
1484
1485 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1488 let rule = MD032BlanksAroundLists::from_config_struct(config);
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 rule.check(&ctx).expect("Lint check failed")
1491 }
1492
1493 fn fix_with_config(content: &str, config: MD032Config) -> String {
1494 let rule = MD032BlanksAroundLists::from_config_struct(config);
1495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1496 rule.fix(&ctx).expect("Lint fix failed")
1497 }
1498
1499 #[test]
1500 fn test_lazy_continuation_allowed_by_default() {
1501 let content = "# Heading\n\n1. List\nSome text.";
1503 let warnings = lint(content);
1504 assert_eq!(
1505 warnings.len(),
1506 0,
1507 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1508 );
1509 }
1510
1511 #[test]
1512 fn test_lazy_continuation_disallowed() {
1513 let content = "# Heading\n\n1. List\nSome text.";
1515 let config = MD032Config {
1516 allow_lazy_continuation: false,
1517 };
1518 let warnings = lint_with_config(content, config);
1519 assert_eq!(
1520 warnings.len(),
1521 1,
1522 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1523 );
1524 assert!(
1525 warnings[0].message.contains("followed by blank line"),
1526 "Warning message should mention blank line"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_lazy_continuation_fix() {
1532 let content = "# Heading\n\n1. List\nSome text.";
1534 let config = MD032Config {
1535 allow_lazy_continuation: false,
1536 };
1537 let fixed = fix_with_config(content, config.clone());
1538 assert_eq!(
1539 fixed, "# Heading\n\n1. List\n\nSome text.",
1540 "Fix should insert blank line before lazy continuation"
1541 );
1542
1543 let warnings_after = lint_with_config(&fixed, config);
1545 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1546 }
1547
1548 #[test]
1549 fn test_lazy_continuation_multiple_lines() {
1550 let content = "- Item 1\nLine 2\nLine 3";
1552 let config = MD032Config {
1553 allow_lazy_continuation: false,
1554 };
1555 let warnings = lint_with_config(content, config.clone());
1556 assert_eq!(
1557 warnings.len(),
1558 1,
1559 "Should warn for lazy continuation. Got: {warnings:?}"
1560 );
1561
1562 let fixed = fix_with_config(content, config.clone());
1563 assert_eq!(
1564 fixed, "- Item 1\n\nLine 2\nLine 3",
1565 "Fix should insert blank line after list"
1566 );
1567
1568 let warnings_after = lint_with_config(&fixed, config);
1570 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1571 }
1572
1573 #[test]
1574 fn test_lazy_continuation_with_indented_content() {
1575 let content = "- Item 1\n Indented content\nLazy text";
1577 let config = MD032Config {
1578 allow_lazy_continuation: false,
1579 };
1580 let warnings = lint_with_config(content, config);
1581 assert_eq!(
1582 warnings.len(),
1583 1,
1584 "Should warn for lazy text after indented content. Got: {warnings:?}"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_lazy_continuation_properly_separated() {
1590 let content = "- Item 1\n\nSome text.";
1592 let config = MD032Config {
1593 allow_lazy_continuation: false,
1594 };
1595 let warnings = lint_with_config(content, config);
1596 assert_eq!(
1597 warnings.len(),
1598 0,
1599 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1600 );
1601 }
1602
1603 #[test]
1606 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1607 let content = "1) First item\nLazy continuation";
1609 let config = MD032Config {
1610 allow_lazy_continuation: false,
1611 };
1612 let warnings = lint_with_config(content, config.clone());
1613 assert_eq!(
1614 warnings.len(),
1615 1,
1616 "Should warn for lazy continuation with parenthesis marker"
1617 );
1618
1619 let fixed = fix_with_config(content, config);
1620 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1621 }
1622
1623 #[test]
1624 fn test_lazy_continuation_followed_by_another_list() {
1625 let content = "- Item 1\nSome text\n- Item 2";
1630 let config = MD032Config {
1631 allow_lazy_continuation: false,
1632 };
1633 let warnings = lint_with_config(content, config);
1634 assert_eq!(
1637 warnings.len(),
1638 0,
1639 "Valid list structure should not trigger lazy continuation warning"
1640 );
1641 }
1642
1643 #[test]
1644 fn test_lazy_continuation_multiple_in_document() {
1645 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1651 let config = MD032Config {
1652 allow_lazy_continuation: false,
1653 };
1654 let warnings = lint_with_config(content, config.clone());
1655 assert_eq!(
1656 warnings.len(),
1657 1,
1658 "Should warn for second list (not followed by blank). Got: {warnings:?}"
1659 );
1660
1661 let fixed = fix_with_config(content, config.clone());
1662 let warnings_after = lint_with_config(&fixed, config);
1663 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1664 }
1665
1666 #[test]
1667 fn test_lazy_continuation_end_of_document_no_newline() {
1668 let content = "- Item\nNo trailing newline";
1670 let config = MD032Config {
1671 allow_lazy_continuation: false,
1672 };
1673 let warnings = lint_with_config(content, config.clone());
1674 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1675
1676 let fixed = fix_with_config(content, config);
1677 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1678 }
1679
1680 #[test]
1681 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1682 let content = "- Item 1\n---";
1685 let config = MD032Config {
1686 allow_lazy_continuation: false,
1687 };
1688 let warnings = lint_with_config(content, config.clone());
1689 assert_eq!(
1691 warnings.len(),
1692 1,
1693 "List should need blank line before thematic break. Got: {warnings:?}"
1694 );
1695
1696 let fixed = fix_with_config(content, config);
1698 assert_eq!(fixed, "- Item 1\n\n---");
1699 }
1700
1701 #[test]
1702 fn test_lazy_continuation_heading_not_flagged() {
1703 let content = "- Item 1\n# Heading";
1706 let config = MD032Config {
1707 allow_lazy_continuation: false,
1708 };
1709 let warnings = lint_with_config(content, config);
1710 assert!(
1713 warnings.iter().all(|w| !w.message.contains("lazy")),
1714 "Heading should not trigger lazy continuation warning"
1715 );
1716 }
1717
1718 #[test]
1719 fn test_lazy_continuation_mixed_list_types() {
1720 let content = "- Unordered\n1. Ordered\nLazy text";
1722 let config = MD032Config {
1723 allow_lazy_continuation: false,
1724 };
1725 let warnings = lint_with_config(content, config.clone());
1726 assert!(!warnings.is_empty(), "Should warn about structure issues");
1727 }
1728
1729 #[test]
1730 fn test_lazy_continuation_deep_nesting() {
1731 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1733 let config = MD032Config {
1734 allow_lazy_continuation: false,
1735 };
1736 let warnings = lint_with_config(content, config.clone());
1737 assert!(
1738 !warnings.is_empty(),
1739 "Should warn about lazy continuation after nested list"
1740 );
1741
1742 let fixed = fix_with_config(content, config.clone());
1743 let warnings_after = lint_with_config(&fixed, config);
1744 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1745 }
1746
1747 #[test]
1748 fn test_lazy_continuation_with_emphasis_in_text() {
1749 let content = "- Item\n*emphasized* continuation";
1751 let config = MD032Config {
1752 allow_lazy_continuation: false,
1753 };
1754 let warnings = lint_with_config(content, config.clone());
1755 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1756
1757 let fixed = fix_with_config(content, config);
1758 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1759 }
1760
1761 #[test]
1762 fn test_lazy_continuation_with_code_span() {
1763 let content = "- Item\n`code` continuation";
1765 let config = MD032Config {
1766 allow_lazy_continuation: false,
1767 };
1768 let warnings = lint_with_config(content, config.clone());
1769 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1770
1771 let fixed = fix_with_config(content, config);
1772 assert_eq!(fixed, "- Item\n\n`code` continuation");
1773 }
1774
1775 #[test]
1776 fn test_lazy_continuation_whitespace_only_line() {
1777 let content = "- Item\n \nText after whitespace-only line";
1780 let config = MD032Config {
1781 allow_lazy_continuation: false,
1782 };
1783 let warnings = lint_with_config(content, config.clone());
1784 assert_eq!(
1786 warnings.len(),
1787 1,
1788 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1789 );
1790
1791 let fixed = fix_with_config(content, config);
1793 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1794 }
1795
1796 #[test]
1797 fn test_lazy_continuation_blockquote_context() {
1798 let content = "> - Item\n> Lazy in quote";
1800 let config = MD032Config {
1801 allow_lazy_continuation: false,
1802 };
1803 let warnings = lint_with_config(content, config);
1804 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1807 }
1808
1809 #[test]
1810 fn test_lazy_continuation_fix_preserves_content() {
1811 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1813 let config = MD032Config {
1814 allow_lazy_continuation: false,
1815 };
1816 let fixed = fix_with_config(content, config);
1817 assert!(fixed.contains("<>&"), "Should preserve special chars");
1818 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1819 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1820 }
1821
1822 #[test]
1823 fn test_lazy_continuation_fix_idempotent() {
1824 let content = "- Item\nLazy";
1826 let config = MD032Config {
1827 allow_lazy_continuation: false,
1828 };
1829 let fixed_once = fix_with_config(content, config.clone());
1830 let fixed_twice = fix_with_config(&fixed_once, config);
1831 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1832 }
1833
1834 #[test]
1835 fn test_lazy_continuation_config_default_allows() {
1836 let content = "- Item\nLazy text that continues";
1838 let default_config = MD032Config::default();
1839 assert!(
1840 default_config.allow_lazy_continuation,
1841 "Default should allow lazy continuation"
1842 );
1843 let warnings = lint_with_config(content, default_config);
1844 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1845 }
1846
1847 #[test]
1848 fn test_lazy_continuation_after_multi_line_item() {
1849 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
1851 let config = MD032Config {
1852 allow_lazy_continuation: false,
1853 };
1854 let warnings = lint_with_config(content, config.clone());
1855 assert_eq!(
1856 warnings.len(),
1857 1,
1858 "Should warn only for the lazy line, not the indented line"
1859 );
1860 }
1861
1862 #[test]
1864 fn test_blockquote_list_with_continuation_and_nested() {
1865 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
1868 let warnings = lint(content);
1869 assert_eq!(
1870 warnings.len(),
1871 0,
1872 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
1873 );
1874 }
1875
1876 #[test]
1877 fn test_blockquote_list_simple() {
1878 let content = "> - item 1\n> - item 2";
1880 let warnings = lint(content);
1881 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
1882 }
1883
1884 #[test]
1885 fn test_blockquote_list_with_continuation_only() {
1886 let content = "> - item 1\n> continuation\n> - item 2";
1888 let warnings = lint(content);
1889 assert_eq!(
1890 warnings.len(),
1891 0,
1892 "Blockquoted list with continuation should have no warnings"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_blockquote_list_with_lazy_continuation() {
1898 let content = "> - item 1\n> lazy continuation\n> - item 2";
1900 let warnings = lint(content);
1901 assert_eq!(
1902 warnings.len(),
1903 0,
1904 "Blockquoted list with lazy continuation should have no warnings"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_nested_blockquote_list() {
1910 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
1912 let warnings = lint(content);
1913 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
1914 }
1915
1916 #[test]
1917 fn test_blockquote_list_needs_preceding_blank() {
1918 let content = "> Text before\n> - item 1\n> - item 2";
1920 let warnings = lint(content);
1921 assert_eq!(
1922 warnings.len(),
1923 1,
1924 "Should warn for missing blank before blockquoted list"
1925 );
1926 }
1927
1928 #[test]
1929 fn test_blockquote_list_properly_separated() {
1930 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
1932 let warnings = lint(content);
1933 assert_eq!(
1934 warnings.len(),
1935 0,
1936 "Properly separated blockquoted list should have no warnings"
1937 );
1938 }
1939
1940 #[test]
1941 fn test_blockquote_ordered_list() {
1942 let content = "> 1. item 1\n> continuation\n> 2. item 2";
1944 let warnings = lint(content);
1945 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
1946 }
1947
1948 #[test]
1949 fn test_blockquote_list_with_empty_blockquote_line() {
1950 let content = "> - item 1\n>\n> - item 2";
1952 let warnings = lint(content);
1953 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
1954 }
1955
1956 #[test]
1958 fn test_blockquote_list_multi_paragraph_items() {
1959 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
1962 let warnings = lint(content);
1963 assert_eq!(
1964 warnings.len(),
1965 0,
1966 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
1967 );
1968 }
1969
1970 #[test]
1972 fn test_blockquote_ordered_list_multi_paragraph_items() {
1973 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
1974 let warnings = lint(content);
1975 assert_eq!(
1976 warnings.len(),
1977 0,
1978 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
1979 );
1980 }
1981
1982 #[test]
1984 fn test_blockquote_list_multiple_continuations() {
1985 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
1986 let warnings = lint(content);
1987 assert_eq!(
1988 warnings.len(),
1989 0,
1990 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
1991 );
1992 }
1993
1994 #[test]
1996 fn test_nested_blockquote_multi_paragraph_list() {
1997 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
1998 let warnings = lint(content);
1999 assert_eq!(
2000 warnings.len(),
2001 0,
2002 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2003 );
2004 }
2005
2006 #[test]
2008 fn test_triple_nested_blockquote_multi_paragraph_list() {
2009 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2010 let warnings = lint(content);
2011 assert_eq!(
2012 warnings.len(),
2013 0,
2014 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2015 );
2016 }
2017
2018 #[test]
2020 fn test_blockquote_list_last_item_continuation() {
2021 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2022 let warnings = lint(content);
2023 assert_eq!(
2024 warnings.len(),
2025 0,
2026 "Last item with continuation should have no warnings. Got: {warnings:?}"
2027 );
2028 }
2029
2030 #[test]
2032 fn test_blockquote_list_first_item_only_continuation() {
2033 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2034 let warnings = lint(content);
2035 assert_eq!(
2036 warnings.len(),
2037 0,
2038 "Single item with continuation should have no warnings. Got: {warnings:?}"
2039 );
2040 }
2041
2042 #[test]
2046 fn test_blockquote_level_change_breaks_list() {
2047 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2049 let warnings = lint(content);
2050 assert!(
2054 warnings.len() <= 2,
2055 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2056 );
2057 }
2058
2059 #[test]
2061 fn test_exit_blockquote_needs_blank_before_list() {
2062 let content = "> Blockquote text\n\n- List outside blockquote\n";
2064 let warnings = lint(content);
2065 assert_eq!(
2066 warnings.len(),
2067 0,
2068 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2069 );
2070
2071 let content2 = "> Blockquote text\n- List outside blockquote\n";
2075 let warnings2 = lint(content2);
2076 assert!(
2078 warnings2.len() <= 1,
2079 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2080 );
2081 }
2082
2083 #[test]
2085 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2086 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2088 let warnings = lint(content_dash);
2089 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2090
2091 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2093 let warnings = lint(content_asterisk);
2094 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2095
2096 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2098 let warnings = lint(content_plus);
2099 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2100 }
2101
2102 #[test]
2104 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2105 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2106 let warnings = lint(content);
2107 assert_eq!(
2108 warnings.len(),
2109 0,
2110 "Parenthesis ordered markers should work. Got: {warnings:?}"
2111 );
2112 }
2113
2114 #[test]
2116 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2117 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2119 let warnings = lint(content);
2120 assert_eq!(
2121 warnings.len(),
2122 0,
2123 "Multi-digit ordered list should work. Got: {warnings:?}"
2124 );
2125 }
2126
2127 #[test]
2129 fn test_blockquote_multi_paragraph_with_formatting() {
2130 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2131 let warnings = lint(content);
2132 assert_eq!(
2133 warnings.len(),
2134 0,
2135 "Continuation with inline formatting should work. Got: {warnings:?}"
2136 );
2137 }
2138
2139 #[test]
2141 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2142 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2143 let warnings = lint(content);
2144 assert_eq!(
2145 warnings.len(),
2146 0,
2147 "All items with continuations should work. Got: {warnings:?}"
2148 );
2149 }
2150
2151 #[test]
2153 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2154 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2155 let warnings = lint(content);
2156 assert_eq!(
2157 warnings.len(),
2158 0,
2159 "Lowercase continuation should work. Got: {warnings:?}"
2160 );
2161 }
2162
2163 #[test]
2165 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2166 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2167 let warnings = lint(content);
2168 assert_eq!(
2169 warnings.len(),
2170 0,
2171 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2172 );
2173 }
2174
2175 #[test]
2177 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2178 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2180 let warnings = lint(content);
2181 assert!(
2183 warnings.len() <= 1,
2184 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2185 );
2186 }
2187
2188 #[test]
2190 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2191 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2193 let warnings = lint(content);
2194 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2195 }
2196
2197 #[test]
2198 fn test_blockquote_list_varying_spaces_after_marker() {
2199 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2201 let warnings = lint(content);
2202 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2203 }
2204
2205 #[test]
2206 fn test_deeply_nested_blockquote_list() {
2207 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2209 let warnings = lint(content);
2210 assert_eq!(
2211 warnings.len(),
2212 0,
2213 "Deeply nested blockquote list should have no warnings"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_blockquote_level_change_in_list() {
2219 let content = "> - item 1\n>> - deeper item\n> - item 2";
2221 let warnings = lint(content);
2224 assert!(
2225 !warnings.is_empty(),
2226 "Blockquote level change should break list and trigger warnings"
2227 );
2228 }
2229
2230 #[test]
2231 fn test_blockquote_list_with_code_span() {
2232 let content = "> - item with `code`\n> continuation\n> - item 2";
2234 let warnings = lint(content);
2235 assert_eq!(
2236 warnings.len(),
2237 0,
2238 "Blockquote list with code span should have no warnings"
2239 );
2240 }
2241
2242 #[test]
2243 fn test_blockquote_list_at_document_end() {
2244 let content = "> Some text\n>\n> - item 1\n> - item 2";
2246 let warnings = lint(content);
2247 assert_eq!(
2248 warnings.len(),
2249 0,
2250 "Blockquote list at document end should have no warnings"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_fix_preserves_blockquote_prefix_before_list() {
2256 let content = "> Text before
2258> - Item 1
2259> - Item 2";
2260 let fixed = fix(content);
2261
2262 let expected = "> Text before
2264>
2265> - Item 1
2266> - Item 2";
2267 assert_eq!(
2268 fixed, expected,
2269 "Fix should insert '>' blank line, not plain blank line"
2270 );
2271 }
2272
2273 #[test]
2274 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2275 let content = ">>> Triple nested
2278>>> - Item 1
2279>>> - Item 2
2280>>> More text";
2281 let fixed = fix(content);
2282
2283 let expected = ">>> Triple nested
2285>>>
2286>>> - Item 1
2287>>> - Item 2
2288>>> More text";
2289 assert_eq!(
2290 fixed, expected,
2291 "Fix should preserve triple-nested blockquote prefix '>>>'"
2292 );
2293 }
2294}