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 ctx.content.is_empty() || ctx.list_blocks.is_empty()
925 }
926
927 fn category(&self) -> RuleCategory {
928 RuleCategory::List
929 }
930
931 fn as_any(&self) -> &dyn std::any::Any {
932 self
933 }
934
935 fn default_config_section(&self) -> Option<(String, toml::Value)> {
936 use crate::rule_config_serde::RuleConfig;
937 let default_config = MD032Config::default();
938 let json_value = serde_json::to_value(&default_config).ok()?;
939 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
940
941 if let toml::Value::Table(table) = toml_value {
942 if !table.is_empty() {
943 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
944 } else {
945 None
946 }
947 } else {
948 None
949 }
950 }
951
952 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
953 where
954 Self: Sized,
955 {
956 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
957 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
958 }
959}
960
961impl MD032BlanksAroundLists {
962 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
964 let lines: Vec<&str> = ctx.content.lines().collect();
965 let num_lines = lines.len();
966 if num_lines == 0 {
967 return Ok(String::new());
968 }
969
970 let list_blocks = self.convert_list_blocks(ctx);
971 if list_blocks.is_empty() {
972 return Ok(ctx.content.to_string());
973 }
974
975 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContInfo> = std::collections::BTreeMap::new();
978 if !self.config.allow_lazy_continuation {
979 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
980 for (line_num, lazy_info) in lazy_lines {
981 let is_within_block = list_blocks
983 .iter()
984 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
985 if !is_within_block {
986 continue;
987 }
988 if !Self::should_apply_lazy_fix(ctx, line_num) {
990 continue;
991 }
992 lazy_fixes.insert(line_num, lazy_info);
993 }
994 }
995
996 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
997
998 for &(start_line, end_line, ref prefix) in &list_blocks {
1000 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
1002 continue;
1003 }
1004
1005 if start_line > 1 {
1007 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1009
1010 if !has_blank_separation && content_line > 0 {
1012 let prev_line_str = lines[content_line - 1];
1013 let is_prev_excluded = ctx
1014 .line_info(content_line)
1015 .is_some_and(|info| info.in_code_block || info.in_front_matter);
1016 let prev_prefix = BLOCKQUOTE_PREFIX_RE
1017 .find(prev_line_str)
1018 .map_or(String::new(), |m| m.as_str().to_string());
1019
1020 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1021 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
1023 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1025 insertions.insert(start_line, bq_prefix);
1026 }
1027 }
1028 }
1029
1030 if end_line < num_lines {
1032 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1034
1035 if !has_blank_separation && content_line > 0 {
1037 let next_line_str = lines[content_line - 1];
1038 let is_next_excluded = ctx
1040 .line_info(content_line)
1041 .is_some_and(|info| info.in_code_block || info.in_front_matter)
1042 || (content_line <= ctx.lines.len()
1043 && ctx.lines[content_line - 1].in_code_block
1044 && ctx.lines[content_line - 1].indent >= 2
1045 && (ctx.lines[content_line - 1]
1046 .content(ctx.content)
1047 .trim()
1048 .starts_with("```")
1049 || ctx.lines[content_line - 1]
1050 .content(ctx.content)
1051 .trim()
1052 .starts_with("~~~")));
1053 let next_prefix = BLOCKQUOTE_PREFIX_RE
1054 .find(next_line_str)
1055 .map_or(String::new(), |m| m.as_str().to_string());
1056
1057 let end_line_str = lines[end_line - 1];
1059 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
1060 .find(end_line_str)
1061 .map_or(String::new(), |m| m.as_str().to_string());
1062 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
1063 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
1064 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1065
1066 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
1069 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
1071 insertions.insert(end_line + 1, bq_prefix);
1072 }
1073 }
1074 }
1075 }
1076
1077 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1079 for (i, line) in lines.iter().enumerate() {
1080 let current_line_num = i + 1;
1081 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1082 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1083 {
1084 result_lines.push(prefix_to_insert.clone());
1085 }
1086
1087 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1089 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1090 result_lines.push(fixed_line);
1091 } else {
1092 result_lines.push(line.to_string());
1093 }
1094 }
1095
1096 let mut result = result_lines.join("\n");
1098 if ctx.content.ends_with('\n') {
1099 result.push('\n');
1100 }
1101 Ok(result)
1102 }
1103}
1104
1105fn is_blank_in_context(line: &str) -> bool {
1107 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1110 line[m.end()..].trim().is_empty()
1112 } else {
1113 line.trim().is_empty()
1115 }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use super::*;
1121 use crate::lint_context::LintContext;
1122 use crate::rule::Rule;
1123
1124 fn lint(content: &str) -> Vec<LintWarning> {
1125 let rule = MD032BlanksAroundLists::default();
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 rule.check(&ctx).expect("Lint check failed")
1128 }
1129
1130 fn fix(content: &str) -> String {
1131 let rule = MD032BlanksAroundLists::default();
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 rule.fix(&ctx).expect("Lint fix failed")
1134 }
1135
1136 fn check_warnings_have_fixes(content: &str) {
1138 let warnings = lint(content);
1139 for warning in &warnings {
1140 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1141 }
1142 }
1143
1144 #[test]
1145 fn test_list_at_start() {
1146 let content = "- Item 1\n- Item 2\nText";
1149 let warnings = lint(content);
1150 assert_eq!(
1151 warnings.len(),
1152 0,
1153 "Trailing text is lazy continuation per CommonMark - no warning expected"
1154 );
1155 }
1156
1157 #[test]
1158 fn test_list_at_end() {
1159 let content = "Text\n- Item 1\n- Item 2";
1160 let warnings = lint(content);
1161 assert_eq!(
1162 warnings.len(),
1163 1,
1164 "Expected 1 warning for list at end without preceding blank line"
1165 );
1166 assert_eq!(
1167 warnings[0].line, 2,
1168 "Warning should be on the first line of the list (line 2)"
1169 );
1170 assert!(warnings[0].message.contains("preceded by blank line"));
1171
1172 check_warnings_have_fixes(content);
1174
1175 let fixed_content = fix(content);
1176 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1177
1178 let warnings_after_fix = lint(&fixed_content);
1180 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1181 }
1182
1183 #[test]
1184 fn test_list_in_middle() {
1185 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1188 let warnings = lint(content);
1189 assert_eq!(
1190 warnings.len(),
1191 1,
1192 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1193 );
1194 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1195 assert!(warnings[0].message.contains("preceded by blank line"));
1196
1197 check_warnings_have_fixes(content);
1199
1200 let fixed_content = fix(content);
1201 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1202
1203 let warnings_after_fix = lint(&fixed_content);
1205 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1206 }
1207
1208 #[test]
1209 fn test_correct_spacing() {
1210 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1211 let warnings = lint(content);
1212 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1213
1214 let fixed_content = fix(content);
1215 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1216 }
1217
1218 #[test]
1219 fn test_list_with_content() {
1220 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1223 let warnings = lint(content);
1224 assert_eq!(
1225 warnings.len(),
1226 1,
1227 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1228 );
1229 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1230 assert!(warnings[0].message.contains("preceded by blank line"));
1231
1232 check_warnings_have_fixes(content);
1234
1235 let fixed_content = fix(content);
1236 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1237 assert_eq!(
1238 fixed_content, expected_fixed,
1239 "Fix did not produce the expected output. Got:\n{fixed_content}"
1240 );
1241
1242 let warnings_after_fix = lint(&fixed_content);
1244 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1245 }
1246
1247 #[test]
1248 fn test_nested_list() {
1249 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1251 let warnings = lint(content);
1252 assert_eq!(
1253 warnings.len(),
1254 1,
1255 "Nested list block needs preceding blank only. Got: {warnings:?}"
1256 );
1257 assert_eq!(warnings[0].line, 2);
1258 assert!(warnings[0].message.contains("preceded by blank line"));
1259
1260 check_warnings_have_fixes(content);
1262
1263 let fixed_content = fix(content);
1264 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1265
1266 let warnings_after_fix = lint(&fixed_content);
1268 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1269 }
1270
1271 #[test]
1272 fn test_list_with_internal_blanks() {
1273 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1275 let warnings = lint(content);
1276 assert_eq!(
1277 warnings.len(),
1278 1,
1279 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1280 );
1281 assert_eq!(warnings[0].line, 2);
1282 assert!(warnings[0].message.contains("preceded by blank line"));
1283
1284 check_warnings_have_fixes(content);
1286
1287 let fixed_content = fix(content);
1288 assert_eq!(
1289 fixed_content,
1290 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1291 );
1292
1293 let warnings_after_fix = lint(&fixed_content);
1295 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1296 }
1297
1298 #[test]
1299 fn test_ignore_code_blocks() {
1300 let content = "```\n- Not a list item\n```\nText";
1301 let warnings = lint(content);
1302 assert_eq!(warnings.len(), 0);
1303 let fixed_content = fix(content);
1304 assert_eq!(fixed_content, content);
1305 }
1306
1307 #[test]
1308 fn test_ignore_front_matter() {
1309 let content = "---\ntitle: Test\n---\n- List Item\nText";
1311 let warnings = lint(content);
1312 assert_eq!(
1313 warnings.len(),
1314 0,
1315 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1316 );
1317
1318 let fixed_content = fix(content);
1320 assert_eq!(fixed_content, content, "No changes when no warnings");
1321 }
1322
1323 #[test]
1324 fn test_multiple_lists() {
1325 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1330 let warnings = lint(content);
1331 assert!(
1333 !warnings.is_empty(),
1334 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1335 );
1336
1337 check_warnings_have_fixes(content);
1339
1340 let fixed_content = fix(content);
1341 let warnings_after_fix = lint(&fixed_content);
1343 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1344 }
1345
1346 #[test]
1347 fn test_adjacent_lists() {
1348 let content = "- List 1\n\n* List 2";
1349 let warnings = lint(content);
1350 assert_eq!(warnings.len(), 0);
1351 let fixed_content = fix(content);
1352 assert_eq!(fixed_content, content);
1353 }
1354
1355 #[test]
1356 fn test_list_in_blockquote() {
1357 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1359 let warnings = lint(content);
1360 assert_eq!(
1361 warnings.len(),
1362 1,
1363 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1364 );
1365 assert_eq!(warnings[0].line, 2);
1366
1367 check_warnings_have_fixes(content);
1369
1370 let fixed_content = fix(content);
1371 assert_eq!(
1373 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1374 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1375 );
1376
1377 let warnings_after_fix = lint(&fixed_content);
1379 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1380 }
1381
1382 #[test]
1383 fn test_ordered_list() {
1384 let content = "Text\n1. Item 1\n2. Item 2\nText";
1386 let warnings = lint(content);
1387 assert_eq!(warnings.len(), 1);
1388
1389 check_warnings_have_fixes(content);
1391
1392 let fixed_content = fix(content);
1393 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1394
1395 let warnings_after_fix = lint(&fixed_content);
1397 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1398 }
1399
1400 #[test]
1401 fn test_no_double_blank_fix() {
1402 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1405 assert_eq!(
1406 warnings.len(),
1407 0,
1408 "Should have no warnings - properly preceded, trailing is lazy"
1409 );
1410
1411 let fixed_content = fix(content);
1412 assert_eq!(
1413 fixed_content, content,
1414 "No fix needed when no warnings. Got:\n{fixed_content}"
1415 );
1416
1417 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1419 assert_eq!(warnings2.len(), 1);
1420 if !warnings2.is_empty() {
1421 assert_eq!(
1422 warnings2[0].line, 2,
1423 "Warning line for missing blank before should be the first line of the block"
1424 );
1425 }
1426
1427 check_warnings_have_fixes(content2);
1429
1430 let fixed_content2 = fix(content2);
1431 assert_eq!(
1432 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1433 "Fix added extra blank before. Got:\n{fixed_content2}"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_empty_input() {
1439 let content = "";
1440 let warnings = lint(content);
1441 assert_eq!(warnings.len(), 0);
1442 let fixed_content = fix(content);
1443 assert_eq!(fixed_content, "");
1444 }
1445
1446 #[test]
1447 fn test_only_list() {
1448 let content = "- Item 1\n- Item 2";
1449 let warnings = lint(content);
1450 assert_eq!(warnings.len(), 0);
1451 let fixed_content = fix(content);
1452 assert_eq!(fixed_content, content);
1453 }
1454
1455 #[test]
1458 fn test_fix_complex_nested_blockquote() {
1459 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1461 let warnings = lint(content);
1462 assert_eq!(
1463 warnings.len(),
1464 1,
1465 "Should warn for missing preceding blank only. Got: {warnings:?}"
1466 );
1467
1468 check_warnings_have_fixes(content);
1470
1471 let fixed_content = fix(content);
1472 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1474 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1475
1476 let warnings_after_fix = lint(&fixed_content);
1477 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1478 }
1479
1480 #[test]
1481 fn test_fix_mixed_list_markers() {
1482 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1485 let warnings = lint(content);
1486 assert!(
1488 !warnings.is_empty(),
1489 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1490 );
1491
1492 check_warnings_have_fixes(content);
1494
1495 let fixed_content = fix(content);
1496 assert!(
1498 fixed_content.contains("Text\n\n-"),
1499 "Fix should add blank line before first list item"
1500 );
1501
1502 let warnings_after_fix = lint(&fixed_content);
1504 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1505 }
1506
1507 #[test]
1508 fn test_fix_ordered_list_with_different_numbers() {
1509 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1511 let warnings = lint(content);
1512 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1513
1514 check_warnings_have_fixes(content);
1516
1517 let fixed_content = fix(content);
1518 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1519 assert_eq!(
1520 fixed_content, expected,
1521 "Fix should handle ordered lists with non-sequential numbers"
1522 );
1523
1524 let warnings_after_fix = lint(&fixed_content);
1526 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1527 }
1528
1529 #[test]
1530 fn test_fix_list_with_code_blocks_inside() {
1531 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1533 let warnings = lint(content);
1534 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1535
1536 check_warnings_have_fixes(content);
1538
1539 let fixed_content = fix(content);
1540 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1541 assert_eq!(
1542 fixed_content, expected,
1543 "Fix should handle lists with internal code blocks"
1544 );
1545
1546 let warnings_after_fix = lint(&fixed_content);
1548 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1549 }
1550
1551 #[test]
1552 fn test_fix_deeply_nested_lists() {
1553 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1555 let warnings = lint(content);
1556 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1557
1558 check_warnings_have_fixes(content);
1560
1561 let fixed_content = fix(content);
1562 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1563 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1564
1565 let warnings_after_fix = lint(&fixed_content);
1567 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1568 }
1569
1570 #[test]
1571 fn test_fix_list_with_multiline_items() {
1572 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1575 let warnings = lint(content);
1576 assert_eq!(
1577 warnings.len(),
1578 1,
1579 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1580 );
1581
1582 check_warnings_have_fixes(content);
1584
1585 let fixed_content = fix(content);
1586 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1587 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1588
1589 let warnings_after_fix = lint(&fixed_content);
1591 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1592 }
1593
1594 #[test]
1595 fn test_fix_list_at_document_boundaries() {
1596 let content1 = "- Item 1\n- Item 2";
1598 let warnings1 = lint(content1);
1599 assert_eq!(
1600 warnings1.len(),
1601 0,
1602 "List at document start should not need blank before"
1603 );
1604 let fixed1 = fix(content1);
1605 assert_eq!(fixed1, content1, "No fix needed for list at start");
1606
1607 let content2 = "Text\n- Item 1\n- Item 2";
1609 let warnings2 = lint(content2);
1610 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1611 check_warnings_have_fixes(content2);
1612 let fixed2 = fix(content2);
1613 assert_eq!(
1614 fixed2, "Text\n\n- Item 1\n- Item 2",
1615 "Should add blank before list at end"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_fix_preserves_existing_blank_lines() {
1621 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1622 let warnings = lint(content);
1623 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1624 let fixed_content = fix(content);
1625 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1626 }
1627
1628 #[test]
1629 fn test_fix_handles_tabs_and_spaces() {
1630 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1633 let warnings = lint(content);
1634 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1636
1637 check_warnings_have_fixes(content);
1639
1640 let fixed_content = fix(content);
1641 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1644 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1645
1646 let warnings_after_fix = lint(&fixed_content);
1648 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1649 }
1650
1651 #[test]
1652 fn test_fix_warning_objects_have_correct_ranges() {
1653 let content = "Text\n- Item 1\n- Item 2\nText";
1655 let warnings = lint(content);
1656 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1657
1658 for warning in &warnings {
1660 assert!(warning.fix.is_some(), "Warning should have fix");
1661 let fix = warning.fix.as_ref().unwrap();
1662 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1663 assert!(
1664 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1665 "Fix should have replacement or be insertion"
1666 );
1667 }
1668 }
1669
1670 #[test]
1671 fn test_fix_idempotent() {
1672 let content = "Text\n- Item 1\n- Item 2\nText";
1674
1675 let fixed_once = fix(content);
1677 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1678
1679 let fixed_twice = fix(&fixed_once);
1681 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1682
1683 let warnings_after_fix = lint(&fixed_once);
1685 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1686 }
1687
1688 #[test]
1689 fn test_fix_with_normalized_line_endings() {
1690 let content = "Text\n- Item 1\n- Item 2\nText";
1694 let warnings = lint(content);
1695 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1696
1697 check_warnings_have_fixes(content);
1699
1700 let fixed_content = fix(content);
1701 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1703 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1704 }
1705
1706 #[test]
1707 fn test_fix_preserves_final_newline() {
1708 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1711 let fixed_with_newline = fix(content_with_newline);
1712 assert!(
1713 fixed_with_newline.ends_with('\n'),
1714 "Fix should preserve final newline when present"
1715 );
1716 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1718
1719 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1721 let fixed_without_newline = fix(content_without_newline);
1722 assert!(
1723 !fixed_without_newline.ends_with('\n'),
1724 "Fix should not add final newline when not present"
1725 );
1726 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1728 }
1729
1730 #[test]
1731 fn test_fix_multiline_list_items_no_indent() {
1732 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";
1733
1734 let warnings = lint(content);
1735 assert_eq!(
1737 warnings.len(),
1738 0,
1739 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1740 );
1741
1742 let fixed_content = fix(content);
1743 assert_eq!(
1745 fixed_content, content,
1746 "Should not modify correctly formatted multi-line list items"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_nested_list_with_lazy_continuation() {
1752 let content = r#"# Test
1758
1759- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1760 1. Switch/case dispatcher statements (original Phase 3.2)
1761 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1762`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1763 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1764 references"#;
1765
1766 let warnings = lint(content);
1767 let md032_warnings: Vec<_> = warnings
1770 .iter()
1771 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1772 .collect();
1773 assert_eq!(
1774 md032_warnings.len(),
1775 0,
1776 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_pipes_in_code_spans_not_detected_as_table() {
1782 let content = r#"# Test
1784
1785- Item with `a | b` inline code
1786 - Nested item should work
1787
1788"#;
1789
1790 let warnings = lint(content);
1791 let md032_warnings: Vec<_> = warnings
1792 .iter()
1793 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1794 .collect();
1795 assert_eq!(
1796 md032_warnings.len(),
1797 0,
1798 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_multiple_code_spans_with_pipes() {
1804 let content = r#"# Test
1806
1807- Item with `a | b` and `c || d` operators
1808 - Nested item should work
1809
1810"#;
1811
1812 let warnings = lint(content);
1813 let md032_warnings: Vec<_> = warnings
1814 .iter()
1815 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1816 .collect();
1817 assert_eq!(
1818 md032_warnings.len(),
1819 0,
1820 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_actual_table_breaks_list() {
1826 let content = r#"# Test
1828
1829- Item before table
1830
1831| Col1 | Col2 |
1832|------|------|
1833| A | B |
1834
1835- Item after table
1836
1837"#;
1838
1839 let warnings = lint(content);
1840 let md032_warnings: Vec<_> = warnings
1842 .iter()
1843 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1844 .collect();
1845 assert_eq!(
1846 md032_warnings.len(),
1847 0,
1848 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_thematic_break_not_lazy_continuation() {
1854 let content = r#"- Item 1
1857- Item 2
1858***
1859
1860More text.
1861"#;
1862
1863 let warnings = lint(content);
1864 let md032_warnings: Vec<_> = warnings
1865 .iter()
1866 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1867 .collect();
1868 assert_eq!(
1869 md032_warnings.len(),
1870 1,
1871 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1872 );
1873 assert!(
1874 md032_warnings[0].message.contains("followed by blank line"),
1875 "Warning should be about missing blank after list"
1876 );
1877 }
1878
1879 #[test]
1880 fn test_thematic_break_with_blank_line() {
1881 let content = r#"- Item 1
1883- Item 2
1884
1885***
1886
1887More text.
1888"#;
1889
1890 let warnings = lint(content);
1891 let md032_warnings: Vec<_> = warnings
1892 .iter()
1893 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1894 .collect();
1895 assert_eq!(
1896 md032_warnings.len(),
1897 0,
1898 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_various_thematic_break_styles() {
1904 for hr in ["---", "***", "___"] {
1909 let content = format!(
1910 r#"- Item 1
1911- Item 2
1912{hr}
1913
1914More text.
1915"#
1916 );
1917
1918 let warnings = lint(&content);
1919 let md032_warnings: Vec<_> = warnings
1920 .iter()
1921 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1922 .collect();
1923 assert_eq!(
1924 md032_warnings.len(),
1925 1,
1926 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1927 );
1928 }
1929 }
1930
1931 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1934 let rule = MD032BlanksAroundLists::from_config_struct(config);
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 rule.check(&ctx).expect("Lint check failed")
1937 }
1938
1939 fn fix_with_config(content: &str, config: MD032Config) -> String {
1940 let rule = MD032BlanksAroundLists::from_config_struct(config);
1941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1942 rule.fix(&ctx).expect("Lint fix failed")
1943 }
1944
1945 #[test]
1946 fn test_lazy_continuation_allowed_by_default() {
1947 let content = "# Heading\n\n1. List\nSome text.";
1949 let warnings = lint(content);
1950 assert_eq!(
1951 warnings.len(),
1952 0,
1953 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_lazy_continuation_disallowed() {
1959 let content = "# Heading\n\n1. List\nSome text.";
1961 let config = MD032Config {
1962 allow_lazy_continuation: false,
1963 };
1964 let warnings = lint_with_config(content, config);
1965 assert_eq!(
1966 warnings.len(),
1967 1,
1968 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1969 );
1970 assert!(
1971 warnings[0].message.contains("Lazy continuation"),
1972 "Warning message should mention lazy continuation"
1973 );
1974 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1975 }
1976
1977 #[test]
1978 fn test_lazy_continuation_fix() {
1979 let content = "# Heading\n\n1. List\nSome text.";
1981 let config = MD032Config {
1982 allow_lazy_continuation: false,
1983 };
1984 let fixed = fix_with_config(content, config.clone());
1985 assert_eq!(
1987 fixed, "# Heading\n\n1. List\n Some text.",
1988 "Fix should add proper indentation to lazy continuation"
1989 );
1990
1991 let warnings_after = lint_with_config(&fixed, config);
1993 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1994 }
1995
1996 #[test]
1997 fn test_lazy_continuation_multiple_lines() {
1998 let content = "- Item 1\nLine 2\nLine 3";
2000 let config = MD032Config {
2001 allow_lazy_continuation: false,
2002 };
2003 let warnings = lint_with_config(content, config.clone());
2004 assert_eq!(
2006 warnings.len(),
2007 2,
2008 "Should warn for each lazy continuation line. Got: {warnings:?}"
2009 );
2010
2011 let fixed = fix_with_config(content, config.clone());
2012 assert_eq!(
2014 fixed, "- Item 1\n Line 2\n Line 3",
2015 "Fix should add proper indentation to lazy continuation lines"
2016 );
2017
2018 let warnings_after = lint_with_config(&fixed, config);
2020 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2021 }
2022
2023 #[test]
2024 fn test_lazy_continuation_with_indented_content() {
2025 let content = "- Item 1\n Indented content\nLazy text";
2027 let config = MD032Config {
2028 allow_lazy_continuation: false,
2029 };
2030 let warnings = lint_with_config(content, config);
2031 assert_eq!(
2032 warnings.len(),
2033 1,
2034 "Should warn for lazy text after indented content. Got: {warnings:?}"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_lazy_continuation_properly_separated() {
2040 let content = "- Item 1\n\nSome text.";
2042 let config = MD032Config {
2043 allow_lazy_continuation: false,
2044 };
2045 let warnings = lint_with_config(content, config);
2046 assert_eq!(
2047 warnings.len(),
2048 0,
2049 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2050 );
2051 }
2052
2053 #[test]
2056 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2057 let content = "1) First item\nLazy continuation";
2059 let config = MD032Config {
2060 allow_lazy_continuation: false,
2061 };
2062 let warnings = lint_with_config(content, config.clone());
2063 assert_eq!(
2064 warnings.len(),
2065 1,
2066 "Should warn for lazy continuation with parenthesis marker"
2067 );
2068
2069 let fixed = fix_with_config(content, config);
2070 assert_eq!(fixed, "1) First item\n Lazy continuation");
2072 }
2073
2074 #[test]
2075 fn test_lazy_continuation_followed_by_another_list() {
2076 let content = "- Item 1\nSome text\n- Item 2";
2082 let config = MD032Config {
2083 allow_lazy_continuation: false,
2084 };
2085 let warnings = lint_with_config(content, config);
2086 assert_eq!(
2088 warnings.len(),
2089 1,
2090 "Should warn about lazy continuation within list. Got: {warnings:?}"
2091 );
2092 assert!(
2093 warnings[0].message.contains("Lazy continuation"),
2094 "Warning should be about lazy continuation"
2095 );
2096 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2097 }
2098
2099 #[test]
2100 fn test_lazy_continuation_multiple_in_document() {
2101 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2106 let config = MD032Config {
2107 allow_lazy_continuation: false,
2108 };
2109 let warnings = lint_with_config(content, config.clone());
2110 assert_eq!(
2112 warnings.len(),
2113 2,
2114 "Should warn for both lazy continuations. Got: {warnings:?}"
2115 );
2116
2117 let fixed = fix_with_config(content, config.clone());
2118 assert!(
2120 fixed.contains(" Lazy 1"),
2121 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2122 );
2123 assert!(
2124 fixed.contains(" Lazy 2"),
2125 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2126 );
2127
2128 let warnings_after = lint_with_config(&fixed, config);
2129 assert_eq!(
2131 warnings_after.len(),
2132 0,
2133 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2134 );
2135 }
2136
2137 #[test]
2138 fn test_lazy_continuation_end_of_document_no_newline() {
2139 let content = "- Item\nNo trailing newline";
2141 let config = MD032Config {
2142 allow_lazy_continuation: false,
2143 };
2144 let warnings = lint_with_config(content, config.clone());
2145 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2146
2147 let fixed = fix_with_config(content, config);
2148 assert_eq!(fixed, "- Item\n No trailing newline");
2150 }
2151
2152 #[test]
2153 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2154 let content = "- Item 1\n---";
2157 let config = MD032Config {
2158 allow_lazy_continuation: false,
2159 };
2160 let warnings = lint_with_config(content, config.clone());
2161 assert_eq!(
2163 warnings.len(),
2164 1,
2165 "List should need blank line before thematic break. Got: {warnings:?}"
2166 );
2167
2168 let fixed = fix_with_config(content, config);
2170 assert_eq!(fixed, "- Item 1\n\n---");
2171 }
2172
2173 #[test]
2174 fn test_lazy_continuation_heading_not_flagged() {
2175 let content = "- Item 1\n# Heading";
2178 let config = MD032Config {
2179 allow_lazy_continuation: false,
2180 };
2181 let warnings = lint_with_config(content, config);
2182 assert!(
2185 warnings.iter().all(|w| !w.message.contains("lazy")),
2186 "Heading should not trigger lazy continuation warning"
2187 );
2188 }
2189
2190 #[test]
2191 fn test_lazy_continuation_mixed_list_types() {
2192 let content = "- Unordered\n1. Ordered\nLazy text";
2194 let config = MD032Config {
2195 allow_lazy_continuation: false,
2196 };
2197 let warnings = lint_with_config(content, config.clone());
2198 assert!(!warnings.is_empty(), "Should warn about structure issues");
2199 }
2200
2201 #[test]
2202 fn test_lazy_continuation_deep_nesting() {
2203 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2205 let config = MD032Config {
2206 allow_lazy_continuation: false,
2207 };
2208 let warnings = lint_with_config(content, config.clone());
2209 assert!(
2210 !warnings.is_empty(),
2211 "Should warn about lazy continuation after nested list"
2212 );
2213
2214 let fixed = fix_with_config(content, config.clone());
2215 let warnings_after = lint_with_config(&fixed, config);
2216 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2217 }
2218
2219 #[test]
2220 fn test_lazy_continuation_with_emphasis_in_text() {
2221 let content = "- Item\n*emphasized* continuation";
2223 let config = MD032Config {
2224 allow_lazy_continuation: false,
2225 };
2226 let warnings = lint_with_config(content, config.clone());
2227 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2228
2229 let fixed = fix_with_config(content, config);
2230 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2232 }
2233
2234 #[test]
2235 fn test_lazy_continuation_with_code_span() {
2236 let content = "- Item\n`code` continuation";
2238 let config = MD032Config {
2239 allow_lazy_continuation: false,
2240 };
2241 let warnings = lint_with_config(content, config.clone());
2242 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2243
2244 let fixed = fix_with_config(content, config);
2245 assert_eq!(fixed, "- Item\n `code` continuation");
2247 }
2248
2249 #[test]
2256 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2257 let content = r#"1. Create a new Chat conversation:
2260 - On the sidebar, select **New Chat**.
2261 - In the box, type `/new`.
2262 A new Chat conversation replaces the previous one.
22631. Under the Chat text box, turn off the toggle."#;
2264 let config = MD032Config {
2265 allow_lazy_continuation: false,
2266 };
2267 let warnings = lint_with_config(content, config);
2268 let lazy_warnings: Vec<_> = warnings
2270 .iter()
2271 .filter(|w| w.message.contains("Lazy continuation"))
2272 .collect();
2273 assert!(
2274 !lazy_warnings.is_empty(),
2275 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2276 );
2277 assert!(
2278 lazy_warnings.iter().any(|w| w.line == 4),
2279 "Should warn on line 4. Got: {lazy_warnings:?}"
2280 );
2281 }
2282
2283 #[test]
2284 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2285 let content = r#"- `field`: Is the specific key:
2288 - `password`: Accesses the password.
2289 - `api_key`: Accesses the api_key.
2290 `token`: Specifies which ID token to use.
2291- `version_id`: Is the unique identifier."#;
2292 let config = MD032Config {
2293 allow_lazy_continuation: false,
2294 };
2295 let warnings = lint_with_config(content, config);
2296 let lazy_warnings: Vec<_> = warnings
2298 .iter()
2299 .filter(|w| w.message.contains("Lazy continuation"))
2300 .collect();
2301 assert!(
2302 !lazy_warnings.is_empty(),
2303 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2304 );
2305 assert!(
2306 lazy_warnings.iter().any(|w| w.line == 4),
2307 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2313 let content = r#"- Check out the branch, and test locally.
2315 - If the MR requires significant modifications:
2316 - **Skip local testing** and review instead.
2317 - **Request verification** from the author.
2318 - **Identify the minimal change** needed.
2319 Your testing might result in opportunities.
2320- If you don't understand, _say so_."#;
2321 let config = MD032Config {
2322 allow_lazy_continuation: false,
2323 };
2324 let warnings = lint_with_config(content, config);
2325 let lazy_warnings: Vec<_> = warnings
2327 .iter()
2328 .filter(|w| w.message.contains("Lazy continuation"))
2329 .collect();
2330 assert!(
2331 !lazy_warnings.is_empty(),
2332 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2333 );
2334 assert!(
2335 lazy_warnings.iter().any(|w| w.line == 6),
2336 "Should warn on line 6. Got: {lazy_warnings:?}"
2337 );
2338 }
2339
2340 #[test]
2341 fn test_issue295_ordered_list_nested_bullets_continuation() {
2342 let content = r#"# Test
2345
23461. First item.
2347 - Nested A.
2348 - Nested B.
2349 Continuation at outer level.
23501. Second item."#;
2351 let config = MD032Config {
2352 allow_lazy_continuation: false,
2353 };
2354 let warnings = lint_with_config(content, config);
2355 let lazy_warnings: Vec<_> = warnings
2357 .iter()
2358 .filter(|w| w.message.contains("Lazy continuation"))
2359 .collect();
2360 assert!(
2361 !lazy_warnings.is_empty(),
2362 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2363 );
2364 assert!(
2366 lazy_warnings.iter().any(|w| w.line == 6),
2367 "Should warn on line 6. Got: {lazy_warnings:?}"
2368 );
2369 }
2370
2371 #[test]
2372 fn test_issue295_multiple_lazy_lines_after_nested() {
2373 let content = r#"1. The device client receives a response.
2375 - Those defined by OAuth Framework.
2376 - Those specific to device authorization.
2377 Those error responses are described below.
2378 For more information on each response,
2379 see the documentation.
23801. Next step in the process."#;
2381 let config = MD032Config {
2382 allow_lazy_continuation: false,
2383 };
2384 let warnings = lint_with_config(content, config);
2385 let lazy_warnings: Vec<_> = warnings
2387 .iter()
2388 .filter(|w| w.message.contains("Lazy continuation"))
2389 .collect();
2390 assert!(
2391 lazy_warnings.len() >= 3,
2392 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2393 lazy_warnings.len()
2394 );
2395 }
2396
2397 #[test]
2398 fn test_issue295_properly_indented_not_lazy() {
2399 let content = r#"1. First item.
2401 - Nested A.
2402 - Nested B.
2403
2404 Properly indented continuation.
24051. Second item."#;
2406 let config = MD032Config {
2407 allow_lazy_continuation: false,
2408 };
2409 let warnings = lint_with_config(content, config);
2410 let lazy_warnings: Vec<_> = warnings
2412 .iter()
2413 .filter(|w| w.message.contains("Lazy continuation"))
2414 .collect();
2415 assert_eq!(
2416 lazy_warnings.len(),
2417 0,
2418 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2419 );
2420 }
2421
2422 #[test]
2429 fn test_html_comment_before_list_with_preceding_blank() {
2430 let content = "Some text.\n\n<!-- comment -->\n- List item";
2433 let warnings = lint(content);
2434 assert_eq!(
2435 warnings.len(),
2436 0,
2437 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2438 );
2439 }
2440
2441 #[test]
2442 fn test_html_comment_after_list_with_following_blank() {
2443 let content = "- List item\n<!-- comment -->\n\nSome text.";
2445 let warnings = lint(content);
2446 assert_eq!(
2447 warnings.len(),
2448 0,
2449 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2450 );
2451 }
2452
2453 #[test]
2454 fn test_list_inside_html_comment_ignored() {
2455 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2457 let warnings = lint(content);
2458 assert_eq!(
2459 warnings.len(),
2460 0,
2461 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2462 );
2463 }
2464
2465 #[test]
2466 fn test_multiline_html_comment_before_list() {
2467 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2469 let warnings = lint(content);
2470 assert_eq!(
2471 warnings.len(),
2472 0,
2473 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2474 );
2475 }
2476
2477 #[test]
2478 fn test_no_blank_before_html_comment_still_warns() {
2479 let content = "Some text.\n<!-- comment -->\n- List item";
2481 let warnings = lint(content);
2482 assert_eq!(
2483 warnings.len(),
2484 1,
2485 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2486 );
2487 assert!(
2488 warnings[0].message.contains("preceded by blank line"),
2489 "Should be 'preceded by blank line' warning"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2495 let content = "- List item\n<!-- comment -->\nSome text.";
2498 let warnings = lint(content);
2499 assert_eq!(
2500 warnings.len(),
2501 0,
2502 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2503 );
2504 }
2505
2506 #[test]
2507 fn test_list_followed_by_heading_through_comment_should_warn() {
2508 let content = "- List item\n<!-- comment -->\n# Heading";
2510 let warnings = lint(content);
2511 assert!(
2514 warnings.len() <= 1,
2515 "Should handle heading after comment gracefully. Got: {warnings:?}"
2516 );
2517 }
2518
2519 #[test]
2520 fn test_html_comment_between_list_and_text_both_directions() {
2521 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2523 let warnings = lint(content);
2524 assert_eq!(
2525 warnings.len(),
2526 0,
2527 "Should not warn with proper separation through comments. Got: {warnings:?}"
2528 );
2529 }
2530
2531 #[test]
2532 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2533 let content = "Text.\n\n<!-- comment -->\n- Item";
2535 let fixed = fix(content);
2536 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2537 }
2538
2539 #[test]
2540 fn test_html_comment_fix_adds_blank_when_needed() {
2541 let content = "Text.\n<!-- comment -->\n- Item";
2544 let fixed = fix(content);
2545 assert!(
2546 fixed.contains("<!-- comment -->\n\n- Item"),
2547 "Fix should add blank line before list. Got: {fixed}"
2548 );
2549 }
2550
2551 #[test]
2552 fn test_ordered_list_inside_html_comment() {
2553 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2555 let warnings = lint(content);
2556 assert_eq!(
2557 warnings.len(),
2558 0,
2559 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2560 );
2561 }
2562
2563 #[test]
2570 fn test_blockquote_list_exit_no_warning() {
2571 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2573 let warnings = lint(content);
2574 assert_eq!(
2575 warnings.len(),
2576 0,
2577 "Should not warn when exiting blockquote. Got: {warnings:?}"
2578 );
2579 }
2580
2581 #[test]
2582 fn test_nested_blockquote_list_exit() {
2583 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2585 let warnings = lint(content);
2586 assert_eq!(
2587 warnings.len(),
2588 0,
2589 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_blockquote_same_level_no_warning() {
2595 let content = "> - item 1\n> - item 2\n> Text after";
2598 let warnings = lint(content);
2599 assert_eq!(
2600 warnings.len(),
2601 0,
2602 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2603 );
2604 }
2605
2606 #[test]
2607 fn test_blockquote_list_with_special_chars() {
2608 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2610 let warnings = lint(content);
2611 assert_eq!(
2612 warnings.len(),
2613 0,
2614 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2615 );
2616 }
2617
2618 #[test]
2619 fn test_lazy_continuation_whitespace_only_line() {
2620 let content = "- Item\n \nText after whitespace-only line";
2623 let config = MD032Config {
2624 allow_lazy_continuation: false,
2625 };
2626 let warnings = lint_with_config(content, config);
2627 assert_eq!(
2629 warnings.len(),
2630 0,
2631 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2632 );
2633 }
2634
2635 #[test]
2636 fn test_lazy_continuation_blockquote_context() {
2637 let content = "> - Item\n> Lazy in quote";
2639 let config = MD032Config {
2640 allow_lazy_continuation: false,
2641 };
2642 let warnings = lint_with_config(content, config);
2643 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2646 }
2647
2648 #[test]
2649 fn test_lazy_continuation_fix_preserves_content() {
2650 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2652 let config = MD032Config {
2653 allow_lazy_continuation: false,
2654 };
2655 let fixed = fix_with_config(content, config);
2656 assert!(fixed.contains("<>&"), "Should preserve special chars");
2657 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2658 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2660 }
2661
2662 #[test]
2663 fn test_lazy_continuation_fix_idempotent() {
2664 let content = "- Item\nLazy";
2666 let config = MD032Config {
2667 allow_lazy_continuation: false,
2668 };
2669 let fixed_once = fix_with_config(content, config.clone());
2670 let fixed_twice = fix_with_config(&fixed_once, config);
2671 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2672 }
2673
2674 #[test]
2675 fn test_lazy_continuation_config_default_allows() {
2676 let content = "- Item\nLazy text that continues";
2678 let default_config = MD032Config::default();
2679 assert!(
2680 default_config.allow_lazy_continuation,
2681 "Default should allow lazy continuation"
2682 );
2683 let warnings = lint_with_config(content, default_config);
2684 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2685 }
2686
2687 #[test]
2688 fn test_lazy_continuation_after_multi_line_item() {
2689 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2691 let config = MD032Config {
2692 allow_lazy_continuation: false,
2693 };
2694 let warnings = lint_with_config(content, config.clone());
2695 assert_eq!(
2696 warnings.len(),
2697 1,
2698 "Should warn only for the lazy line, not the indented line"
2699 );
2700 }
2701
2702 #[test]
2704 fn test_blockquote_list_with_continuation_and_nested() {
2705 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2708 let warnings = lint(content);
2709 assert_eq!(
2710 warnings.len(),
2711 0,
2712 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_blockquote_list_simple() {
2718 let content = "> - item 1\n> - item 2";
2720 let warnings = lint(content);
2721 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2722 }
2723
2724 #[test]
2725 fn test_blockquote_list_with_continuation_only() {
2726 let content = "> - item 1\n> continuation\n> - item 2";
2728 let warnings = lint(content);
2729 assert_eq!(
2730 warnings.len(),
2731 0,
2732 "Blockquoted list with continuation should have no warnings"
2733 );
2734 }
2735
2736 #[test]
2737 fn test_blockquote_list_with_lazy_continuation() {
2738 let content = "> - item 1\n> lazy continuation\n> - item 2";
2740 let warnings = lint(content);
2741 assert_eq!(
2742 warnings.len(),
2743 0,
2744 "Blockquoted list with lazy continuation should have no warnings"
2745 );
2746 }
2747
2748 #[test]
2749 fn test_nested_blockquote_list() {
2750 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2752 let warnings = lint(content);
2753 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2754 }
2755
2756 #[test]
2757 fn test_blockquote_list_needs_preceding_blank() {
2758 let content = "> Text before\n> - item 1\n> - item 2";
2760 let warnings = lint(content);
2761 assert_eq!(
2762 warnings.len(),
2763 1,
2764 "Should warn for missing blank before blockquoted list"
2765 );
2766 }
2767
2768 #[test]
2769 fn test_blockquote_list_properly_separated() {
2770 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2772 let warnings = lint(content);
2773 assert_eq!(
2774 warnings.len(),
2775 0,
2776 "Properly separated blockquoted list should have no warnings"
2777 );
2778 }
2779
2780 #[test]
2781 fn test_blockquote_ordered_list() {
2782 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2784 let warnings = lint(content);
2785 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2786 }
2787
2788 #[test]
2789 fn test_blockquote_list_with_empty_blockquote_line() {
2790 let content = "> - item 1\n>\n> - item 2";
2792 let warnings = lint(content);
2793 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2794 }
2795
2796 #[test]
2798 fn test_blockquote_list_multi_paragraph_items() {
2799 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2802 let warnings = lint(content);
2803 assert_eq!(
2804 warnings.len(),
2805 0,
2806 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2807 );
2808 }
2809
2810 #[test]
2812 fn test_blockquote_ordered_list_multi_paragraph_items() {
2813 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2814 let warnings = lint(content);
2815 assert_eq!(
2816 warnings.len(),
2817 0,
2818 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2819 );
2820 }
2821
2822 #[test]
2824 fn test_blockquote_list_multiple_continuations() {
2825 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2826 let warnings = lint(content);
2827 assert_eq!(
2828 warnings.len(),
2829 0,
2830 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2831 );
2832 }
2833
2834 #[test]
2836 fn test_nested_blockquote_multi_paragraph_list() {
2837 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2838 let warnings = lint(content);
2839 assert_eq!(
2840 warnings.len(),
2841 0,
2842 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2843 );
2844 }
2845
2846 #[test]
2848 fn test_triple_nested_blockquote_multi_paragraph_list() {
2849 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2850 let warnings = lint(content);
2851 assert_eq!(
2852 warnings.len(),
2853 0,
2854 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2855 );
2856 }
2857
2858 #[test]
2860 fn test_blockquote_list_last_item_continuation() {
2861 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2862 let warnings = lint(content);
2863 assert_eq!(
2864 warnings.len(),
2865 0,
2866 "Last item with continuation should have no warnings. Got: {warnings:?}"
2867 );
2868 }
2869
2870 #[test]
2872 fn test_blockquote_list_first_item_only_continuation() {
2873 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2874 let warnings = lint(content);
2875 assert_eq!(
2876 warnings.len(),
2877 0,
2878 "Single item with continuation should have no warnings. Got: {warnings:?}"
2879 );
2880 }
2881
2882 #[test]
2886 fn test_blockquote_level_change_breaks_list() {
2887 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2889 let warnings = lint(content);
2890 assert!(
2894 warnings.len() <= 2,
2895 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2896 );
2897 }
2898
2899 #[test]
2901 fn test_exit_blockquote_needs_blank_before_list() {
2902 let content = "> Blockquote text\n\n- List outside blockquote\n";
2904 let warnings = lint(content);
2905 assert_eq!(
2906 warnings.len(),
2907 0,
2908 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2909 );
2910
2911 let content2 = "> Blockquote text\n- List outside blockquote\n";
2915 let warnings2 = lint(content2);
2916 assert!(
2918 warnings2.len() <= 1,
2919 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2920 );
2921 }
2922
2923 #[test]
2925 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2926 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2928 let warnings = lint(content_dash);
2929 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2930
2931 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2933 let warnings = lint(content_asterisk);
2934 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2935
2936 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2938 let warnings = lint(content_plus);
2939 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2940 }
2941
2942 #[test]
2944 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2945 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2946 let warnings = lint(content);
2947 assert_eq!(
2948 warnings.len(),
2949 0,
2950 "Parenthesis ordered markers should work. Got: {warnings:?}"
2951 );
2952 }
2953
2954 #[test]
2956 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2957 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2959 let warnings = lint(content);
2960 assert_eq!(
2961 warnings.len(),
2962 0,
2963 "Multi-digit ordered list should work. Got: {warnings:?}"
2964 );
2965 }
2966
2967 #[test]
2969 fn test_blockquote_multi_paragraph_with_formatting() {
2970 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2971 let warnings = lint(content);
2972 assert_eq!(
2973 warnings.len(),
2974 0,
2975 "Continuation with inline formatting should work. Got: {warnings:?}"
2976 );
2977 }
2978
2979 #[test]
2981 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2982 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2983 let warnings = lint(content);
2984 assert_eq!(
2985 warnings.len(),
2986 0,
2987 "All items with continuations should work. Got: {warnings:?}"
2988 );
2989 }
2990
2991 #[test]
2993 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2994 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2995 let warnings = lint(content);
2996 assert_eq!(
2997 warnings.len(),
2998 0,
2999 "Lowercase continuation should work. Got: {warnings:?}"
3000 );
3001 }
3002
3003 #[test]
3005 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3006 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3007 let warnings = lint(content);
3008 assert_eq!(
3009 warnings.len(),
3010 0,
3011 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3012 );
3013 }
3014
3015 #[test]
3017 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3018 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3020 let warnings = lint(content);
3021 assert!(
3023 warnings.len() <= 1,
3024 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3025 );
3026 }
3027
3028 #[test]
3030 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3031 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3033 let warnings = lint(content);
3034 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3035 }
3036
3037 #[test]
3038 fn test_blockquote_list_varying_spaces_after_marker() {
3039 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3041 let warnings = lint(content);
3042 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3043 }
3044
3045 #[test]
3046 fn test_deeply_nested_blockquote_list() {
3047 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3049 let warnings = lint(content);
3050 assert_eq!(
3051 warnings.len(),
3052 0,
3053 "Deeply nested blockquote list should have no warnings"
3054 );
3055 }
3056
3057 #[test]
3058 fn test_blockquote_level_change_in_list() {
3059 let content = "> - item 1\n>> - deeper item\n> - item 2";
3061 let warnings = lint(content);
3064 assert!(
3065 !warnings.is_empty(),
3066 "Blockquote level change should break list and trigger warnings"
3067 );
3068 }
3069
3070 #[test]
3071 fn test_blockquote_list_with_code_span() {
3072 let content = "> - item with `code`\n> continuation\n> - item 2";
3074 let warnings = lint(content);
3075 assert_eq!(
3076 warnings.len(),
3077 0,
3078 "Blockquote list with code span should have no warnings"
3079 );
3080 }
3081
3082 #[test]
3083 fn test_blockquote_list_at_document_end() {
3084 let content = "> Some text\n>\n> - item 1\n> - item 2";
3086 let warnings = lint(content);
3087 assert_eq!(
3088 warnings.len(),
3089 0,
3090 "Blockquote list at document end should have no warnings"
3091 );
3092 }
3093
3094 #[test]
3095 fn test_fix_preserves_blockquote_prefix_before_list() {
3096 let content = "> Text before
3098> - Item 1
3099> - Item 2";
3100 let fixed = fix(content);
3101
3102 let expected = "> Text before
3104>
3105> - Item 1
3106> - Item 2";
3107 assert_eq!(
3108 fixed, expected,
3109 "Fix should insert '>' blank line, not plain blank line"
3110 );
3111 }
3112
3113 #[test]
3114 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3115 let content = ">>> Triple nested
3118>>> - Item 1
3119>>> - Item 2
3120>>> More text";
3121 let fixed = fix(content);
3122
3123 let expected = ">>> Triple nested
3125>>>
3126>>> - Item 1
3127>>> - Item 2
3128>>> More text";
3129 assert_eq!(
3130 fixed, expected,
3131 "Fix should preserve triple-nested blockquote prefix '>>>'"
3132 );
3133 }
3134
3135 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3138 let rule = MD032BlanksAroundLists::default();
3139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3140 rule.check(&ctx).unwrap()
3141 }
3142
3143 #[test]
3144 fn test_quarto_list_after_div_open() {
3145 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3147 let warnings = lint_quarto(content);
3148 assert!(
3150 warnings.is_empty(),
3151 "Quarto div marker should be transparent before list: {warnings:?}"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_quarto_list_before_div_close() {
3157 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3159 let warnings = lint_quarto(content);
3160 assert!(
3162 warnings.is_empty(),
3163 "Quarto div marker should be transparent after list: {warnings:?}"
3164 );
3165 }
3166
3167 #[test]
3168 fn test_quarto_list_needs_blank_without_div() {
3169 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3171 let warnings = lint_quarto(content);
3172 assert!(
3175 !warnings.is_empty(),
3176 "Should still require blank when not present: {warnings:?}"
3177 );
3178 }
3179
3180 #[test]
3181 fn test_quarto_list_in_callout_with_content() {
3182 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3184 let warnings = lint_quarto(content);
3185 assert!(
3186 warnings.is_empty(),
3187 "List with proper blanks inside callout should pass: {warnings:?}"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3193 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3195 let warnings = lint(content); assert!(
3198 !warnings.is_empty(),
3199 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3200 );
3201 }
3202
3203 #[test]
3204 fn test_quarto_nested_divs_with_list() {
3205 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3207 let warnings = lint_quarto(content);
3208 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3209 }
3210}