1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote};
3use crate::utils::element_cache::ElementCache;
4use crate::utils::quarto_divs;
5use crate::utils::range_utils::{LineIndex, calculate_line_range};
6use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
7use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12#[derive(Debug, Clone)]
14struct LazyContInfo {
15 expected_indent: usize,
17 current_indent: usize,
19 blockquote_level: usize,
21}
22
23mod md032_config;
24pub use md032_config::MD032Config;
25
26static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
28
29fn is_thematic_break(line: &str) -> bool {
32 if ElementCache::calculate_indentation_width_default(line) > 3 {
34 return false;
35 }
36
37 let trimmed = line.trim();
38 if trimmed.len() < 3 {
39 return false;
40 }
41
42 let chars: Vec<char> = trimmed.chars().collect();
43 let first_non_space = chars.iter().find(|&&c| c != ' ');
44
45 if let Some(&marker) = first_non_space {
46 if marker != '-' && marker != '*' && marker != '_' {
47 return false;
48 }
49 let marker_count = chars.iter().filter(|&&c| c == marker).count();
50 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
51 marker_count >= 3 && other_count == 0
52 } else {
53 false
54 }
55}
56
57#[derive(Debug, Clone, Default)]
127pub struct MD032BlanksAroundLists {
128 config: MD032Config,
129}
130
131impl MD032BlanksAroundLists {
132 pub fn from_config_struct(config: MD032Config) -> Self {
133 Self { config }
134 }
135}
136
137impl MD032BlanksAroundLists {
138 fn should_require_blank_line_before(
140 ctx: &crate::lint_context::LintContext,
141 prev_line_num: usize,
142 current_line_num: usize,
143 ) -> bool {
144 if ctx
146 .line_info(prev_line_num)
147 .is_some_and(|info| info.in_code_block || info.in_front_matter)
148 {
149 return true;
150 }
151
152 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
154 return false;
155 }
156
157 true
159 }
160
161 fn is_nested_list(
163 ctx: &crate::lint_context::LintContext,
164 prev_line_num: usize, current_line_num: usize, ) -> bool {
167 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
169 let current_line = &ctx.lines[current_line_num - 1];
170 if current_line.indent >= 2 {
171 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
173 let prev_line = &ctx.lines[prev_line_num - 1];
174 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
176 return true;
177 }
178 }
179 }
180 }
181 false
182 }
183
184 fn detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashMap<usize, LazyContInfo> {
192 let mut lazy_lines = HashMap::new();
193 let parser = Parser::new_ext(ctx.content, Options::all());
194
195 let mut item_stack: Vec<(usize, usize)> = vec![];
197 let mut after_soft_break = false;
198
199 for (event, range) in parser.into_offset_iter() {
200 match event {
201 Event::Start(Tag::Item) => {
202 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
203 let line_info = ctx.lines.get(line_num.saturating_sub(1));
204 let line_content = line_info.map(|li| li.content(ctx.content)).unwrap_or("");
205
206 let bq_level = line_content
208 .chars()
209 .take_while(|c| *c == '>' || c.is_whitespace())
210 .filter(|&c| c == '>')
211 .count();
212
213 let expected_indent = if bq_level > 0 {
215 line_info
218 .and_then(|li| li.list_item.as_ref())
219 .map(|item| item.content_column.saturating_sub(item.marker_column))
220 .unwrap_or(2)
221 } else {
222 line_info
224 .and_then(|li| li.list_item.as_ref())
225 .map(|item| item.content_column)
226 .unwrap_or(0)
227 };
228
229 item_stack.push((expected_indent, bq_level));
230 after_soft_break = false;
231 }
232 Event::End(TagEnd::Item) => {
233 item_stack.pop();
234 after_soft_break = false;
235 }
236 Event::SoftBreak if !item_stack.is_empty() => {
237 after_soft_break = true;
238 }
239 Event::Text(_)
249 | Event::Code(_)
250 | Event::Start(Tag::Emphasis)
251 | Event::Start(Tag::Strong)
252 | Event::Start(Tag::Strikethrough)
253 | Event::Start(Tag::Subscript)
254 | Event::Start(Tag::Superscript)
255 | Event::Start(Tag::Link { .. })
256 | Event::Start(Tag::Image { .. })
257 if after_soft_break =>
258 {
259 if let Some(&(expected_indent, expected_bq_level)) = item_stack.last() {
260 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
261 let line_info = ctx.lines.get(line_num.saturating_sub(1));
262 let line_content = line_info.map(|li| li.content(ctx.content)).unwrap_or("");
263 let fallback_indent = line_info.map(|li| li.indent).unwrap_or(0);
264
265 let actual_indent =
266 effective_indent_in_blockquote(line_content, expected_bq_level, fallback_indent);
267
268 if actual_indent < expected_indent {
269 lazy_lines.insert(
271 line_num,
272 LazyContInfo {
273 expected_indent,
274 current_indent: actual_indent,
275 blockquote_level: expected_bq_level,
276 },
277 );
278 }
279 }
280 after_soft_break = false;
281 }
282 _ => {
283 after_soft_break = false;
284 }
285 }
286 }
287
288 lazy_lines
289 }
290
291 fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
293 match line_offsets.binary_search(&byte_offset) {
294 Ok(idx) => idx + 1,
295 Err(idx) => idx.max(1),
296 }
297 }
298
299 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
302 ctx.lines
303 .get(line_num.saturating_sub(1))
304 .map(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment)
305 .unwrap_or(false)
306 }
307
308 fn calculate_lazy_continuation_fix(
311 ctx: &crate::lint_context::LintContext,
312 line_num: usize,
313 lazy_info: &LazyContInfo,
314 ) -> Option<Fix> {
315 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
316 let line_content = line_info.content(ctx.content);
317
318 if lazy_info.blockquote_level == 0 {
319 let start_byte = line_info.byte_offset;
321 let end_byte = start_byte + lazy_info.current_indent;
322 let replacement = " ".repeat(lazy_info.expected_indent);
323
324 Some(Fix {
325 range: start_byte..end_byte,
326 replacement,
327 })
328 } else {
329 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
331 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
332 if prefix_byte_len == 0 {
333 return None;
334 }
335
336 let current_indent = after_bq.len() - after_bq.trim_start().len();
337 let start_byte = line_info.byte_offset + prefix_byte_len;
338 let end_byte = start_byte + current_indent;
339 let replacement = " ".repeat(lazy_info.expected_indent);
340
341 Some(Fix {
342 range: start_byte..end_byte,
343 replacement,
344 })
345 }
346 }
347
348 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContInfo) -> String {
351 if lazy_info.blockquote_level == 0 {
352 let content = line.trim_start();
354 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
355 } else {
356 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
358 let prefix_len = line.len().saturating_sub(after_bq.len());
359 if prefix_len == 0 {
360 return line.to_string();
361 }
362
363 let prefix = &line[..prefix_len];
364 let rest = after_bq.trim_start();
365 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
366 }
367 }
368
369 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
377 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
378 for line_num in (1..before_line).rev() {
379 let idx = line_num - 1;
380 if let Some(info) = ctx.lines.get(idx) {
381 if info.in_html_comment {
383 continue;
384 }
385 if is_quarto {
387 let trimmed = info.content(ctx.content).trim();
388 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
389 continue;
390 }
391 }
392 return (line_num, info.is_blank);
393 }
394 }
395 (0, true)
397 }
398
399 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
406 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
407 let num_lines = ctx.lines.len();
408 for line_num in (after_line + 1)..=num_lines {
409 let idx = line_num - 1;
410 if let Some(info) = ctx.lines.get(idx) {
411 if info.in_html_comment {
413 continue;
414 }
415 if is_quarto {
417 let trimmed = info.content(ctx.content).trim();
418 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
419 continue;
420 }
421 }
422 return (line_num, info.is_blank);
423 }
424 }
425 (0, true)
427 }
428
429 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
431 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
432
433 for block in &ctx.list_blocks {
434 let mut segments: Vec<(usize, usize)> = Vec::new();
440 let mut current_start = block.start_line;
441 let mut prev_item_line = 0;
442
443 let get_blockquote_level = |line_num: usize| -> usize {
445 if line_num == 0 || line_num > ctx.lines.len() {
446 return 0;
447 }
448 let line_content = ctx.lines[line_num - 1].content(ctx.content);
449 BLOCKQUOTE_PREFIX_RE
450 .find(line_content)
451 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
452 .unwrap_or(0)
453 };
454
455 let mut prev_bq_level = 0;
456
457 for &item_line in &block.item_lines {
458 let current_bq_level = get_blockquote_level(item_line);
459
460 if prev_item_line > 0 {
461 let blockquote_level_changed = prev_bq_level != current_bq_level;
463
464 let mut has_standalone_code_fence = false;
467
468 let min_indent_for_content = if block.is_ordered {
470 3 } else {
474 2 };
477
478 for check_line in (prev_item_line + 1)..item_line {
479 if check_line - 1 < ctx.lines.len() {
480 let line = &ctx.lines[check_line - 1];
481 let line_content = line.content(ctx.content);
482 if line.in_code_block
483 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
484 {
485 if line.indent < min_indent_for_content {
488 has_standalone_code_fence = true;
489 break;
490 }
491 }
492 }
493 }
494
495 if has_standalone_code_fence || blockquote_level_changed {
496 segments.push((current_start, prev_item_line));
498 current_start = item_line;
499 }
500 }
501 prev_item_line = item_line;
502 prev_bq_level = current_bq_level;
503 }
504
505 if prev_item_line > 0 {
508 segments.push((current_start, prev_item_line));
509 }
510
511 let has_code_fence_splits = segments.len() > 1 && {
513 let mut found_fence = false;
515 for i in 0..segments.len() - 1 {
516 let seg_end = segments[i].1;
517 let next_start = segments[i + 1].0;
518 for check_line in (seg_end + 1)..next_start {
520 if check_line - 1 < ctx.lines.len() {
521 let line = &ctx.lines[check_line - 1];
522 let line_content = line.content(ctx.content);
523 if line.in_code_block
524 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
525 {
526 found_fence = true;
527 break;
528 }
529 }
530 }
531 if found_fence {
532 break;
533 }
534 }
535 found_fence
536 };
537
538 for (start, end) in segments.iter() {
540 let mut actual_end = *end;
542
543 if !has_code_fence_splits && *end < block.end_line {
546 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
548
549 let min_continuation_indent = if block_bq_level > 0 {
552 if block.is_ordered {
554 block.max_marker_width
555 } else {
556 2 }
558 } else {
559 ctx.lines
560 .get(*end - 1)
561 .and_then(|line_info| line_info.list_item.as_ref())
562 .map(|item| item.content_column)
563 .unwrap_or(2)
564 };
565
566 for check_line in (*end + 1)..=block.end_line {
567 if check_line - 1 < ctx.lines.len() {
568 let line = &ctx.lines[check_line - 1];
569 let line_content = line.content(ctx.content);
570 if block.item_lines.contains(&check_line) || line.heading.is_some() {
572 break;
573 }
574 if line.in_code_block {
576 break;
577 }
578
579 let effective_indent =
581 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
582
583 if effective_indent >= min_continuation_indent {
585 actual_end = check_line;
586 }
587 else if !line.is_blank
592 && line.heading.is_none()
593 && !block.item_lines.contains(&check_line)
594 && !is_thematic_break(line_content)
595 {
596 actual_end = check_line;
598 } else if !line.is_blank {
599 break;
601 }
602 }
603 }
604 }
605
606 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
607 }
608 }
609
610 blocks.retain(|(start, end, _)| {
612 let all_in_comment =
614 (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
615 !all_in_comment
616 });
617
618 blocks
619 }
620
621 fn perform_checks(
622 &self,
623 ctx: &crate::lint_context::LintContext,
624 lines: &[&str],
625 list_blocks: &[(usize, usize, String)],
626 line_index: &LineIndex,
627 ) -> LintResult {
628 let mut warnings = Vec::new();
629 let num_lines = lines.len();
630
631 for (line_idx, line) in lines.iter().enumerate() {
634 let line_num = line_idx + 1;
635
636 let is_in_list = list_blocks
638 .iter()
639 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
640 if is_in_list {
641 continue;
642 }
643
644 if ctx
646 .line_info(line_num)
647 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
648 {
649 continue;
650 }
651
652 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
654 if line_idx > 0 {
656 let prev_line = lines[line_idx - 1];
657 let prev_is_blank = is_blank_in_context(prev_line);
658 let prev_excluded = ctx
659 .line_info(line_idx)
660 .is_some_and(|info| info.in_code_block || info.in_front_matter);
661
662 let prev_trimmed = prev_line.trim();
667 let is_sentence_continuation = !prev_is_blank
668 && !prev_trimmed.is_empty()
669 && !prev_trimmed.ends_with('.')
670 && !prev_trimmed.ends_with('!')
671 && !prev_trimmed.ends_with('?')
672 && !prev_trimmed.ends_with(':')
673 && !prev_trimmed.ends_with(';')
674 && !prev_trimmed.ends_with('>')
675 && !prev_trimmed.ends_with('-')
676 && !prev_trimmed.ends_with('*');
677
678 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
679 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
681
682 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
683 warnings.push(LintWarning {
684 line: start_line,
685 column: start_col,
686 end_line,
687 end_column: end_col,
688 severity: Severity::Warning,
689 rule_name: Some(self.name().to_string()),
690 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
691 fix: Some(Fix {
692 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
693 replacement: format!("{bq_prefix}\n"),
694 }),
695 });
696 }
697
698 if line_idx + 1 < num_lines {
701 let next_line = lines[line_idx + 1];
702 let next_is_blank = is_blank_in_context(next_line);
703 let next_excluded = ctx
704 .line_info(line_idx + 2)
705 .is_some_and(|info| info.in_code_block || info.in_front_matter);
706
707 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
708 let next_is_list_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
710 || next_line.trim_start().starts_with("- ")
711 || next_line.trim_start().starts_with("* ")
712 || next_line.trim_start().starts_with("+ ")
713 || next_line.starts_with("1. ")
714 || (next_line.len() > next_line.trim_start().len()); if !next_is_list_content {
717 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
718 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
719 warnings.push(LintWarning {
720 line: start_line,
721 column: start_col,
722 end_line,
723 end_column: end_col,
724 severity: Severity::Warning,
725 rule_name: Some(self.name().to_string()),
726 message: "List should be followed by blank line".to_string(),
727 fix: Some(Fix {
728 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
729 replacement: format!("{bq_prefix}\n"),
730 }),
731 });
732 }
733 }
734 }
735 }
736 }
737 }
738
739 for &(start_line, end_line, ref prefix) in list_blocks {
740 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
742 continue;
743 }
744
745 if start_line > 1 {
746 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
748
749 if !has_blank_separation && content_line > 0 {
751 let prev_line_str = lines[content_line - 1];
752 let is_prev_excluded = ctx
753 .line_info(content_line)
754 .is_some_and(|info| info.in_code_block || info.in_front_matter);
755 let prev_prefix = BLOCKQUOTE_PREFIX_RE
756 .find(prev_line_str)
757 .map_or(String::new(), |m| m.as_str().to_string());
758 let prefixes_match = prev_prefix.trim() == prefix.trim();
759
760 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
763 if !is_prev_excluded && prefixes_match && should_require {
764 let (start_line, start_col, end_line, end_col) =
766 calculate_line_range(start_line, lines[start_line - 1]);
767
768 warnings.push(LintWarning {
769 line: start_line,
770 column: start_col,
771 end_line,
772 end_column: end_col,
773 severity: Severity::Warning,
774 rule_name: Some(self.name().to_string()),
775 message: "List should be preceded by blank line".to_string(),
776 fix: Some(Fix {
777 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
778 replacement: format!("{prefix}\n"),
779 }),
780 });
781 }
782 }
783 }
784
785 if end_line < num_lines {
786 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
788
789 if !has_blank_separation && content_line > 0 {
791 let next_line_str = lines[content_line - 1];
792 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
795 || (content_line <= ctx.lines.len()
796 && ctx.lines[content_line - 1].in_code_block
797 && ctx.lines[content_line - 1].indent >= 2);
798 let next_prefix = BLOCKQUOTE_PREFIX_RE
799 .find(next_line_str)
800 .map_or(String::new(), |m| m.as_str().to_string());
801
802 let end_line_str = lines[end_line - 1];
807 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
808 .find(end_line_str)
809 .map_or(String::new(), |m| m.as_str().to_string());
810 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
811 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
812 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
813
814 let prefixes_match = next_prefix.trim() == prefix.trim();
815
816 if !is_next_excluded && prefixes_match && !exits_blockquote {
819 let (start_line_last, start_col_last, end_line_last, end_col_last) =
821 calculate_line_range(end_line, lines[end_line - 1]);
822
823 warnings.push(LintWarning {
824 line: start_line_last,
825 column: start_col_last,
826 end_line: end_line_last,
827 end_column: end_col_last,
828 severity: Severity::Warning,
829 rule_name: Some(self.name().to_string()),
830 message: "List should be followed by blank line".to_string(),
831 fix: Some(Fix {
832 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
833 replacement: format!("{prefix}\n"),
834 }),
835 });
836 }
837 }
838 }
839 }
840 Ok(warnings)
841 }
842}
843
844impl Rule for MD032BlanksAroundLists {
845 fn name(&self) -> &'static str {
846 "MD032"
847 }
848
849 fn description(&self) -> &'static str {
850 "Lists should be surrounded by blank lines"
851 }
852
853 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
854 let content = ctx.content;
855 let lines: Vec<&str> = content.lines().collect();
856 let line_index = &ctx.line_index;
857
858 if lines.is_empty() {
860 return Ok(Vec::new());
861 }
862
863 let list_blocks = self.convert_list_blocks(ctx);
864
865 if list_blocks.is_empty() {
866 return Ok(Vec::new());
867 }
868
869 let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
870
871 if !self.config.allow_lazy_continuation {
876 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
877
878 for (line_num, lazy_info) in lazy_lines {
879 let is_within_block = list_blocks
883 .iter()
884 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
885
886 if !is_within_block {
887 continue;
888 }
889
890 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
892 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
893
894 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
896 Self::calculate_lazy_continuation_fix(ctx, line_num, &lazy_info)
897 } else {
898 None
899 };
900
901 warnings.push(LintWarning {
902 line: start_line,
903 column: start_col,
904 end_line,
905 end_column: end_col,
906 severity: Severity::Warning,
907 rule_name: Some(self.name().to_string()),
908 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
909 fix,
910 });
911 }
912 }
913
914 Ok(warnings)
915 }
916
917 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
918 self.fix_with_structure_impl(ctx)
919 }
920
921 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
922 if ctx.content.is_empty() || !ctx.likely_has_lists() {
924 return true;
925 }
926 ctx.list_blocks.is_empty()
928 }
929
930 fn category(&self) -> RuleCategory {
931 RuleCategory::List
932 }
933
934 fn as_any(&self) -> &dyn std::any::Any {
935 self
936 }
937
938 fn default_config_section(&self) -> Option<(String, toml::Value)> {
939 use crate::rule_config_serde::RuleConfig;
940 let default_config = MD032Config::default();
941 let json_value = serde_json::to_value(&default_config).ok()?;
942 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
943
944 if let toml::Value::Table(table) = toml_value {
945 if !table.is_empty() {
946 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
947 } else {
948 None
949 }
950 } else {
951 None
952 }
953 }
954
955 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
956 where
957 Self: Sized,
958 {
959 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
960 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
961 }
962}
963
964impl MD032BlanksAroundLists {
965 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
967 let lines: Vec<&str> = ctx.content.lines().collect();
968 let num_lines = lines.len();
969 if num_lines == 0 {
970 return Ok(String::new());
971 }
972
973 let list_blocks = self.convert_list_blocks(ctx);
974 if list_blocks.is_empty() {
975 return Ok(ctx.content.to_string());
976 }
977
978 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContInfo> = std::collections::BTreeMap::new();
981 if !self.config.allow_lazy_continuation {
982 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
983 for (line_num, lazy_info) in lazy_lines {
984 let is_within_block = list_blocks
986 .iter()
987 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
988 if !is_within_block {
989 continue;
990 }
991 if !Self::should_apply_lazy_fix(ctx, line_num) {
993 continue;
994 }
995 lazy_fixes.insert(line_num, lazy_info);
996 }
997 }
998
999 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
1000
1001 for &(start_line, end_line, ref prefix) in &list_blocks {
1003 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
1005 continue;
1006 }
1007
1008 if start_line > 1 {
1010 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1012
1013 if !has_blank_separation && content_line > 0 {
1015 let prev_line_str = lines[content_line - 1];
1016 let is_prev_excluded = ctx
1017 .line_info(content_line)
1018 .is_some_and(|info| info.in_code_block || info.in_front_matter);
1019 let prev_prefix = BLOCKQUOTE_PREFIX_RE
1020 .find(prev_line_str)
1021 .map_or(String::new(), |m| m.as_str().to_string());
1022
1023 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1024 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
1026 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1028 insertions.insert(start_line, bq_prefix);
1029 }
1030 }
1031 }
1032
1033 if end_line < num_lines {
1035 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1037
1038 if !has_blank_separation && content_line > 0 {
1040 let next_line_str = lines[content_line - 1];
1041 let is_next_excluded = ctx
1043 .line_info(content_line)
1044 .is_some_and(|info| info.in_code_block || info.in_front_matter)
1045 || (content_line <= ctx.lines.len()
1046 && ctx.lines[content_line - 1].in_code_block
1047 && ctx.lines[content_line - 1].indent >= 2
1048 && (ctx.lines[content_line - 1]
1049 .content(ctx.content)
1050 .trim()
1051 .starts_with("```")
1052 || ctx.lines[content_line - 1]
1053 .content(ctx.content)
1054 .trim()
1055 .starts_with("~~~")));
1056 let next_prefix = BLOCKQUOTE_PREFIX_RE
1057 .find(next_line_str)
1058 .map_or(String::new(), |m| m.as_str().to_string());
1059
1060 let end_line_str = lines[end_line - 1];
1062 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
1063 .find(end_line_str)
1064 .map_or(String::new(), |m| m.as_str().to_string());
1065 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
1066 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
1067 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1068
1069 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
1072 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
1074 insertions.insert(end_line + 1, bq_prefix);
1075 }
1076 }
1077 }
1078 }
1079
1080 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1082 for (i, line) in lines.iter().enumerate() {
1083 let current_line_num = i + 1;
1084 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1085 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1086 {
1087 result_lines.push(prefix_to_insert.clone());
1088 }
1089
1090 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1092 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1093 result_lines.push(fixed_line);
1094 } else {
1095 result_lines.push(line.to_string());
1096 }
1097 }
1098
1099 let mut result = result_lines.join("\n");
1101 if ctx.content.ends_with('\n') {
1102 result.push('\n');
1103 }
1104 Ok(result)
1105 }
1106}
1107
1108fn is_blank_in_context(line: &str) -> bool {
1110 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1113 line[m.end()..].trim().is_empty()
1115 } else {
1116 line.trim().is_empty()
1118 }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124 use crate::lint_context::LintContext;
1125 use crate::rule::Rule;
1126
1127 fn lint(content: &str) -> Vec<LintWarning> {
1128 let rule = MD032BlanksAroundLists::default();
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 rule.check(&ctx).expect("Lint check failed")
1131 }
1132
1133 fn fix(content: &str) -> String {
1134 let rule = MD032BlanksAroundLists::default();
1135 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1136 rule.fix(&ctx).expect("Lint fix failed")
1137 }
1138
1139 fn check_warnings_have_fixes(content: &str) {
1141 let warnings = lint(content);
1142 for warning in &warnings {
1143 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1144 }
1145 }
1146
1147 #[test]
1148 fn test_list_at_start() {
1149 let content = "- Item 1\n- Item 2\nText";
1152 let warnings = lint(content);
1153 assert_eq!(
1154 warnings.len(),
1155 0,
1156 "Trailing text is lazy continuation per CommonMark - no warning expected"
1157 );
1158 }
1159
1160 #[test]
1161 fn test_list_at_end() {
1162 let content = "Text\n- Item 1\n- Item 2";
1163 let warnings = lint(content);
1164 assert_eq!(
1165 warnings.len(),
1166 1,
1167 "Expected 1 warning for list at end without preceding blank line"
1168 );
1169 assert_eq!(
1170 warnings[0].line, 2,
1171 "Warning should be on the first line of the list (line 2)"
1172 );
1173 assert!(warnings[0].message.contains("preceded by blank line"));
1174
1175 check_warnings_have_fixes(content);
1177
1178 let fixed_content = fix(content);
1179 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1180
1181 let warnings_after_fix = lint(&fixed_content);
1183 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1184 }
1185
1186 #[test]
1187 fn test_list_in_middle() {
1188 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1191 let warnings = lint(content);
1192 assert_eq!(
1193 warnings.len(),
1194 1,
1195 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1196 );
1197 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1198 assert!(warnings[0].message.contains("preceded by blank line"));
1199
1200 check_warnings_have_fixes(content);
1202
1203 let fixed_content = fix(content);
1204 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1205
1206 let warnings_after_fix = lint(&fixed_content);
1208 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1209 }
1210
1211 #[test]
1212 fn test_correct_spacing() {
1213 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1214 let warnings = lint(content);
1215 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1216
1217 let fixed_content = fix(content);
1218 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1219 }
1220
1221 #[test]
1222 fn test_list_with_content() {
1223 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1226 let warnings = lint(content);
1227 assert_eq!(
1228 warnings.len(),
1229 1,
1230 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1231 );
1232 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1233 assert!(warnings[0].message.contains("preceded by blank line"));
1234
1235 check_warnings_have_fixes(content);
1237
1238 let fixed_content = fix(content);
1239 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1240 assert_eq!(
1241 fixed_content, expected_fixed,
1242 "Fix did not produce the expected output. Got:\n{fixed_content}"
1243 );
1244
1245 let warnings_after_fix = lint(&fixed_content);
1247 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1248 }
1249
1250 #[test]
1251 fn test_nested_list() {
1252 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1254 let warnings = lint(content);
1255 assert_eq!(
1256 warnings.len(),
1257 1,
1258 "Nested list block needs preceding blank only. Got: {warnings:?}"
1259 );
1260 assert_eq!(warnings[0].line, 2);
1261 assert!(warnings[0].message.contains("preceded by blank line"));
1262
1263 check_warnings_have_fixes(content);
1265
1266 let fixed_content = fix(content);
1267 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1268
1269 let warnings_after_fix = lint(&fixed_content);
1271 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1272 }
1273
1274 #[test]
1275 fn test_list_with_internal_blanks() {
1276 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1278 let warnings = lint(content);
1279 assert_eq!(
1280 warnings.len(),
1281 1,
1282 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1283 );
1284 assert_eq!(warnings[0].line, 2);
1285 assert!(warnings[0].message.contains("preceded by blank line"));
1286
1287 check_warnings_have_fixes(content);
1289
1290 let fixed_content = fix(content);
1291 assert_eq!(
1292 fixed_content,
1293 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1294 );
1295
1296 let warnings_after_fix = lint(&fixed_content);
1298 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1299 }
1300
1301 #[test]
1302 fn test_ignore_code_blocks() {
1303 let content = "```\n- Not a list item\n```\nText";
1304 let warnings = lint(content);
1305 assert_eq!(warnings.len(), 0);
1306 let fixed_content = fix(content);
1307 assert_eq!(fixed_content, content);
1308 }
1309
1310 #[test]
1311 fn test_ignore_front_matter() {
1312 let content = "---\ntitle: Test\n---\n- List Item\nText";
1314 let warnings = lint(content);
1315 assert_eq!(
1316 warnings.len(),
1317 0,
1318 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1319 );
1320
1321 let fixed_content = fix(content);
1323 assert_eq!(fixed_content, content, "No changes when no warnings");
1324 }
1325
1326 #[test]
1327 fn test_multiple_lists() {
1328 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1333 let warnings = lint(content);
1334 assert!(
1336 !warnings.is_empty(),
1337 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1338 );
1339
1340 check_warnings_have_fixes(content);
1342
1343 let fixed_content = fix(content);
1344 let warnings_after_fix = lint(&fixed_content);
1346 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1347 }
1348
1349 #[test]
1350 fn test_adjacent_lists() {
1351 let content = "- List 1\n\n* List 2";
1352 let warnings = lint(content);
1353 assert_eq!(warnings.len(), 0);
1354 let fixed_content = fix(content);
1355 assert_eq!(fixed_content, content);
1356 }
1357
1358 #[test]
1359 fn test_list_in_blockquote() {
1360 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1362 let warnings = lint(content);
1363 assert_eq!(
1364 warnings.len(),
1365 1,
1366 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1367 );
1368 assert_eq!(warnings[0].line, 2);
1369
1370 check_warnings_have_fixes(content);
1372
1373 let fixed_content = fix(content);
1374 assert_eq!(
1376 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1377 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1378 );
1379
1380 let warnings_after_fix = lint(&fixed_content);
1382 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1383 }
1384
1385 #[test]
1386 fn test_ordered_list() {
1387 let content = "Text\n1. Item 1\n2. Item 2\nText";
1389 let warnings = lint(content);
1390 assert_eq!(warnings.len(), 1);
1391
1392 check_warnings_have_fixes(content);
1394
1395 let fixed_content = fix(content);
1396 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1397
1398 let warnings_after_fix = lint(&fixed_content);
1400 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1401 }
1402
1403 #[test]
1404 fn test_no_double_blank_fix() {
1405 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1408 assert_eq!(
1409 warnings.len(),
1410 0,
1411 "Should have no warnings - properly preceded, trailing is lazy"
1412 );
1413
1414 let fixed_content = fix(content);
1415 assert_eq!(
1416 fixed_content, content,
1417 "No fix needed when no warnings. Got:\n{fixed_content}"
1418 );
1419
1420 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1422 assert_eq!(warnings2.len(), 1);
1423 if !warnings2.is_empty() {
1424 assert_eq!(
1425 warnings2[0].line, 2,
1426 "Warning line for missing blank before should be the first line of the block"
1427 );
1428 }
1429
1430 check_warnings_have_fixes(content2);
1432
1433 let fixed_content2 = fix(content2);
1434 assert_eq!(
1435 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1436 "Fix added extra blank before. Got:\n{fixed_content2}"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_empty_input() {
1442 let content = "";
1443 let warnings = lint(content);
1444 assert_eq!(warnings.len(), 0);
1445 let fixed_content = fix(content);
1446 assert_eq!(fixed_content, "");
1447 }
1448
1449 #[test]
1450 fn test_only_list() {
1451 let content = "- Item 1\n- Item 2";
1452 let warnings = lint(content);
1453 assert_eq!(warnings.len(), 0);
1454 let fixed_content = fix(content);
1455 assert_eq!(fixed_content, content);
1456 }
1457
1458 #[test]
1461 fn test_fix_complex_nested_blockquote() {
1462 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1464 let warnings = lint(content);
1465 assert_eq!(
1466 warnings.len(),
1467 1,
1468 "Should warn for missing preceding blank only. Got: {warnings:?}"
1469 );
1470
1471 check_warnings_have_fixes(content);
1473
1474 let fixed_content = fix(content);
1475 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1477 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1478
1479 let warnings_after_fix = lint(&fixed_content);
1480 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1481 }
1482
1483 #[test]
1484 fn test_fix_mixed_list_markers() {
1485 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1488 let warnings = lint(content);
1489 assert!(
1491 !warnings.is_empty(),
1492 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1493 );
1494
1495 check_warnings_have_fixes(content);
1497
1498 let fixed_content = fix(content);
1499 assert!(
1501 fixed_content.contains("Text\n\n-"),
1502 "Fix should add blank line before first list item"
1503 );
1504
1505 let warnings_after_fix = lint(&fixed_content);
1507 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1508 }
1509
1510 #[test]
1511 fn test_fix_ordered_list_with_different_numbers() {
1512 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1514 let warnings = lint(content);
1515 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1516
1517 check_warnings_have_fixes(content);
1519
1520 let fixed_content = fix(content);
1521 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1522 assert_eq!(
1523 fixed_content, expected,
1524 "Fix should handle ordered lists with non-sequential numbers"
1525 );
1526
1527 let warnings_after_fix = lint(&fixed_content);
1529 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1530 }
1531
1532 #[test]
1533 fn test_fix_list_with_code_blocks_inside() {
1534 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1536 let warnings = lint(content);
1537 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1538
1539 check_warnings_have_fixes(content);
1541
1542 let fixed_content = fix(content);
1543 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1544 assert_eq!(
1545 fixed_content, expected,
1546 "Fix should handle lists with internal code blocks"
1547 );
1548
1549 let warnings_after_fix = lint(&fixed_content);
1551 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1552 }
1553
1554 #[test]
1555 fn test_fix_deeply_nested_lists() {
1556 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1558 let warnings = lint(content);
1559 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1560
1561 check_warnings_have_fixes(content);
1563
1564 let fixed_content = fix(content);
1565 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1566 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1567
1568 let warnings_after_fix = lint(&fixed_content);
1570 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1571 }
1572
1573 #[test]
1574 fn test_fix_list_with_multiline_items() {
1575 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1578 let warnings = lint(content);
1579 assert_eq!(
1580 warnings.len(),
1581 1,
1582 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1583 );
1584
1585 check_warnings_have_fixes(content);
1587
1588 let fixed_content = fix(content);
1589 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1590 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1591
1592 let warnings_after_fix = lint(&fixed_content);
1594 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1595 }
1596
1597 #[test]
1598 fn test_fix_list_at_document_boundaries() {
1599 let content1 = "- Item 1\n- Item 2";
1601 let warnings1 = lint(content1);
1602 assert_eq!(
1603 warnings1.len(),
1604 0,
1605 "List at document start should not need blank before"
1606 );
1607 let fixed1 = fix(content1);
1608 assert_eq!(fixed1, content1, "No fix needed for list at start");
1609
1610 let content2 = "Text\n- Item 1\n- Item 2";
1612 let warnings2 = lint(content2);
1613 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1614 check_warnings_have_fixes(content2);
1615 let fixed2 = fix(content2);
1616 assert_eq!(
1617 fixed2, "Text\n\n- Item 1\n- Item 2",
1618 "Should add blank before list at end"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_fix_preserves_existing_blank_lines() {
1624 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1625 let warnings = lint(content);
1626 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1627 let fixed_content = fix(content);
1628 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1629 }
1630
1631 #[test]
1632 fn test_fix_handles_tabs_and_spaces() {
1633 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1636 let warnings = lint(content);
1637 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1639
1640 check_warnings_have_fixes(content);
1642
1643 let fixed_content = fix(content);
1644 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1647 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1648
1649 let warnings_after_fix = lint(&fixed_content);
1651 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1652 }
1653
1654 #[test]
1655 fn test_fix_warning_objects_have_correct_ranges() {
1656 let content = "Text\n- Item 1\n- Item 2\nText";
1658 let warnings = lint(content);
1659 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1660
1661 for warning in &warnings {
1663 assert!(warning.fix.is_some(), "Warning should have fix");
1664 let fix = warning.fix.as_ref().unwrap();
1665 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1666 assert!(
1667 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1668 "Fix should have replacement or be insertion"
1669 );
1670 }
1671 }
1672
1673 #[test]
1674 fn test_fix_idempotent() {
1675 let content = "Text\n- Item 1\n- Item 2\nText";
1677
1678 let fixed_once = fix(content);
1680 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1681
1682 let fixed_twice = fix(&fixed_once);
1684 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1685
1686 let warnings_after_fix = lint(&fixed_once);
1688 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1689 }
1690
1691 #[test]
1692 fn test_fix_with_normalized_line_endings() {
1693 let content = "Text\n- Item 1\n- Item 2\nText";
1697 let warnings = lint(content);
1698 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1699
1700 check_warnings_have_fixes(content);
1702
1703 let fixed_content = fix(content);
1704 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1706 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1707 }
1708
1709 #[test]
1710 fn test_fix_preserves_final_newline() {
1711 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1714 let fixed_with_newline = fix(content_with_newline);
1715 assert!(
1716 fixed_with_newline.ends_with('\n'),
1717 "Fix should preserve final newline when present"
1718 );
1719 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1721
1722 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1724 let fixed_without_newline = fix(content_without_newline);
1725 assert!(
1726 !fixed_without_newline.ends_with('\n'),
1727 "Fix should not add final newline when not present"
1728 );
1729 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1731 }
1732
1733 #[test]
1734 fn test_fix_multiline_list_items_no_indent() {
1735 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";
1736
1737 let warnings = lint(content);
1738 assert_eq!(
1740 warnings.len(),
1741 0,
1742 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1743 );
1744
1745 let fixed_content = fix(content);
1746 assert_eq!(
1748 fixed_content, content,
1749 "Should not modify correctly formatted multi-line list items"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_nested_list_with_lazy_continuation() {
1755 let content = r#"# Test
1761
1762- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1763 1. Switch/case dispatcher statements (original Phase 3.2)
1764 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1765`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1766 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1767 references"#;
1768
1769 let warnings = lint(content);
1770 let md032_warnings: Vec<_> = warnings
1773 .iter()
1774 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1775 .collect();
1776 assert_eq!(
1777 md032_warnings.len(),
1778 0,
1779 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_pipes_in_code_spans_not_detected_as_table() {
1785 let content = r#"# Test
1787
1788- Item with `a | b` inline code
1789 - Nested item should work
1790
1791"#;
1792
1793 let warnings = lint(content);
1794 let md032_warnings: Vec<_> = warnings
1795 .iter()
1796 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1797 .collect();
1798 assert_eq!(
1799 md032_warnings.len(),
1800 0,
1801 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_multiple_code_spans_with_pipes() {
1807 let content = r#"# Test
1809
1810- Item with `a | b` and `c || d` operators
1811 - Nested item should work
1812
1813"#;
1814
1815 let warnings = lint(content);
1816 let md032_warnings: Vec<_> = warnings
1817 .iter()
1818 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1819 .collect();
1820 assert_eq!(
1821 md032_warnings.len(),
1822 0,
1823 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_actual_table_breaks_list() {
1829 let content = r#"# Test
1831
1832- Item before table
1833
1834| Col1 | Col2 |
1835|------|------|
1836| A | B |
1837
1838- Item after table
1839
1840"#;
1841
1842 let warnings = lint(content);
1843 let md032_warnings: Vec<_> = warnings
1845 .iter()
1846 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1847 .collect();
1848 assert_eq!(
1849 md032_warnings.len(),
1850 0,
1851 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1852 );
1853 }
1854
1855 #[test]
1856 fn test_thematic_break_not_lazy_continuation() {
1857 let content = r#"- Item 1
1860- Item 2
1861***
1862
1863More text.
1864"#;
1865
1866 let warnings = lint(content);
1867 let md032_warnings: Vec<_> = warnings
1868 .iter()
1869 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1870 .collect();
1871 assert_eq!(
1872 md032_warnings.len(),
1873 1,
1874 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1875 );
1876 assert!(
1877 md032_warnings[0].message.contains("followed by blank line"),
1878 "Warning should be about missing blank after list"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_thematic_break_with_blank_line() {
1884 let content = r#"- Item 1
1886- Item 2
1887
1888***
1889
1890More text.
1891"#;
1892
1893 let warnings = lint(content);
1894 let md032_warnings: Vec<_> = warnings
1895 .iter()
1896 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1897 .collect();
1898 assert_eq!(
1899 md032_warnings.len(),
1900 0,
1901 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1902 );
1903 }
1904
1905 #[test]
1906 fn test_various_thematic_break_styles() {
1907 for hr in ["---", "***", "___"] {
1912 let content = format!(
1913 r#"- Item 1
1914- Item 2
1915{hr}
1916
1917More text.
1918"#
1919 );
1920
1921 let warnings = lint(&content);
1922 let md032_warnings: Vec<_> = warnings
1923 .iter()
1924 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1925 .collect();
1926 assert_eq!(
1927 md032_warnings.len(),
1928 1,
1929 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1930 );
1931 }
1932 }
1933
1934 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1937 let rule = MD032BlanksAroundLists::from_config_struct(config);
1938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1939 rule.check(&ctx).expect("Lint check failed")
1940 }
1941
1942 fn fix_with_config(content: &str, config: MD032Config) -> String {
1943 let rule = MD032BlanksAroundLists::from_config_struct(config);
1944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1945 rule.fix(&ctx).expect("Lint fix failed")
1946 }
1947
1948 #[test]
1949 fn test_lazy_continuation_allowed_by_default() {
1950 let content = "# Heading\n\n1. List\nSome text.";
1952 let warnings = lint(content);
1953 assert_eq!(
1954 warnings.len(),
1955 0,
1956 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_lazy_continuation_disallowed() {
1962 let content = "# Heading\n\n1. List\nSome text.";
1964 let config = MD032Config {
1965 allow_lazy_continuation: false,
1966 };
1967 let warnings = lint_with_config(content, config);
1968 assert_eq!(
1969 warnings.len(),
1970 1,
1971 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1972 );
1973 assert!(
1974 warnings[0].message.contains("Lazy continuation"),
1975 "Warning message should mention lazy continuation"
1976 );
1977 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1978 }
1979
1980 #[test]
1981 fn test_lazy_continuation_fix() {
1982 let content = "# Heading\n\n1. List\nSome text.";
1984 let config = MD032Config {
1985 allow_lazy_continuation: false,
1986 };
1987 let fixed = fix_with_config(content, config.clone());
1988 assert_eq!(
1990 fixed, "# Heading\n\n1. List\n Some text.",
1991 "Fix should add proper indentation to lazy continuation"
1992 );
1993
1994 let warnings_after = lint_with_config(&fixed, config);
1996 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1997 }
1998
1999 #[test]
2000 fn test_lazy_continuation_multiple_lines() {
2001 let content = "- Item 1\nLine 2\nLine 3";
2003 let config = MD032Config {
2004 allow_lazy_continuation: false,
2005 };
2006 let warnings = lint_with_config(content, config.clone());
2007 assert_eq!(
2009 warnings.len(),
2010 2,
2011 "Should warn for each lazy continuation line. Got: {warnings:?}"
2012 );
2013
2014 let fixed = fix_with_config(content, config.clone());
2015 assert_eq!(
2017 fixed, "- Item 1\n Line 2\n Line 3",
2018 "Fix should add proper indentation to lazy continuation lines"
2019 );
2020
2021 let warnings_after = lint_with_config(&fixed, config);
2023 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2024 }
2025
2026 #[test]
2027 fn test_lazy_continuation_with_indented_content() {
2028 let content = "- Item 1\n Indented content\nLazy text";
2030 let config = MD032Config {
2031 allow_lazy_continuation: false,
2032 };
2033 let warnings = lint_with_config(content, config);
2034 assert_eq!(
2035 warnings.len(),
2036 1,
2037 "Should warn for lazy text after indented content. Got: {warnings:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_lazy_continuation_properly_separated() {
2043 let content = "- Item 1\n\nSome text.";
2045 let config = MD032Config {
2046 allow_lazy_continuation: false,
2047 };
2048 let warnings = lint_with_config(content, config);
2049 assert_eq!(
2050 warnings.len(),
2051 0,
2052 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2053 );
2054 }
2055
2056 #[test]
2059 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2060 let content = "1) First item\nLazy continuation";
2062 let config = MD032Config {
2063 allow_lazy_continuation: false,
2064 };
2065 let warnings = lint_with_config(content, config.clone());
2066 assert_eq!(
2067 warnings.len(),
2068 1,
2069 "Should warn for lazy continuation with parenthesis marker"
2070 );
2071
2072 let fixed = fix_with_config(content, config);
2073 assert_eq!(fixed, "1) First item\n Lazy continuation");
2075 }
2076
2077 #[test]
2078 fn test_lazy_continuation_followed_by_another_list() {
2079 let content = "- Item 1\nSome text\n- Item 2";
2085 let config = MD032Config {
2086 allow_lazy_continuation: false,
2087 };
2088 let warnings = lint_with_config(content, config);
2089 assert_eq!(
2091 warnings.len(),
2092 1,
2093 "Should warn about lazy continuation within list. Got: {warnings:?}"
2094 );
2095 assert!(
2096 warnings[0].message.contains("Lazy continuation"),
2097 "Warning should be about lazy continuation"
2098 );
2099 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2100 }
2101
2102 #[test]
2103 fn test_lazy_continuation_multiple_in_document() {
2104 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2109 let config = MD032Config {
2110 allow_lazy_continuation: false,
2111 };
2112 let warnings = lint_with_config(content, config.clone());
2113 assert_eq!(
2115 warnings.len(),
2116 2,
2117 "Should warn for both lazy continuations. Got: {warnings:?}"
2118 );
2119
2120 let fixed = fix_with_config(content, config.clone());
2121 assert!(
2123 fixed.contains(" Lazy 1"),
2124 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2125 );
2126 assert!(
2127 fixed.contains(" Lazy 2"),
2128 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2129 );
2130
2131 let warnings_after = lint_with_config(&fixed, config);
2132 assert_eq!(
2134 warnings_after.len(),
2135 0,
2136 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2137 );
2138 }
2139
2140 #[test]
2141 fn test_lazy_continuation_end_of_document_no_newline() {
2142 let content = "- Item\nNo trailing newline";
2144 let config = MD032Config {
2145 allow_lazy_continuation: false,
2146 };
2147 let warnings = lint_with_config(content, config.clone());
2148 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2149
2150 let fixed = fix_with_config(content, config);
2151 assert_eq!(fixed, "- Item\n No trailing newline");
2153 }
2154
2155 #[test]
2156 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2157 let content = "- Item 1\n---";
2160 let config = MD032Config {
2161 allow_lazy_continuation: false,
2162 };
2163 let warnings = lint_with_config(content, config.clone());
2164 assert_eq!(
2166 warnings.len(),
2167 1,
2168 "List should need blank line before thematic break. Got: {warnings:?}"
2169 );
2170
2171 let fixed = fix_with_config(content, config);
2173 assert_eq!(fixed, "- Item 1\n\n---");
2174 }
2175
2176 #[test]
2177 fn test_lazy_continuation_heading_not_flagged() {
2178 let content = "- Item 1\n# Heading";
2181 let config = MD032Config {
2182 allow_lazy_continuation: false,
2183 };
2184 let warnings = lint_with_config(content, config);
2185 assert!(
2188 warnings.iter().all(|w| !w.message.contains("lazy")),
2189 "Heading should not trigger lazy continuation warning"
2190 );
2191 }
2192
2193 #[test]
2194 fn test_lazy_continuation_mixed_list_types() {
2195 let content = "- Unordered\n1. Ordered\nLazy text";
2197 let config = MD032Config {
2198 allow_lazy_continuation: false,
2199 };
2200 let warnings = lint_with_config(content, config.clone());
2201 assert!(!warnings.is_empty(), "Should warn about structure issues");
2202 }
2203
2204 #[test]
2205 fn test_lazy_continuation_deep_nesting() {
2206 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2208 let config = MD032Config {
2209 allow_lazy_continuation: false,
2210 };
2211 let warnings = lint_with_config(content, config.clone());
2212 assert!(
2213 !warnings.is_empty(),
2214 "Should warn about lazy continuation after nested list"
2215 );
2216
2217 let fixed = fix_with_config(content, config.clone());
2218 let warnings_after = lint_with_config(&fixed, config);
2219 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2220 }
2221
2222 #[test]
2223 fn test_lazy_continuation_with_emphasis_in_text() {
2224 let content = "- Item\n*emphasized* continuation";
2226 let config = MD032Config {
2227 allow_lazy_continuation: false,
2228 };
2229 let warnings = lint_with_config(content, config.clone());
2230 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2231
2232 let fixed = fix_with_config(content, config);
2233 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2235 }
2236
2237 #[test]
2238 fn test_lazy_continuation_with_code_span() {
2239 let content = "- Item\n`code` continuation";
2241 let config = MD032Config {
2242 allow_lazy_continuation: false,
2243 };
2244 let warnings = lint_with_config(content, config.clone());
2245 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2246
2247 let fixed = fix_with_config(content, config);
2248 assert_eq!(fixed, "- Item\n `code` continuation");
2250 }
2251
2252 #[test]
2259 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2260 let content = r#"1. Create a new Chat conversation:
2263 - On the sidebar, select **New Chat**.
2264 - In the box, type `/new`.
2265 A new Chat conversation replaces the previous one.
22661. Under the Chat text box, turn off the toggle."#;
2267 let config = MD032Config {
2268 allow_lazy_continuation: false,
2269 };
2270 let warnings = lint_with_config(content, config);
2271 let lazy_warnings: Vec<_> = warnings
2273 .iter()
2274 .filter(|w| w.message.contains("Lazy continuation"))
2275 .collect();
2276 assert!(
2277 !lazy_warnings.is_empty(),
2278 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2279 );
2280 assert!(
2281 lazy_warnings.iter().any(|w| w.line == 4),
2282 "Should warn on line 4. Got: {lazy_warnings:?}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2288 let content = r#"- `field`: Is the specific key:
2291 - `password`: Accesses the password.
2292 - `api_key`: Accesses the api_key.
2293 `token`: Specifies which ID token to use.
2294- `version_id`: Is the unique identifier."#;
2295 let config = MD032Config {
2296 allow_lazy_continuation: false,
2297 };
2298 let warnings = lint_with_config(content, config);
2299 let lazy_warnings: Vec<_> = warnings
2301 .iter()
2302 .filter(|w| w.message.contains("Lazy continuation"))
2303 .collect();
2304 assert!(
2305 !lazy_warnings.is_empty(),
2306 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2307 );
2308 assert!(
2309 lazy_warnings.iter().any(|w| w.line == 4),
2310 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2316 let content = r#"- Check out the branch, and test locally.
2318 - If the MR requires significant modifications:
2319 - **Skip local testing** and review instead.
2320 - **Request verification** from the author.
2321 - **Identify the minimal change** needed.
2322 Your testing might result in opportunities.
2323- If you don't understand, _say so_."#;
2324 let config = MD032Config {
2325 allow_lazy_continuation: false,
2326 };
2327 let warnings = lint_with_config(content, config);
2328 let lazy_warnings: Vec<_> = warnings
2330 .iter()
2331 .filter(|w| w.message.contains("Lazy continuation"))
2332 .collect();
2333 assert!(
2334 !lazy_warnings.is_empty(),
2335 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2336 );
2337 assert!(
2338 lazy_warnings.iter().any(|w| w.line == 6),
2339 "Should warn on line 6. Got: {lazy_warnings:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn test_issue295_ordered_list_nested_bullets_continuation() {
2345 let content = r#"# Test
2348
23491. First item.
2350 - Nested A.
2351 - Nested B.
2352 Continuation at outer level.
23531. Second item."#;
2354 let config = MD032Config {
2355 allow_lazy_continuation: false,
2356 };
2357 let warnings = lint_with_config(content, config);
2358 let lazy_warnings: Vec<_> = warnings
2360 .iter()
2361 .filter(|w| w.message.contains("Lazy continuation"))
2362 .collect();
2363 assert!(
2364 !lazy_warnings.is_empty(),
2365 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2366 );
2367 assert!(
2369 lazy_warnings.iter().any(|w| w.line == 6),
2370 "Should warn on line 6. Got: {lazy_warnings:?}"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_issue295_multiple_lazy_lines_after_nested() {
2376 let content = r#"1. The device client receives a response.
2378 - Those defined by OAuth Framework.
2379 - Those specific to device authorization.
2380 Those error responses are described below.
2381 For more information on each response,
2382 see the documentation.
23831. Next step in the process."#;
2384 let config = MD032Config {
2385 allow_lazy_continuation: false,
2386 };
2387 let warnings = lint_with_config(content, config);
2388 let lazy_warnings: Vec<_> = warnings
2390 .iter()
2391 .filter(|w| w.message.contains("Lazy continuation"))
2392 .collect();
2393 assert!(
2394 lazy_warnings.len() >= 3,
2395 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2396 lazy_warnings.len()
2397 );
2398 }
2399
2400 #[test]
2401 fn test_issue295_properly_indented_not_lazy() {
2402 let content = r#"1. First item.
2404 - Nested A.
2405 - Nested B.
2406
2407 Properly indented continuation.
24081. Second item."#;
2409 let config = MD032Config {
2410 allow_lazy_continuation: false,
2411 };
2412 let warnings = lint_with_config(content, config);
2413 let lazy_warnings: Vec<_> = warnings
2415 .iter()
2416 .filter(|w| w.message.contains("Lazy continuation"))
2417 .collect();
2418 assert_eq!(
2419 lazy_warnings.len(),
2420 0,
2421 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2422 );
2423 }
2424
2425 #[test]
2432 fn test_html_comment_before_list_with_preceding_blank() {
2433 let content = "Some text.\n\n<!-- comment -->\n- List item";
2436 let warnings = lint(content);
2437 assert_eq!(
2438 warnings.len(),
2439 0,
2440 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_html_comment_after_list_with_following_blank() {
2446 let content = "- List item\n<!-- comment -->\n\nSome text.";
2448 let warnings = lint(content);
2449 assert_eq!(
2450 warnings.len(),
2451 0,
2452 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2453 );
2454 }
2455
2456 #[test]
2457 fn test_list_inside_html_comment_ignored() {
2458 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2460 let warnings = lint(content);
2461 assert_eq!(
2462 warnings.len(),
2463 0,
2464 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_multiline_html_comment_before_list() {
2470 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2472 let warnings = lint(content);
2473 assert_eq!(
2474 warnings.len(),
2475 0,
2476 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2477 );
2478 }
2479
2480 #[test]
2481 fn test_no_blank_before_html_comment_still_warns() {
2482 let content = "Some text.\n<!-- comment -->\n- List item";
2484 let warnings = lint(content);
2485 assert_eq!(
2486 warnings.len(),
2487 1,
2488 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2489 );
2490 assert!(
2491 warnings[0].message.contains("preceded by blank line"),
2492 "Should be 'preceded by blank line' warning"
2493 );
2494 }
2495
2496 #[test]
2497 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2498 let content = "- List item\n<!-- comment -->\nSome text.";
2501 let warnings = lint(content);
2502 assert_eq!(
2503 warnings.len(),
2504 0,
2505 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2506 );
2507 }
2508
2509 #[test]
2510 fn test_list_followed_by_heading_through_comment_should_warn() {
2511 let content = "- List item\n<!-- comment -->\n# Heading";
2513 let warnings = lint(content);
2514 assert!(
2517 warnings.len() <= 1,
2518 "Should handle heading after comment gracefully. Got: {warnings:?}"
2519 );
2520 }
2521
2522 #[test]
2523 fn test_html_comment_between_list_and_text_both_directions() {
2524 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2526 let warnings = lint(content);
2527 assert_eq!(
2528 warnings.len(),
2529 0,
2530 "Should not warn with proper separation through comments. Got: {warnings:?}"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2536 let content = "Text.\n\n<!-- comment -->\n- Item";
2538 let fixed = fix(content);
2539 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2540 }
2541
2542 #[test]
2543 fn test_html_comment_fix_adds_blank_when_needed() {
2544 let content = "Text.\n<!-- comment -->\n- Item";
2547 let fixed = fix(content);
2548 assert!(
2549 fixed.contains("<!-- comment -->\n\n- Item"),
2550 "Fix should add blank line before list. Got: {fixed}"
2551 );
2552 }
2553
2554 #[test]
2555 fn test_ordered_list_inside_html_comment() {
2556 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2558 let warnings = lint(content);
2559 assert_eq!(
2560 warnings.len(),
2561 0,
2562 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2563 );
2564 }
2565
2566 #[test]
2573 fn test_blockquote_list_exit_no_warning() {
2574 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2576 let warnings = lint(content);
2577 assert_eq!(
2578 warnings.len(),
2579 0,
2580 "Should not warn when exiting blockquote. Got: {warnings:?}"
2581 );
2582 }
2583
2584 #[test]
2585 fn test_nested_blockquote_list_exit() {
2586 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2588 let warnings = lint(content);
2589 assert_eq!(
2590 warnings.len(),
2591 0,
2592 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2593 );
2594 }
2595
2596 #[test]
2597 fn test_blockquote_same_level_no_warning() {
2598 let content = "> - item 1\n> - item 2\n> Text after";
2601 let warnings = lint(content);
2602 assert_eq!(
2603 warnings.len(),
2604 0,
2605 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn test_blockquote_list_with_special_chars() {
2611 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2613 let warnings = lint(content);
2614 assert_eq!(
2615 warnings.len(),
2616 0,
2617 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2618 );
2619 }
2620
2621 #[test]
2622 fn test_lazy_continuation_whitespace_only_line() {
2623 let content = "- Item\n \nText after whitespace-only line";
2626 let config = MD032Config {
2627 allow_lazy_continuation: false,
2628 };
2629 let warnings = lint_with_config(content, config);
2630 assert_eq!(
2632 warnings.len(),
2633 0,
2634 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2635 );
2636 }
2637
2638 #[test]
2639 fn test_lazy_continuation_blockquote_context() {
2640 let content = "> - Item\n> Lazy in quote";
2642 let config = MD032Config {
2643 allow_lazy_continuation: false,
2644 };
2645 let warnings = lint_with_config(content, config);
2646 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2649 }
2650
2651 #[test]
2652 fn test_lazy_continuation_fix_preserves_content() {
2653 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2655 let config = MD032Config {
2656 allow_lazy_continuation: false,
2657 };
2658 let fixed = fix_with_config(content, config);
2659 assert!(fixed.contains("<>&"), "Should preserve special chars");
2660 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2661 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2663 }
2664
2665 #[test]
2666 fn test_lazy_continuation_fix_idempotent() {
2667 let content = "- Item\nLazy";
2669 let config = MD032Config {
2670 allow_lazy_continuation: false,
2671 };
2672 let fixed_once = fix_with_config(content, config.clone());
2673 let fixed_twice = fix_with_config(&fixed_once, config);
2674 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2675 }
2676
2677 #[test]
2678 fn test_lazy_continuation_config_default_allows() {
2679 let content = "- Item\nLazy text that continues";
2681 let default_config = MD032Config::default();
2682 assert!(
2683 default_config.allow_lazy_continuation,
2684 "Default should allow lazy continuation"
2685 );
2686 let warnings = lint_with_config(content, default_config);
2687 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2688 }
2689
2690 #[test]
2691 fn test_lazy_continuation_after_multi_line_item() {
2692 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2694 let config = MD032Config {
2695 allow_lazy_continuation: false,
2696 };
2697 let warnings = lint_with_config(content, config.clone());
2698 assert_eq!(
2699 warnings.len(),
2700 1,
2701 "Should warn only for the lazy line, not the indented line"
2702 );
2703 }
2704
2705 #[test]
2707 fn test_blockquote_list_with_continuation_and_nested() {
2708 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2711 let warnings = lint(content);
2712 assert_eq!(
2713 warnings.len(),
2714 0,
2715 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn test_blockquote_list_simple() {
2721 let content = "> - item 1\n> - item 2";
2723 let warnings = lint(content);
2724 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2725 }
2726
2727 #[test]
2728 fn test_blockquote_list_with_continuation_only() {
2729 let content = "> - item 1\n> continuation\n> - item 2";
2731 let warnings = lint(content);
2732 assert_eq!(
2733 warnings.len(),
2734 0,
2735 "Blockquoted list with continuation should have no warnings"
2736 );
2737 }
2738
2739 #[test]
2740 fn test_blockquote_list_with_lazy_continuation() {
2741 let content = "> - item 1\n> lazy continuation\n> - item 2";
2743 let warnings = lint(content);
2744 assert_eq!(
2745 warnings.len(),
2746 0,
2747 "Blockquoted list with lazy continuation should have no warnings"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_nested_blockquote_list() {
2753 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2755 let warnings = lint(content);
2756 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2757 }
2758
2759 #[test]
2760 fn test_blockquote_list_needs_preceding_blank() {
2761 let content = "> Text before\n> - item 1\n> - item 2";
2763 let warnings = lint(content);
2764 assert_eq!(
2765 warnings.len(),
2766 1,
2767 "Should warn for missing blank before blockquoted list"
2768 );
2769 }
2770
2771 #[test]
2772 fn test_blockquote_list_properly_separated() {
2773 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2775 let warnings = lint(content);
2776 assert_eq!(
2777 warnings.len(),
2778 0,
2779 "Properly separated blockquoted list should have no warnings"
2780 );
2781 }
2782
2783 #[test]
2784 fn test_blockquote_ordered_list() {
2785 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2787 let warnings = lint(content);
2788 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2789 }
2790
2791 #[test]
2792 fn test_blockquote_list_with_empty_blockquote_line() {
2793 let content = "> - item 1\n>\n> - item 2";
2795 let warnings = lint(content);
2796 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2797 }
2798
2799 #[test]
2801 fn test_blockquote_list_multi_paragraph_items() {
2802 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2805 let warnings = lint(content);
2806 assert_eq!(
2807 warnings.len(),
2808 0,
2809 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2810 );
2811 }
2812
2813 #[test]
2815 fn test_blockquote_ordered_list_multi_paragraph_items() {
2816 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2817 let warnings = lint(content);
2818 assert_eq!(
2819 warnings.len(),
2820 0,
2821 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2822 );
2823 }
2824
2825 #[test]
2827 fn test_blockquote_list_multiple_continuations() {
2828 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2829 let warnings = lint(content);
2830 assert_eq!(
2831 warnings.len(),
2832 0,
2833 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2834 );
2835 }
2836
2837 #[test]
2839 fn test_nested_blockquote_multi_paragraph_list() {
2840 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2841 let warnings = lint(content);
2842 assert_eq!(
2843 warnings.len(),
2844 0,
2845 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2846 );
2847 }
2848
2849 #[test]
2851 fn test_triple_nested_blockquote_multi_paragraph_list() {
2852 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2853 let warnings = lint(content);
2854 assert_eq!(
2855 warnings.len(),
2856 0,
2857 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2858 );
2859 }
2860
2861 #[test]
2863 fn test_blockquote_list_last_item_continuation() {
2864 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2865 let warnings = lint(content);
2866 assert_eq!(
2867 warnings.len(),
2868 0,
2869 "Last item with continuation should have no warnings. Got: {warnings:?}"
2870 );
2871 }
2872
2873 #[test]
2875 fn test_blockquote_list_first_item_only_continuation() {
2876 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2877 let warnings = lint(content);
2878 assert_eq!(
2879 warnings.len(),
2880 0,
2881 "Single item with continuation should have no warnings. Got: {warnings:?}"
2882 );
2883 }
2884
2885 #[test]
2889 fn test_blockquote_level_change_breaks_list() {
2890 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2892 let warnings = lint(content);
2893 assert!(
2897 warnings.len() <= 2,
2898 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2899 );
2900 }
2901
2902 #[test]
2904 fn test_exit_blockquote_needs_blank_before_list() {
2905 let content = "> Blockquote text\n\n- List outside blockquote\n";
2907 let warnings = lint(content);
2908 assert_eq!(
2909 warnings.len(),
2910 0,
2911 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2912 );
2913
2914 let content2 = "> Blockquote text\n- List outside blockquote\n";
2918 let warnings2 = lint(content2);
2919 assert!(
2921 warnings2.len() <= 1,
2922 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2923 );
2924 }
2925
2926 #[test]
2928 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2929 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2931 let warnings = lint(content_dash);
2932 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2933
2934 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2936 let warnings = lint(content_asterisk);
2937 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2938
2939 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2941 let warnings = lint(content_plus);
2942 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2943 }
2944
2945 #[test]
2947 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2948 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2949 let warnings = lint(content);
2950 assert_eq!(
2951 warnings.len(),
2952 0,
2953 "Parenthesis ordered markers should work. Got: {warnings:?}"
2954 );
2955 }
2956
2957 #[test]
2959 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2960 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2962 let warnings = lint(content);
2963 assert_eq!(
2964 warnings.len(),
2965 0,
2966 "Multi-digit ordered list should work. Got: {warnings:?}"
2967 );
2968 }
2969
2970 #[test]
2972 fn test_blockquote_multi_paragraph_with_formatting() {
2973 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2974 let warnings = lint(content);
2975 assert_eq!(
2976 warnings.len(),
2977 0,
2978 "Continuation with inline formatting should work. Got: {warnings:?}"
2979 );
2980 }
2981
2982 #[test]
2984 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2985 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2986 let warnings = lint(content);
2987 assert_eq!(
2988 warnings.len(),
2989 0,
2990 "All items with continuations should work. Got: {warnings:?}"
2991 );
2992 }
2993
2994 #[test]
2996 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2997 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2998 let warnings = lint(content);
2999 assert_eq!(
3000 warnings.len(),
3001 0,
3002 "Lowercase continuation should work. Got: {warnings:?}"
3003 );
3004 }
3005
3006 #[test]
3008 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3009 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3010 let warnings = lint(content);
3011 assert_eq!(
3012 warnings.len(),
3013 0,
3014 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3015 );
3016 }
3017
3018 #[test]
3020 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3021 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3023 let warnings = lint(content);
3024 assert!(
3026 warnings.len() <= 1,
3027 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3028 );
3029 }
3030
3031 #[test]
3033 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3034 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3036 let warnings = lint(content);
3037 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3038 }
3039
3040 #[test]
3041 fn test_blockquote_list_varying_spaces_after_marker() {
3042 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3044 let warnings = lint(content);
3045 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3046 }
3047
3048 #[test]
3049 fn test_deeply_nested_blockquote_list() {
3050 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3052 let warnings = lint(content);
3053 assert_eq!(
3054 warnings.len(),
3055 0,
3056 "Deeply nested blockquote list should have no warnings"
3057 );
3058 }
3059
3060 #[test]
3061 fn test_blockquote_level_change_in_list() {
3062 let content = "> - item 1\n>> - deeper item\n> - item 2";
3064 let warnings = lint(content);
3067 assert!(
3068 !warnings.is_empty(),
3069 "Blockquote level change should break list and trigger warnings"
3070 );
3071 }
3072
3073 #[test]
3074 fn test_blockquote_list_with_code_span() {
3075 let content = "> - item with `code`\n> continuation\n> - item 2";
3077 let warnings = lint(content);
3078 assert_eq!(
3079 warnings.len(),
3080 0,
3081 "Blockquote list with code span should have no warnings"
3082 );
3083 }
3084
3085 #[test]
3086 fn test_blockquote_list_at_document_end() {
3087 let content = "> Some text\n>\n> - item 1\n> - item 2";
3089 let warnings = lint(content);
3090 assert_eq!(
3091 warnings.len(),
3092 0,
3093 "Blockquote list at document end should have no warnings"
3094 );
3095 }
3096
3097 #[test]
3098 fn test_fix_preserves_blockquote_prefix_before_list() {
3099 let content = "> Text before
3101> - Item 1
3102> - Item 2";
3103 let fixed = fix(content);
3104
3105 let expected = "> Text before
3107>
3108> - Item 1
3109> - Item 2";
3110 assert_eq!(
3111 fixed, expected,
3112 "Fix should insert '>' blank line, not plain blank line"
3113 );
3114 }
3115
3116 #[test]
3117 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3118 let content = ">>> Triple nested
3121>>> - Item 1
3122>>> - Item 2
3123>>> More text";
3124 let fixed = fix(content);
3125
3126 let expected = ">>> Triple nested
3128>>>
3129>>> - Item 1
3130>>> - Item 2
3131>>> More text";
3132 assert_eq!(
3133 fixed, expected,
3134 "Fix should preserve triple-nested blockquote prefix '>>>'"
3135 );
3136 }
3137
3138 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3141 let rule = MD032BlanksAroundLists::default();
3142 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3143 rule.check(&ctx).unwrap()
3144 }
3145
3146 #[test]
3147 fn test_quarto_list_after_div_open() {
3148 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3150 let warnings = lint_quarto(content);
3151 assert!(
3153 warnings.is_empty(),
3154 "Quarto div marker should be transparent before list: {warnings:?}"
3155 );
3156 }
3157
3158 #[test]
3159 fn test_quarto_list_before_div_close() {
3160 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3162 let warnings = lint_quarto(content);
3163 assert!(
3165 warnings.is_empty(),
3166 "Quarto div marker should be transparent after list: {warnings:?}"
3167 );
3168 }
3169
3170 #[test]
3171 fn test_quarto_list_needs_blank_without_div() {
3172 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3174 let warnings = lint_quarto(content);
3175 assert!(
3178 !warnings.is_empty(),
3179 "Should still require blank when not present: {warnings:?}"
3180 );
3181 }
3182
3183 #[test]
3184 fn test_quarto_list_in_callout_with_content() {
3185 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3187 let warnings = lint_quarto(content);
3188 assert!(
3189 warnings.is_empty(),
3190 "List with proper blanks inside callout should pass: {warnings:?}"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3196 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3198 let warnings = lint(content); assert!(
3201 !warnings.is_empty(),
3202 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3203 );
3204 }
3205
3206 #[test]
3207 fn test_quarto_nested_divs_with_list() {
3208 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3210 let warnings = lint_quarto(content);
3211 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3212 }
3213}