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 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
356 warnings.push(LintWarning {
357 line: start_line,
358 column: start_col,
359 end_line,
360 end_column: end_col,
361 severity: Severity::Warning,
362 rule_name: Some(self.name().to_string()),
363 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
364 fix: Some(Fix {
365 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
366 replacement: format!("{bq_prefix}\n"),
367 }),
368 });
369 }
370 }
371 }
372 }
373
374 for &(start_line, end_line, ref prefix) in list_blocks {
375 if start_line > 1 {
376 let prev_line_actual_idx_0 = start_line - 2;
377 let prev_line_actual_idx_1 = start_line - 1;
378 let prev_line_str = lines[prev_line_actual_idx_0];
379 let is_prev_excluded = ctx
380 .line_info(prev_line_actual_idx_1)
381 .is_some_and(|info| info.in_code_block || info.in_front_matter);
382 let prev_prefix = BLOCKQUOTE_PREFIX_RE
383 .find(prev_line_str)
384 .map_or(String::new(), |m| m.as_str().to_string());
385 let prev_is_blank = is_blank_in_context(prev_line_str);
386 let prefixes_match = prev_prefix.trim() == prefix.trim();
387
388 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
391 if !is_prev_excluded && !prev_is_blank && prefixes_match && should_require {
392 let (start_line, start_col, end_line, end_col) =
394 calculate_line_range(start_line, lines[start_line - 1]);
395
396 warnings.push(LintWarning {
397 line: start_line,
398 column: start_col,
399 end_line,
400 end_column: end_col,
401 severity: Severity::Warning,
402 rule_name: Some(self.name().to_string()),
403 message: "List should be preceded by blank line".to_string(),
404 fix: Some(Fix {
405 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
406 replacement: format!("{prefix}\n"),
407 }),
408 });
409 }
410 }
411
412 if end_line < num_lines {
413 let next_line_idx_0 = end_line;
414 let next_line_idx_1 = end_line + 1;
415 let next_line_str = lines[next_line_idx_0];
416 let is_next_excluded = ctx.line_info(next_line_idx_1).is_some_and(|info| info.in_front_matter)
419 || (next_line_idx_0 < ctx.lines.len()
420 && ctx.lines[next_line_idx_0].in_code_block
421 && ctx.lines[next_line_idx_0].indent >= 2);
422 let next_prefix = BLOCKQUOTE_PREFIX_RE
423 .find(next_line_str)
424 .map_or(String::new(), |m| m.as_str().to_string());
425 let next_is_blank = is_blank_in_context(next_line_str);
426 let prefixes_match = next_prefix.trim() == prefix.trim();
427
428 if !is_next_excluded && !next_is_blank && prefixes_match {
430 let (start_line_last, start_col_last, end_line_last, end_col_last) =
432 calculate_line_range(end_line, lines[end_line - 1]);
433
434 warnings.push(LintWarning {
435 line: start_line_last,
436 column: start_col_last,
437 end_line: end_line_last,
438 end_column: end_col_last,
439 severity: Severity::Warning,
440 rule_name: Some(self.name().to_string()),
441 message: "List should be followed by blank line".to_string(),
442 fix: Some(Fix {
443 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
444 replacement: format!("{prefix}\n"),
445 }),
446 });
447 }
448 }
449 }
450 Ok(warnings)
451 }
452}
453
454impl Rule for MD032BlanksAroundLists {
455 fn name(&self) -> &'static str {
456 "MD032"
457 }
458
459 fn description(&self) -> &'static str {
460 "Lists should be surrounded by blank lines"
461 }
462
463 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
464 let content = ctx.content;
465 let lines: Vec<&str> = content.lines().collect();
466 let line_index = &ctx.line_index;
467
468 if lines.is_empty() {
470 return Ok(Vec::new());
471 }
472
473 let list_blocks = self.convert_list_blocks(ctx);
474
475 if list_blocks.is_empty() {
476 return Ok(Vec::new());
477 }
478
479 self.perform_checks(ctx, &lines, &list_blocks, line_index)
480 }
481
482 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
483 self.fix_with_structure_impl(ctx)
484 }
485
486 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
487 if ctx.content.is_empty() || !ctx.likely_has_lists() {
489 return true;
490 }
491 ctx.list_blocks.is_empty()
493 }
494
495 fn category(&self) -> RuleCategory {
496 RuleCategory::List
497 }
498
499 fn as_any(&self) -> &dyn std::any::Any {
500 self
501 }
502
503 fn default_config_section(&self) -> Option<(String, toml::Value)> {
504 use crate::rule_config_serde::RuleConfig;
505 let default_config = MD032Config::default();
506 let json_value = serde_json::to_value(&default_config).ok()?;
507 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
508
509 if let toml::Value::Table(table) = toml_value {
510 if !table.is_empty() {
511 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
512 } else {
513 None
514 }
515 } else {
516 None
517 }
518 }
519
520 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
521 where
522 Self: Sized,
523 {
524 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
525 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
526 }
527}
528
529impl MD032BlanksAroundLists {
530 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
532 let lines: Vec<&str> = ctx.content.lines().collect();
533 let num_lines = lines.len();
534 if num_lines == 0 {
535 return Ok(String::new());
536 }
537
538 let list_blocks = self.convert_list_blocks(ctx);
539 if list_blocks.is_empty() {
540 return Ok(ctx.content.to_string());
541 }
542
543 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
544
545 for &(start_line, end_line, ref prefix) in &list_blocks {
547 if start_line > 1 {
549 let prev_line_actual_idx_0 = start_line - 2;
550 let prev_line_actual_idx_1 = start_line - 1;
551 let is_prev_excluded = ctx
552 .line_info(prev_line_actual_idx_1)
553 .is_some_and(|info| info.in_code_block || info.in_front_matter);
554 let prev_prefix = BLOCKQUOTE_PREFIX_RE
555 .find(lines[prev_line_actual_idx_0])
556 .map_or(String::new(), |m| m.as_str().to_string());
557
558 let should_require = Self::should_require_blank_line_before(ctx, prev_line_actual_idx_1, start_line);
559 if !is_prev_excluded
562 && !is_blank_in_context(lines[prev_line_actual_idx_0])
563 && prev_prefix.trim() == prefix.trim()
564 && should_require
565 {
566 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
568 insertions.insert(start_line, bq_prefix);
569 }
570 }
571
572 if end_line < num_lines {
574 let after_block_line_idx_0 = end_line;
575 let after_block_line_idx_1 = end_line + 1;
576 let line_after_block_content_str = lines[after_block_line_idx_0];
577 let is_line_after_excluded = ctx
580 .line_info(after_block_line_idx_1)
581 .is_some_and(|info| info.in_code_block || info.in_front_matter)
582 || (after_block_line_idx_0 < ctx.lines.len()
583 && ctx.lines[after_block_line_idx_0].in_code_block
584 && ctx.lines[after_block_line_idx_0].indent >= 2
585 && (ctx.lines[after_block_line_idx_0]
586 .content(ctx.content)
587 .trim()
588 .starts_with("```")
589 || ctx.lines[after_block_line_idx_0]
590 .content(ctx.content)
591 .trim()
592 .starts_with("~~~")));
593 let after_prefix = BLOCKQUOTE_PREFIX_RE
594 .find(line_after_block_content_str)
595 .map_or(String::new(), |m| m.as_str().to_string());
596
597 if !is_line_after_excluded
599 && !is_blank_in_context(line_after_block_content_str)
600 && after_prefix.trim() == prefix.trim()
601 {
602 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
604 insertions.insert(after_block_line_idx_1, bq_prefix);
605 }
606 }
607 }
608
609 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
611 for (i, line) in lines.iter().enumerate() {
612 let current_line_num = i + 1;
613 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
614 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
615 {
616 result_lines.push(prefix_to_insert.clone());
617 }
618 result_lines.push(line.to_string());
619 }
620
621 let mut result = result_lines.join("\n");
623 if ctx.content.ends_with('\n') {
624 result.push('\n');
625 }
626 Ok(result)
627 }
628}
629
630fn is_blank_in_context(line: &str) -> bool {
632 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
635 line[m.end()..].trim().is_empty()
637 } else {
638 line.trim().is_empty()
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646 use crate::lint_context::LintContext;
647 use crate::rule::Rule;
648
649 fn lint(content: &str) -> Vec<LintWarning> {
650 let rule = MD032BlanksAroundLists::default();
651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
652 rule.check(&ctx).expect("Lint check failed")
653 }
654
655 fn fix(content: &str) -> String {
656 let rule = MD032BlanksAroundLists::default();
657 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
658 rule.fix(&ctx).expect("Lint fix failed")
659 }
660
661 fn check_warnings_have_fixes(content: &str) {
663 let warnings = lint(content);
664 for warning in &warnings {
665 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
666 }
667 }
668
669 #[test]
670 fn test_list_at_start() {
671 let content = "- Item 1\n- Item 2\nText";
674 let warnings = lint(content);
675 assert_eq!(
676 warnings.len(),
677 0,
678 "Trailing text is lazy continuation per CommonMark - no warning expected"
679 );
680 }
681
682 #[test]
683 fn test_list_at_end() {
684 let content = "Text\n- Item 1\n- Item 2";
685 let warnings = lint(content);
686 assert_eq!(
687 warnings.len(),
688 1,
689 "Expected 1 warning for list at end without preceding blank line"
690 );
691 assert_eq!(
692 warnings[0].line, 2,
693 "Warning should be on the first line of the list (line 2)"
694 );
695 assert!(warnings[0].message.contains("preceded by blank line"));
696
697 check_warnings_have_fixes(content);
699
700 let fixed_content = fix(content);
701 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
702
703 let warnings_after_fix = lint(&fixed_content);
705 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
706 }
707
708 #[test]
709 fn test_list_in_middle() {
710 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
713 let warnings = lint(content);
714 assert_eq!(
715 warnings.len(),
716 1,
717 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
718 );
719 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
720 assert!(warnings[0].message.contains("preceded by blank line"));
721
722 check_warnings_have_fixes(content);
724
725 let fixed_content = fix(content);
726 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
727
728 let warnings_after_fix = lint(&fixed_content);
730 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
731 }
732
733 #[test]
734 fn test_correct_spacing() {
735 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
736 let warnings = lint(content);
737 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
738
739 let fixed_content = fix(content);
740 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
741 }
742
743 #[test]
744 fn test_list_with_content() {
745 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
748 let warnings = lint(content);
749 assert_eq!(
750 warnings.len(),
751 1,
752 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
753 );
754 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
755 assert!(warnings[0].message.contains("preceded by blank line"));
756
757 check_warnings_have_fixes(content);
759
760 let fixed_content = fix(content);
761 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
762 assert_eq!(
763 fixed_content, expected_fixed,
764 "Fix did not produce the expected output. Got:\n{fixed_content}"
765 );
766
767 let warnings_after_fix = lint(&fixed_content);
769 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
770 }
771
772 #[test]
773 fn test_nested_list() {
774 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
776 let warnings = lint(content);
777 assert_eq!(
778 warnings.len(),
779 1,
780 "Nested list block needs preceding blank only. Got: {warnings:?}"
781 );
782 assert_eq!(warnings[0].line, 2);
783 assert!(warnings[0].message.contains("preceded by blank line"));
784
785 check_warnings_have_fixes(content);
787
788 let fixed_content = fix(content);
789 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
790
791 let warnings_after_fix = lint(&fixed_content);
793 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
794 }
795
796 #[test]
797 fn test_list_with_internal_blanks() {
798 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
800 let warnings = lint(content);
801 assert_eq!(
802 warnings.len(),
803 1,
804 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
805 );
806 assert_eq!(warnings[0].line, 2);
807 assert!(warnings[0].message.contains("preceded by blank line"));
808
809 check_warnings_have_fixes(content);
811
812 let fixed_content = fix(content);
813 assert_eq!(
814 fixed_content,
815 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
816 );
817
818 let warnings_after_fix = lint(&fixed_content);
820 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
821 }
822
823 #[test]
824 fn test_ignore_code_blocks() {
825 let content = "```\n- Not a list item\n```\nText";
826 let warnings = lint(content);
827 assert_eq!(warnings.len(), 0);
828 let fixed_content = fix(content);
829 assert_eq!(fixed_content, content);
830 }
831
832 #[test]
833 fn test_ignore_front_matter() {
834 let content = "---\ntitle: Test\n---\n- List Item\nText";
836 let warnings = lint(content);
837 assert_eq!(
838 warnings.len(),
839 0,
840 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
841 );
842
843 let fixed_content = fix(content);
845 assert_eq!(fixed_content, content, "No changes when no warnings");
846 }
847
848 #[test]
849 fn test_multiple_lists() {
850 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
855 let warnings = lint(content);
856 assert!(
858 !warnings.is_empty(),
859 "Should have at least one warning for missing blank line. Got: {warnings:?}"
860 );
861
862 check_warnings_have_fixes(content);
864
865 let fixed_content = fix(content);
866 let warnings_after_fix = lint(&fixed_content);
868 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
869 }
870
871 #[test]
872 fn test_adjacent_lists() {
873 let content = "- List 1\n\n* List 2";
874 let warnings = lint(content);
875 assert_eq!(warnings.len(), 0);
876 let fixed_content = fix(content);
877 assert_eq!(fixed_content, content);
878 }
879
880 #[test]
881 fn test_list_in_blockquote() {
882 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
884 let warnings = lint(content);
885 assert_eq!(
886 warnings.len(),
887 1,
888 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
889 );
890 assert_eq!(warnings[0].line, 2);
891
892 check_warnings_have_fixes(content);
894
895 let fixed_content = fix(content);
896 assert_eq!(
898 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
899 "Fix for blockquoted list failed. Got:\n{fixed_content}"
900 );
901
902 let warnings_after_fix = lint(&fixed_content);
904 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
905 }
906
907 #[test]
908 fn test_ordered_list() {
909 let content = "Text\n1. Item 1\n2. Item 2\nText";
911 let warnings = lint(content);
912 assert_eq!(warnings.len(), 1);
913
914 check_warnings_have_fixes(content);
916
917 let fixed_content = fix(content);
918 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
919
920 let warnings_after_fix = lint(&fixed_content);
922 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
923 }
924
925 #[test]
926 fn test_no_double_blank_fix() {
927 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
930 assert_eq!(
931 warnings.len(),
932 0,
933 "Should have no warnings - properly preceded, trailing is lazy"
934 );
935
936 let fixed_content = fix(content);
937 assert_eq!(
938 fixed_content, content,
939 "No fix needed when no warnings. Got:\n{fixed_content}"
940 );
941
942 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
944 assert_eq!(warnings2.len(), 1);
945 if !warnings2.is_empty() {
946 assert_eq!(
947 warnings2[0].line, 2,
948 "Warning line for missing blank before should be the first line of the block"
949 );
950 }
951
952 check_warnings_have_fixes(content2);
954
955 let fixed_content2 = fix(content2);
956 assert_eq!(
957 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
958 "Fix added extra blank before. Got:\n{fixed_content2}"
959 );
960 }
961
962 #[test]
963 fn test_empty_input() {
964 let content = "";
965 let warnings = lint(content);
966 assert_eq!(warnings.len(), 0);
967 let fixed_content = fix(content);
968 assert_eq!(fixed_content, "");
969 }
970
971 #[test]
972 fn test_only_list() {
973 let content = "- Item 1\n- Item 2";
974 let warnings = lint(content);
975 assert_eq!(warnings.len(), 0);
976 let fixed_content = fix(content);
977 assert_eq!(fixed_content, content);
978 }
979
980 #[test]
983 fn test_fix_complex_nested_blockquote() {
984 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
986 let warnings = lint(content);
987 assert_eq!(
988 warnings.len(),
989 1,
990 "Should warn for missing preceding blank only. Got: {warnings:?}"
991 );
992
993 check_warnings_have_fixes(content);
995
996 let fixed_content = fix(content);
997 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
999 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1000
1001 let warnings_after_fix = lint(&fixed_content);
1002 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1003 }
1004
1005 #[test]
1006 fn test_fix_mixed_list_markers() {
1007 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1010 let warnings = lint(content);
1011 assert!(
1013 !warnings.is_empty(),
1014 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1015 );
1016
1017 check_warnings_have_fixes(content);
1019
1020 let fixed_content = fix(content);
1021 assert!(
1023 fixed_content.contains("Text\n\n-"),
1024 "Fix should add blank line before first list item"
1025 );
1026
1027 let warnings_after_fix = lint(&fixed_content);
1029 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1030 }
1031
1032 #[test]
1033 fn test_fix_ordered_list_with_different_numbers() {
1034 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1036 let warnings = lint(content);
1037 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1038
1039 check_warnings_have_fixes(content);
1041
1042 let fixed_content = fix(content);
1043 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1044 assert_eq!(
1045 fixed_content, expected,
1046 "Fix should handle ordered lists with non-sequential numbers"
1047 );
1048
1049 let warnings_after_fix = lint(&fixed_content);
1051 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1052 }
1053
1054 #[test]
1055 fn test_fix_list_with_code_blocks_inside() {
1056 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1058 let warnings = lint(content);
1059 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1060
1061 check_warnings_have_fixes(content);
1063
1064 let fixed_content = fix(content);
1065 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1066 assert_eq!(
1067 fixed_content, expected,
1068 "Fix should handle lists with internal code blocks"
1069 );
1070
1071 let warnings_after_fix = lint(&fixed_content);
1073 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1074 }
1075
1076 #[test]
1077 fn test_fix_deeply_nested_lists() {
1078 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1080 let warnings = lint(content);
1081 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1082
1083 check_warnings_have_fixes(content);
1085
1086 let fixed_content = fix(content);
1087 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1088 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1089
1090 let warnings_after_fix = lint(&fixed_content);
1092 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1093 }
1094
1095 #[test]
1096 fn test_fix_list_with_multiline_items() {
1097 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1100 let warnings = lint(content);
1101 assert_eq!(
1102 warnings.len(),
1103 1,
1104 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1105 );
1106
1107 check_warnings_have_fixes(content);
1109
1110 let fixed_content = fix(content);
1111 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1112 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1113
1114 let warnings_after_fix = lint(&fixed_content);
1116 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1117 }
1118
1119 #[test]
1120 fn test_fix_list_at_document_boundaries() {
1121 let content1 = "- Item 1\n- Item 2";
1123 let warnings1 = lint(content1);
1124 assert_eq!(
1125 warnings1.len(),
1126 0,
1127 "List at document start should not need blank before"
1128 );
1129 let fixed1 = fix(content1);
1130 assert_eq!(fixed1, content1, "No fix needed for list at start");
1131
1132 let content2 = "Text\n- Item 1\n- Item 2";
1134 let warnings2 = lint(content2);
1135 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1136 check_warnings_have_fixes(content2);
1137 let fixed2 = fix(content2);
1138 assert_eq!(
1139 fixed2, "Text\n\n- Item 1\n- Item 2",
1140 "Should add blank before list at end"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_fix_preserves_existing_blank_lines() {
1146 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1147 let warnings = lint(content);
1148 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1149 let fixed_content = fix(content);
1150 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1151 }
1152
1153 #[test]
1154 fn test_fix_handles_tabs_and_spaces() {
1155 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1158 let warnings = lint(content);
1159 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1161
1162 check_warnings_have_fixes(content);
1164
1165 let fixed_content = fix(content);
1166 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1169 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1170
1171 let warnings_after_fix = lint(&fixed_content);
1173 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1174 }
1175
1176 #[test]
1177 fn test_fix_warning_objects_have_correct_ranges() {
1178 let content = "Text\n- Item 1\n- Item 2\nText";
1180 let warnings = lint(content);
1181 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1182
1183 for warning in &warnings {
1185 assert!(warning.fix.is_some(), "Warning should have fix");
1186 let fix = warning.fix.as_ref().unwrap();
1187 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1188 assert!(
1189 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1190 "Fix should have replacement or be insertion"
1191 );
1192 }
1193 }
1194
1195 #[test]
1196 fn test_fix_idempotent() {
1197 let content = "Text\n- Item 1\n- Item 2\nText";
1199
1200 let fixed_once = fix(content);
1202 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1203
1204 let fixed_twice = fix(&fixed_once);
1206 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1207
1208 let warnings_after_fix = lint(&fixed_once);
1210 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1211 }
1212
1213 #[test]
1214 fn test_fix_with_normalized_line_endings() {
1215 let content = "Text\n- Item 1\n- Item 2\nText";
1219 let warnings = lint(content);
1220 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1221
1222 check_warnings_have_fixes(content);
1224
1225 let fixed_content = fix(content);
1226 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1228 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1229 }
1230
1231 #[test]
1232 fn test_fix_preserves_final_newline() {
1233 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1236 let fixed_with_newline = fix(content_with_newline);
1237 assert!(
1238 fixed_with_newline.ends_with('\n'),
1239 "Fix should preserve final newline when present"
1240 );
1241 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1243
1244 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1246 let fixed_without_newline = fix(content_without_newline);
1247 assert!(
1248 !fixed_without_newline.ends_with('\n'),
1249 "Fix should not add final newline when not present"
1250 );
1251 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1253 }
1254
1255 #[test]
1256 fn test_fix_multiline_list_items_no_indent() {
1257 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";
1258
1259 let warnings = lint(content);
1260 assert_eq!(
1262 warnings.len(),
1263 0,
1264 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1265 );
1266
1267 let fixed_content = fix(content);
1268 assert_eq!(
1270 fixed_content, content,
1271 "Should not modify correctly formatted multi-line list items"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_nested_list_with_lazy_continuation() {
1277 let content = r#"# Test
1283
1284- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1285 1. Switch/case dispatcher statements (original Phase 3.2)
1286 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1287`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1288 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1289 references"#;
1290
1291 let warnings = lint(content);
1292 let md032_warnings: Vec<_> = warnings
1295 .iter()
1296 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1297 .collect();
1298 assert_eq!(
1299 md032_warnings.len(),
1300 0,
1301 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_pipes_in_code_spans_not_detected_as_table() {
1307 let content = r#"# Test
1309
1310- Item with `a | b` inline code
1311 - Nested item should work
1312
1313"#;
1314
1315 let warnings = lint(content);
1316 let md032_warnings: Vec<_> = warnings
1317 .iter()
1318 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1319 .collect();
1320 assert_eq!(
1321 md032_warnings.len(),
1322 0,
1323 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1324 );
1325 }
1326
1327 #[test]
1328 fn test_multiple_code_spans_with_pipes() {
1329 let content = r#"# Test
1331
1332- Item with `a | b` and `c || d` operators
1333 - Nested item should work
1334
1335"#;
1336
1337 let warnings = lint(content);
1338 let md032_warnings: Vec<_> = warnings
1339 .iter()
1340 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1341 .collect();
1342 assert_eq!(
1343 md032_warnings.len(),
1344 0,
1345 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1346 );
1347 }
1348
1349 #[test]
1350 fn test_actual_table_breaks_list() {
1351 let content = r#"# Test
1353
1354- Item before table
1355
1356| Col1 | Col2 |
1357|------|------|
1358| A | B |
1359
1360- Item after table
1361
1362"#;
1363
1364 let warnings = lint(content);
1365 let md032_warnings: Vec<_> = warnings
1367 .iter()
1368 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1369 .collect();
1370 assert_eq!(
1371 md032_warnings.len(),
1372 0,
1373 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_thematic_break_not_lazy_continuation() {
1379 let content = r#"- Item 1
1382- Item 2
1383***
1384
1385More text.
1386"#;
1387
1388 let warnings = lint(content);
1389 let md032_warnings: Vec<_> = warnings
1390 .iter()
1391 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1392 .collect();
1393 assert_eq!(
1394 md032_warnings.len(),
1395 1,
1396 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1397 );
1398 assert!(
1399 md032_warnings[0].message.contains("followed by blank line"),
1400 "Warning should be about missing blank after list"
1401 );
1402 }
1403
1404 #[test]
1405 fn test_thematic_break_with_blank_line() {
1406 let content = r#"- Item 1
1408- Item 2
1409
1410***
1411
1412More text.
1413"#;
1414
1415 let warnings = lint(content);
1416 let md032_warnings: Vec<_> = warnings
1417 .iter()
1418 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1419 .collect();
1420 assert_eq!(
1421 md032_warnings.len(),
1422 0,
1423 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_various_thematic_break_styles() {
1429 for hr in ["---", "***", "___"] {
1434 let content = format!(
1435 r#"- Item 1
1436- Item 2
1437{hr}
1438
1439More text.
1440"#
1441 );
1442
1443 let warnings = lint(&content);
1444 let md032_warnings: Vec<_> = warnings
1445 .iter()
1446 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1447 .collect();
1448 assert_eq!(
1449 md032_warnings.len(),
1450 1,
1451 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1452 );
1453 }
1454 }
1455
1456 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1459 let rule = MD032BlanksAroundLists::from_config_struct(config);
1460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1461 rule.check(&ctx).expect("Lint check failed")
1462 }
1463
1464 fn fix_with_config(content: &str, config: MD032Config) -> String {
1465 let rule = MD032BlanksAroundLists::from_config_struct(config);
1466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1467 rule.fix(&ctx).expect("Lint fix failed")
1468 }
1469
1470 #[test]
1471 fn test_lazy_continuation_allowed_by_default() {
1472 let content = "# Heading\n\n1. List\nSome text.";
1474 let warnings = lint(content);
1475 assert_eq!(
1476 warnings.len(),
1477 0,
1478 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_lazy_continuation_disallowed() {
1484 let content = "# Heading\n\n1. List\nSome text.";
1486 let config = MD032Config {
1487 allow_lazy_continuation: false,
1488 };
1489 let warnings = lint_with_config(content, config);
1490 assert_eq!(
1491 warnings.len(),
1492 1,
1493 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1494 );
1495 assert!(
1496 warnings[0].message.contains("followed by blank line"),
1497 "Warning message should mention blank line"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_lazy_continuation_fix() {
1503 let content = "# Heading\n\n1. List\nSome text.";
1505 let config = MD032Config {
1506 allow_lazy_continuation: false,
1507 };
1508 let fixed = fix_with_config(content, config.clone());
1509 assert_eq!(
1510 fixed, "# Heading\n\n1. List\n\nSome text.",
1511 "Fix should insert blank line before lazy continuation"
1512 );
1513
1514 let warnings_after = lint_with_config(&fixed, config);
1516 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1517 }
1518
1519 #[test]
1520 fn test_lazy_continuation_multiple_lines() {
1521 let content = "- Item 1\nLine 2\nLine 3";
1523 let config = MD032Config {
1524 allow_lazy_continuation: false,
1525 };
1526 let warnings = lint_with_config(content, config.clone());
1527 assert_eq!(
1528 warnings.len(),
1529 1,
1530 "Should warn for lazy continuation. Got: {warnings:?}"
1531 );
1532
1533 let fixed = fix_with_config(content, config.clone());
1534 assert_eq!(
1535 fixed, "- Item 1\n\nLine 2\nLine 3",
1536 "Fix should insert blank line after list"
1537 );
1538
1539 let warnings_after = lint_with_config(&fixed, config);
1541 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1542 }
1543
1544 #[test]
1545 fn test_lazy_continuation_with_indented_content() {
1546 let content = "- Item 1\n Indented content\nLazy text";
1548 let config = MD032Config {
1549 allow_lazy_continuation: false,
1550 };
1551 let warnings = lint_with_config(content, config);
1552 assert_eq!(
1553 warnings.len(),
1554 1,
1555 "Should warn for lazy text after indented content. Got: {warnings:?}"
1556 );
1557 }
1558
1559 #[test]
1560 fn test_lazy_continuation_properly_separated() {
1561 let content = "- Item 1\n\nSome text.";
1563 let config = MD032Config {
1564 allow_lazy_continuation: false,
1565 };
1566 let warnings = lint_with_config(content, config);
1567 assert_eq!(
1568 warnings.len(),
1569 0,
1570 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1571 );
1572 }
1573
1574 #[test]
1577 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1578 let content = "1) First item\nLazy continuation";
1580 let config = MD032Config {
1581 allow_lazy_continuation: false,
1582 };
1583 let warnings = lint_with_config(content, config.clone());
1584 assert_eq!(
1585 warnings.len(),
1586 1,
1587 "Should warn for lazy continuation with parenthesis marker"
1588 );
1589
1590 let fixed = fix_with_config(content, config);
1591 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1592 }
1593
1594 #[test]
1595 fn test_lazy_continuation_followed_by_another_list() {
1596 let content = "- Item 1\nSome text\n- Item 2";
1601 let config = MD032Config {
1602 allow_lazy_continuation: false,
1603 };
1604 let warnings = lint_with_config(content, config);
1605 assert_eq!(
1608 warnings.len(),
1609 0,
1610 "Valid list structure should not trigger lazy continuation warning"
1611 );
1612 }
1613
1614 #[test]
1615 fn test_lazy_continuation_multiple_in_document() {
1616 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1622 let config = MD032Config {
1623 allow_lazy_continuation: false,
1624 };
1625 let warnings = lint_with_config(content, config.clone());
1626 assert_eq!(
1627 warnings.len(),
1628 1,
1629 "Should warn for second list (not followed by blank). Got: {warnings:?}"
1630 );
1631
1632 let fixed = fix_with_config(content, config.clone());
1633 let warnings_after = lint_with_config(&fixed, config);
1634 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1635 }
1636
1637 #[test]
1638 fn test_lazy_continuation_end_of_document_no_newline() {
1639 let content = "- Item\nNo trailing newline";
1641 let config = MD032Config {
1642 allow_lazy_continuation: false,
1643 };
1644 let warnings = lint_with_config(content, config.clone());
1645 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1646
1647 let fixed = fix_with_config(content, config);
1648 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1649 }
1650
1651 #[test]
1652 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1653 let content = "- Item 1\n---";
1656 let config = MD032Config {
1657 allow_lazy_continuation: false,
1658 };
1659 let warnings = lint_with_config(content, config.clone());
1660 assert_eq!(
1662 warnings.len(),
1663 1,
1664 "List should need blank line before thematic break. Got: {warnings:?}"
1665 );
1666
1667 let fixed = fix_with_config(content, config);
1669 assert_eq!(fixed, "- Item 1\n\n---");
1670 }
1671
1672 #[test]
1673 fn test_lazy_continuation_heading_not_flagged() {
1674 let content = "- Item 1\n# Heading";
1677 let config = MD032Config {
1678 allow_lazy_continuation: false,
1679 };
1680 let warnings = lint_with_config(content, config);
1681 assert!(
1684 warnings.iter().all(|w| !w.message.contains("lazy")),
1685 "Heading should not trigger lazy continuation warning"
1686 );
1687 }
1688
1689 #[test]
1690 fn test_lazy_continuation_mixed_list_types() {
1691 let content = "- Unordered\n1. Ordered\nLazy text";
1693 let config = MD032Config {
1694 allow_lazy_continuation: false,
1695 };
1696 let warnings = lint_with_config(content, config.clone());
1697 assert!(!warnings.is_empty(), "Should warn about structure issues");
1698 }
1699
1700 #[test]
1701 fn test_lazy_continuation_deep_nesting() {
1702 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1704 let config = MD032Config {
1705 allow_lazy_continuation: false,
1706 };
1707 let warnings = lint_with_config(content, config.clone());
1708 assert!(
1709 !warnings.is_empty(),
1710 "Should warn about lazy continuation after nested list"
1711 );
1712
1713 let fixed = fix_with_config(content, config.clone());
1714 let warnings_after = lint_with_config(&fixed, config);
1715 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1716 }
1717
1718 #[test]
1719 fn test_lazy_continuation_with_emphasis_in_text() {
1720 let content = "- Item\n*emphasized* continuation";
1722 let config = MD032Config {
1723 allow_lazy_continuation: false,
1724 };
1725 let warnings = lint_with_config(content, config.clone());
1726 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1727
1728 let fixed = fix_with_config(content, config);
1729 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1730 }
1731
1732 #[test]
1733 fn test_lazy_continuation_with_code_span() {
1734 let content = "- Item\n`code` continuation";
1736 let config = MD032Config {
1737 allow_lazy_continuation: false,
1738 };
1739 let warnings = lint_with_config(content, config.clone());
1740 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
1741
1742 let fixed = fix_with_config(content, config);
1743 assert_eq!(fixed, "- Item\n\n`code` continuation");
1744 }
1745
1746 #[test]
1747 fn test_lazy_continuation_whitespace_only_line() {
1748 let content = "- Item\n \nText after whitespace-only line";
1751 let config = MD032Config {
1752 allow_lazy_continuation: false,
1753 };
1754 let warnings = lint_with_config(content, config.clone());
1755 assert_eq!(
1757 warnings.len(),
1758 1,
1759 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
1760 );
1761
1762 let fixed = fix_with_config(content, config);
1764 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
1765 }
1766
1767 #[test]
1768 fn test_lazy_continuation_blockquote_context() {
1769 let content = "> - Item\n> Lazy in quote";
1771 let config = MD032Config {
1772 allow_lazy_continuation: false,
1773 };
1774 let warnings = lint_with_config(content, config);
1775 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
1778 }
1779
1780 #[test]
1781 fn test_lazy_continuation_fix_preserves_content() {
1782 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
1784 let config = MD032Config {
1785 allow_lazy_continuation: false,
1786 };
1787 let fixed = fix_with_config(content, config);
1788 assert!(fixed.contains("<>&"), "Should preserve special chars");
1789 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
1790 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
1791 }
1792
1793 #[test]
1794 fn test_lazy_continuation_fix_idempotent() {
1795 let content = "- Item\nLazy";
1797 let config = MD032Config {
1798 allow_lazy_continuation: false,
1799 };
1800 let fixed_once = fix_with_config(content, config.clone());
1801 let fixed_twice = fix_with_config(&fixed_once, config);
1802 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1803 }
1804
1805 #[test]
1806 fn test_lazy_continuation_config_default_allows() {
1807 let content = "- Item\nLazy text that continues";
1809 let default_config = MD032Config::default();
1810 assert!(
1811 default_config.allow_lazy_continuation,
1812 "Default should allow lazy continuation"
1813 );
1814 let warnings = lint_with_config(content, default_config);
1815 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
1816 }
1817
1818 #[test]
1819 fn test_lazy_continuation_after_multi_line_item() {
1820 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
1822 let config = MD032Config {
1823 allow_lazy_continuation: false,
1824 };
1825 let warnings = lint_with_config(content, config.clone());
1826 assert_eq!(
1827 warnings.len(),
1828 1,
1829 "Should warn only for the lazy line, not the indented line"
1830 );
1831 }
1832
1833 #[test]
1835 fn test_blockquote_list_with_continuation_and_nested() {
1836 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
1839 let warnings = lint(content);
1840 assert_eq!(
1841 warnings.len(),
1842 0,
1843 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_blockquote_list_simple() {
1849 let content = "> - item 1\n> - item 2";
1851 let warnings = lint(content);
1852 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
1853 }
1854
1855 #[test]
1856 fn test_blockquote_list_with_continuation_only() {
1857 let content = "> - item 1\n> continuation\n> - item 2";
1859 let warnings = lint(content);
1860 assert_eq!(
1861 warnings.len(),
1862 0,
1863 "Blockquoted list with continuation should have no warnings"
1864 );
1865 }
1866
1867 #[test]
1868 fn test_blockquote_list_with_lazy_continuation() {
1869 let content = "> - item 1\n> lazy continuation\n> - item 2";
1871 let warnings = lint(content);
1872 assert_eq!(
1873 warnings.len(),
1874 0,
1875 "Blockquoted list with lazy continuation should have no warnings"
1876 );
1877 }
1878
1879 #[test]
1880 fn test_nested_blockquote_list() {
1881 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
1883 let warnings = lint(content);
1884 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
1885 }
1886
1887 #[test]
1888 fn test_blockquote_list_needs_preceding_blank() {
1889 let content = "> Text before\n> - item 1\n> - item 2";
1891 let warnings = lint(content);
1892 assert_eq!(
1893 warnings.len(),
1894 1,
1895 "Should warn for missing blank before blockquoted list"
1896 );
1897 }
1898
1899 #[test]
1900 fn test_blockquote_list_properly_separated() {
1901 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
1903 let warnings = lint(content);
1904 assert_eq!(
1905 warnings.len(),
1906 0,
1907 "Properly separated blockquoted list should have no warnings"
1908 );
1909 }
1910
1911 #[test]
1912 fn test_blockquote_ordered_list() {
1913 let content = "> 1. item 1\n> continuation\n> 2. item 2";
1915 let warnings = lint(content);
1916 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
1917 }
1918
1919 #[test]
1920 fn test_blockquote_list_with_empty_blockquote_line() {
1921 let content = "> - item 1\n>\n> - item 2";
1923 let warnings = lint(content);
1924 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
1925 }
1926
1927 #[test]
1929 fn test_blockquote_list_multi_paragraph_items() {
1930 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
1933 let warnings = lint(content);
1934 assert_eq!(
1935 warnings.len(),
1936 0,
1937 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
1938 );
1939 }
1940
1941 #[test]
1943 fn test_blockquote_ordered_list_multi_paragraph_items() {
1944 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
1945 let warnings = lint(content);
1946 assert_eq!(
1947 warnings.len(),
1948 0,
1949 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
1950 );
1951 }
1952
1953 #[test]
1955 fn test_blockquote_list_multiple_continuations() {
1956 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
1957 let warnings = lint(content);
1958 assert_eq!(
1959 warnings.len(),
1960 0,
1961 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
1962 );
1963 }
1964
1965 #[test]
1967 fn test_nested_blockquote_multi_paragraph_list() {
1968 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
1969 let warnings = lint(content);
1970 assert_eq!(
1971 warnings.len(),
1972 0,
1973 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
1974 );
1975 }
1976
1977 #[test]
1979 fn test_triple_nested_blockquote_multi_paragraph_list() {
1980 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
1981 let warnings = lint(content);
1982 assert_eq!(
1983 warnings.len(),
1984 0,
1985 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
1986 );
1987 }
1988
1989 #[test]
1991 fn test_blockquote_list_last_item_continuation() {
1992 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
1993 let warnings = lint(content);
1994 assert_eq!(
1995 warnings.len(),
1996 0,
1997 "Last item with continuation should have no warnings. Got: {warnings:?}"
1998 );
1999 }
2000
2001 #[test]
2003 fn test_blockquote_list_first_item_only_continuation() {
2004 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2005 let warnings = lint(content);
2006 assert_eq!(
2007 warnings.len(),
2008 0,
2009 "Single item with continuation should have no warnings. Got: {warnings:?}"
2010 );
2011 }
2012
2013 #[test]
2017 fn test_blockquote_level_change_breaks_list() {
2018 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2020 let warnings = lint(content);
2021 assert!(
2025 warnings.len() <= 2,
2026 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2027 );
2028 }
2029
2030 #[test]
2032 fn test_exit_blockquote_needs_blank_before_list() {
2033 let content = "> Blockquote text\n\n- List outside blockquote\n";
2035 let warnings = lint(content);
2036 assert_eq!(
2037 warnings.len(),
2038 0,
2039 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2040 );
2041
2042 let content2 = "> Blockquote text\n- List outside blockquote\n";
2046 let warnings2 = lint(content2);
2047 assert!(
2049 warnings2.len() <= 1,
2050 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2051 );
2052 }
2053
2054 #[test]
2056 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2057 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2059 let warnings = lint(content_dash);
2060 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2061
2062 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2064 let warnings = lint(content_asterisk);
2065 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2066
2067 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2069 let warnings = lint(content_plus);
2070 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2071 }
2072
2073 #[test]
2075 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2076 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2077 let warnings = lint(content);
2078 assert_eq!(
2079 warnings.len(),
2080 0,
2081 "Parenthesis ordered markers should work. Got: {warnings:?}"
2082 );
2083 }
2084
2085 #[test]
2087 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2088 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2090 let warnings = lint(content);
2091 assert_eq!(
2092 warnings.len(),
2093 0,
2094 "Multi-digit ordered list should work. Got: {warnings:?}"
2095 );
2096 }
2097
2098 #[test]
2100 fn test_blockquote_multi_paragraph_with_formatting() {
2101 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2102 let warnings = lint(content);
2103 assert_eq!(
2104 warnings.len(),
2105 0,
2106 "Continuation with inline formatting should work. Got: {warnings:?}"
2107 );
2108 }
2109
2110 #[test]
2112 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2113 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2114 let warnings = lint(content);
2115 assert_eq!(
2116 warnings.len(),
2117 0,
2118 "All items with continuations should work. Got: {warnings:?}"
2119 );
2120 }
2121
2122 #[test]
2124 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2125 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2126 let warnings = lint(content);
2127 assert_eq!(
2128 warnings.len(),
2129 0,
2130 "Lowercase continuation should work. Got: {warnings:?}"
2131 );
2132 }
2133
2134 #[test]
2136 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2137 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2138 let warnings = lint(content);
2139 assert_eq!(
2140 warnings.len(),
2141 0,
2142 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2143 );
2144 }
2145
2146 #[test]
2148 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2149 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2151 let warnings = lint(content);
2152 assert!(
2154 warnings.len() <= 1,
2155 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2156 );
2157 }
2158
2159 #[test]
2161 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2162 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2164 let warnings = lint(content);
2165 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2166 }
2167
2168 #[test]
2169 fn test_blockquote_list_varying_spaces_after_marker() {
2170 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2172 let warnings = lint(content);
2173 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2174 }
2175
2176 #[test]
2177 fn test_deeply_nested_blockquote_list() {
2178 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2180 let warnings = lint(content);
2181 assert_eq!(
2182 warnings.len(),
2183 0,
2184 "Deeply nested blockquote list should have no warnings"
2185 );
2186 }
2187
2188 #[test]
2189 #[ignore = "rumdl doesn't yet detect blockquote level changes between list items as list-breaking"]
2190 fn test_blockquote_level_change_in_list() {
2191 let content = "> - item 1\n>> - deeper item\n> - item 2";
2195 let warnings = lint(content);
2198 assert!(
2199 !warnings.is_empty(),
2200 "Blockquote level change should break list and trigger warnings"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_blockquote_list_with_code_span() {
2206 let content = "> - item with `code`\n> continuation\n> - item 2";
2208 let warnings = lint(content);
2209 assert_eq!(
2210 warnings.len(),
2211 0,
2212 "Blockquote list with code span should have no warnings"
2213 );
2214 }
2215
2216 #[test]
2217 fn test_blockquote_list_at_document_end() {
2218 let content = "> Some text\n>\n> - item 1\n> - item 2";
2220 let warnings = lint(content);
2221 assert_eq!(
2222 warnings.len(),
2223 0,
2224 "Blockquote list at document end should have no warnings"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_fix_preserves_blockquote_prefix_before_list() {
2230 let content = "> Text before
2232> - Item 1
2233> - Item 2";
2234 let fixed = fix(content);
2235
2236 let expected = "> Text before
2238>
2239> - Item 1
2240> - Item 2";
2241 assert_eq!(
2242 fixed, expected,
2243 "Fix should insert '>' blank line, not plain blank line"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2249 let content = ">>> Triple nested
2252>>> - Item 1
2253>>> - Item 2
2254>>> More text";
2255 let fixed = fix(content);
2256
2257 let expected = ">>> Triple nested
2259>>>
2260>>> - Item 1
2261>>> - Item 2
2262>>> More text";
2263 assert_eq!(
2264 fixed, expected,
2265 "Fix should preserve triple-nested blockquote prefix '>>>'"
2266 );
2267 }
2268}