1use crate::lint_context::LazyContLine;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote, parse_blockquote_prefix};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::list_fix_guard::{Allowed, drop_structure_changing_fixes, protect_literal_code};
6use crate::utils::pandoc;
7use crate::utils::range_utils::calculate_line_range;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17fn is_thematic_break(line: &str) -> bool {
20 if calculate_indentation_width_default(line) > 3 {
22 return false;
23 }
24
25 let trimmed = line.trim();
26 if trimmed.len() < 3 {
27 return false;
28 }
29
30 let chars: Vec<char> = trimmed.chars().collect();
31 let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33 if let Some(&marker) = first_non_space {
34 if marker != '-' && marker != '*' && marker != '_' {
35 return false;
36 }
37 let marker_count = chars.iter().filter(|&&c| c == marker).count();
38 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39 marker_count >= 3 && other_count == 0
40 } else {
41 false
42 }
43}
44
45#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116 config: MD032Config,
117}
118
119struct ListCoverage {
125 starts: Vec<usize>,
126 reach: Vec<usize>,
127}
128
129impl ListCoverage {
130 fn new(list_blocks: &[(usize, usize, String)]) -> Self {
131 let mut spans: Vec<(usize, usize)> = list_blocks.iter().map(|&(start, end, _)| (start, end)).collect();
132 spans.sort_unstable();
133 let mut furthest = 0;
134 let (starts, reach) = spans
135 .into_iter()
136 .map(|(start, end)| {
137 furthest = furthest.max(end);
138 (start, furthest)
139 })
140 .unzip();
141 Self { starts, reach }
142 }
143
144 fn contains(&self, line_num: usize) -> bool {
145 let before = self.starts.partition_point(|&start| start <= line_num);
146 before > 0 && self.reach[before - 1] >= line_num
147 }
148}
149
150impl MD032BlanksAroundLists {
151 pub fn from_config_struct(config: MD032Config) -> Self {
152 Self { config }
153 }
154}
155
156impl MD032BlanksAroundLists {
157 fn should_require_blank_line_before(
159 ctx: &crate::lint_context::LintContext,
160 prev_line_num: usize,
161 current_line_num: usize,
162 ) -> bool {
163 if ctx
165 .line_info(prev_line_num)
166 .is_some_and(|info| info.in_code_block || info.in_front_matter)
167 {
168 return true;
169 }
170
171 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
173 return false;
174 }
175
176 true
178 }
179
180 fn is_nested_list(
182 ctx: &crate::lint_context::LintContext,
183 prev_line_num: usize, current_line_num: usize, ) -> bool {
186 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
188 let current_line = &ctx.lines[current_line_num - 1];
189 if current_line.indent >= 2 {
190 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
192 let prev_line = &ctx.lines[prev_line_num - 1];
193 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
195 return true;
196 }
197 }
198 }
199 }
200 false
201 }
202
203 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
206 ctx.lines
207 .get(line_num.saturating_sub(1))
208 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
209 }
210
211 fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
217 if !ctx.flavor.is_pandoc_compatible() {
218 return false;
219 }
220 let trimmed = info.content(ctx.content).trim();
221 pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
222 }
223
224 fn is_reportable_lazy_line(
227 ctx: &crate::lint_context::LintContext,
228 coverage: &ListCoverage,
229 line_num: usize,
230 ) -> bool {
231 if !coverage.contains(line_num) {
232 return false;
233 }
234 ctx.lines
235 .get(line_num.saturating_sub(1))
236 .is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
237 }
238
239 fn calculate_lazy_continuation_fix(
242 ctx: &crate::lint_context::LintContext,
243 line_num: usize,
244 lazy_info: &LazyContLine,
245 ) -> Option<Fix> {
246 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
247 let line_content = line_info.content(ctx.content);
248
249 if lazy_info.blockquote_level == 0 {
250 let start_byte = line_info.byte_offset;
252 let end_byte = start_byte + lazy_info.current_indent;
253 let replacement = " ".repeat(lazy_info.expected_indent);
254
255 Some(Fix::new(start_byte..end_byte, replacement))
256 } else {
257 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
259 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
260 if prefix_byte_len == 0 {
261 return None;
262 }
263
264 let current_indent = after_bq.len() - after_bq.trim_start().len();
265 let start_byte = line_info.byte_offset + prefix_byte_len;
266 let end_byte = start_byte + current_indent;
267 let replacement = " ".repeat(lazy_info.expected_indent);
268
269 Some(Fix::new(start_byte..end_byte, replacement))
270 }
271 }
272
273 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
276 if lazy_info.blockquote_level == 0 {
277 let content = line.trim_start();
279 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
280 } else {
281 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
283 let prefix_len = line.len().saturating_sub(after_bq.len());
284 if prefix_len == 0 {
285 return line.to_string();
286 }
287
288 let prefix = &line[..prefix_len];
289 let rest = after_bq.trim_start();
290 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
291 }
292 }
293
294 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
302 for line_num in (1..before_line).rev() {
303 let idx = line_num - 1;
304 if let Some(info) = ctx.lines.get(idx) {
305 if is_blank_in_context(info.content(ctx.content)) {
308 return (line_num, true);
309 }
310 if info.in_html_comment || info.in_mdx_comment {
312 continue;
313 }
314 if Self::is_transparent_div_marker(ctx, info) {
316 continue;
317 }
318 return (line_num, is_blank_in_context(info.content(ctx.content)));
319 }
320 }
321 (0, true)
323 }
324
325 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
332 let num_lines = ctx.lines.len();
333 for line_num in (after_line + 1)..=num_lines {
334 let idx = line_num - 1;
335 if let Some(info) = ctx.lines.get(idx) {
336 if is_blank_in_context(info.content(ctx.content)) {
339 return (line_num, true);
340 }
341 if info.in_html_comment || info.in_mdx_comment {
343 continue;
344 }
345 if Self::is_transparent_div_marker(ctx, info) {
347 continue;
348 }
349 return (line_num, is_blank_in_context(info.content(ctx.content)));
350 }
351 }
352 (0, true)
354 }
355
356 fn block_ends_in_comment_line(lines: &[&str], end_line: usize) -> bool {
363 lines.get(end_line - 1).is_some_and(|line| is_blank_in_context(line))
364 }
365
366 fn prefix_for_blank_after(
374 ctx: &crate::lint_context::LintContext,
375 end_line: usize,
376 following_line: usize,
377 list_bq_level: usize,
378 ) -> String {
379 let end_bq_level = ctx
380 .line_info(end_line)
381 .and_then(|info| info.blockquote.as_ref())
382 .map_or(0, |bq| bq.nesting_level);
383 let source = if end_bq_level == list_bq_level {
384 end_line
385 } else {
386 following_line
387 };
388 ctx.blockquote_prefix_for_blank_line(source - 1)
389 }
390
391 fn is_following_content_excluded(ctx: &crate::lint_context::LintContext, line_num: usize, prefix: &str) -> bool {
392 ctx.line_info(line_num).is_some_and(|info| {
393 info.in_front_matter
394 || (info.in_code_block
395 && effective_indent_in_blockquote(
396 info.content(ctx.content),
397 prefix.chars().filter(|&c| c == '>').count(),
398 info.indent,
399 ) >= 2)
400 })
401 }
402
403 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
405 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
406
407 for block in &ctx.list_blocks {
408 if ctx
410 .line_info(block.start_line)
411 .is_some_and(|info| info.in_footnote_definition)
412 {
413 continue;
414 }
415
416 let mut segments: Vec<(usize, usize)> = Vec::new();
422 let mut current_start = block.start_line;
423 let mut prev_item_line = 0;
424
425 let get_blockquote_level = |line_num: usize| -> usize {
427 if line_num == 0 || line_num > ctx.lines.len() {
428 return 0;
429 }
430 let line_content = ctx.lines[line_num - 1].content(ctx.content);
431 parse_blockquote_prefix(line_content).map_or(0, |bq| bq.nesting_level)
432 };
433
434 let mut prev_bq_level = 0;
435
436 for &item_line in &block.item_lines {
437 let current_bq_level = get_blockquote_level(item_line);
438
439 if prev_item_line > 0 {
440 let blockquote_level_changed = prev_bq_level != current_bq_level;
442
443 let mut has_standalone_code_fence = false;
446
447 let min_indent_for_content = if block.is_ordered {
449 3 } else {
453 2 };
456
457 for check_line in (prev_item_line + 1)..item_line {
458 if check_line - 1 < ctx.lines.len() {
459 let line = &ctx.lines[check_line - 1];
460 let line_content = line.content(ctx.content);
461 if line.in_code_block
462 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
463 {
464 if line.indent < min_indent_for_content {
467 has_standalone_code_fence = true;
468 break;
469 }
470 }
471 }
472 }
473
474 if has_standalone_code_fence || blockquote_level_changed {
475 segments.push((current_start, prev_item_line));
477 current_start = item_line;
478 }
479 }
480 prev_item_line = item_line;
481 prev_bq_level = current_bq_level;
482 }
483
484 if prev_item_line > 0 {
487 segments.push((current_start, prev_item_line));
488 }
489
490 for (start, end) in &segments {
492 let mut actual_end = *end;
494
495 if *end < block.end_line {
496 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
498
499 let min_continuation_indent = if block_bq_level > 0 {
502 if block.is_ordered {
504 block.max_marker_width
505 } else {
506 2 }
508 } else {
509 ctx.lines
510 .get(*end - 1)
511 .and_then(|line_info| line_info.list_item.as_ref())
512 .map_or(2, |item| item.content_column)
513 };
514
515 for check_line in (*end + 1)..=block.end_line {
516 if check_line - 1 < ctx.lines.len() {
517 let line = &ctx.lines[check_line - 1];
518 let line_content = line.content(ctx.content);
519 if block.item_lines.contains(&check_line) || line.heading.is_some() {
523 break;
524 }
525 if line.in_code_block {
527 break;
528 }
529
530 let effective_indent =
532 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
533
534 if effective_indent >= min_continuation_indent {
536 actual_end = check_line;
537 }
538 else if !line.is_blank
543 && line.heading.is_none()
544 && !block.item_lines.contains(&check_line)
545 && !is_thematic_break(line_content)
546 {
547 actual_end = check_line;
549 } else if !line.is_blank {
550 break;
552 }
553 }
554 }
555 }
556
557 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
558 }
559 }
560
561 blocks.retain(|(start, end, _)| {
563 let all_in_comment = (*start..=*end).all(|line_num| {
565 ctx.lines
566 .get(line_num - 1)
567 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
568 });
569 !all_in_comment
570 });
571
572 blocks
573 }
574
575 fn perform_checks(
576 &self,
577 ctx: &crate::lint_context::LintContext,
578 lines: &[&str],
579 list_blocks: &[(usize, usize, String)],
580 ) -> (Vec<LintWarning>, usize) {
581 let mut warnings = Vec::new();
582 let num_lines = lines.len();
583 let coverage = ListCoverage::new(list_blocks);
584
585 for (line_idx, line) in lines.iter().enumerate() {
588 let line_num = line_idx + 1;
589
590 if coverage.contains(line_num) {
592 continue;
593 }
594
595 if ctx.line_info(line_num).is_some_and(|info| {
597 info.in_code_block
598 || info.in_front_matter
599 || info.in_html_comment
600 || info.in_mdx_comment
601 || info.in_html_block
602 || info.in_jsx_block
603 }) {
604 continue;
605 }
606
607 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
609 if line_idx > 0 {
611 let prev_line = lines[line_idx - 1];
612 let prev_is_blank = is_blank_in_context(prev_line);
613 let prev_line_info = ctx.line_info(line_idx);
614 let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
615
616 let prev_in_mkdocs_container =
632 prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
633 let continues_stale_container_list = prev_in_mkdocs_container && {
634 let item_indent = calculate_indentation_width_default(line);
635 let mut found_marker = false;
636 for j in (0..line_idx).rev() {
637 let in_container = ctx
638 .line_info(j + 1)
639 .is_some_and(|info| info.in_admonition || info.in_content_tab);
640 if !in_container {
641 break;
642 }
643 let candidate = lines[j];
644 if is_blank_in_context(candidate) {
645 continue;
646 }
647 let candidate_indent = calculate_indentation_width_default(candidate);
648 if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
649 && candidate_indent == item_indent
650 {
651 found_marker = true;
652 break;
653 }
654 if candidate_indent <= item_indent {
655 break;
656 }
657 }
658 found_marker
659 };
660
661 let prev_trimmed = prev_line.trim();
666 let is_sentence_continuation = continues_stale_container_list
667 || (!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 || !is_sentence_continuation {
679 if !prev_is_blank && !prev_excluded {
680 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
682
683 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
684 warnings.push(LintWarning {
685 line: start_line,
686 column: start_col,
687 end_line,
688 end_column: end_col,
689 severity: Severity::Warning,
690 rule_name: Some(self.name().to_string()),
691 message: "Ordered list starting with non-1 should be preceded by blank line"
692 .to_string(),
693 fix: Some(Fix::new(
694 ctx.line_column_byte_range_with_length(line_num, 1, 0),
695 format!("{bq_prefix}\n"),
696 )),
697 });
698 }
699
700 if line_idx + 1 < num_lines {
703 let next_line = lines[line_idx + 1];
704 let next_is_blank = is_blank_in_context(next_line);
705 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
706
707 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
708 let next_trimmed = next_line.trim_start();
712 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
713 || next_line.starts_with("1. ")
714 || (next_line.len() > next_trimmed.len()
715 && !next_trimmed.starts_with("- ")
716 && !next_trimmed.starts_with("* ")
717 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
720 let (start_line, start_col, end_line, end_col) =
721 calculate_line_range(line_num, line);
722 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
723 warnings.push(LintWarning {
724 line: start_line,
725 column: start_col,
726 end_line,
727 end_column: end_col,
728 severity: Severity::Warning,
729 rule_name: Some(self.name().to_string()),
730 message: "List should be followed by blank line".to_string(),
731 fix: Some(Fix::new(
732 ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
733 format!("{bq_prefix}\n"),
734 )),
735 });
736 }
737 }
738 }
739 }
740 }
741 }
742 }
743
744 let separating_from = warnings.len();
747
748 for &(start_line, end_line, ref prefix) in list_blocks {
749 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
750 if ctx
752 .line_info(start_line)
753 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
754 {
755 continue;
756 }
757
758 if start_line > 1 {
759 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
761
762 if !has_blank_separation && content_line > 0 {
764 let prev_line_str = lines[content_line - 1];
765 let is_prev_excluded = ctx
766 .line_info(content_line)
767 .is_some_and(|info| info.in_code_block || info.in_front_matter);
768 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
769 let prefixes_match = prev_bq_level == block_bq_level;
770
771 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
774 if !is_prev_excluded && prefixes_match && should_require {
775 let (start_line, start_col, end_line, end_col) =
777 calculate_line_range(start_line, lines[start_line - 1]);
778
779 warnings.push(LintWarning {
780 line: start_line,
781 column: start_col,
782 end_line,
783 end_column: end_col,
784 severity: Severity::Warning,
785 rule_name: Some(self.name().to_string()),
786 message: "List should be preceded by blank line".to_string(),
787 fix: Some(Fix::new(
788 ctx.line_column_byte_range_with_length(start_line, 1, 0),
789 format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
790 )),
791 });
792 }
793 }
794 }
795
796 if end_line < num_lines && !Self::block_ends_in_comment_line(lines, end_line) {
797 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
799
800 if !has_blank_separation && content_line > 0 {
802 let next_line_str = lines[content_line - 1];
803 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
806 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
807
808 let end_line_str = lines[end_line - 1];
813 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
814 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
815
816 let prefixes_match = next_line_bq_level == block_bq_level;
817
818 if !is_next_excluded && prefixes_match && !exits_blockquote {
821 let (start_line_last, start_col_last, end_line_last, end_col_last) =
823 calculate_line_range(end_line, lines[end_line - 1]);
824
825 warnings.push(LintWarning {
826 line: start_line_last,
827 column: start_col_last,
828 end_line: end_line_last,
829 end_column: end_col_last,
830 severity: Severity::Warning,
831 rule_name: Some(self.name().to_string()),
832 message: "List should be followed by blank line".to_string(),
833 fix: Some(Fix::new(
834 ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
835 format!(
836 "{}\n",
837 Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level)
838 ),
839 )),
840 });
841 }
842 }
843 }
844 }
845 (warnings, separating_from)
846 }
847}
848
849impl Rule for MD032BlanksAroundLists {
850 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
851 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(32))
852 }
853
854 fn name(&self) -> &'static str {
855 "MD032"
856 }
857
858 fn description(&self) -> &'static str {
859 "Lists should be surrounded by blank lines"
860 }
861
862 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
863 if self.config == Default::default() {
864 return Ok(self.prepared(ctx).warnings.clone());
865 }
866 Ok(self.check_native(ctx))
867 }
868
869 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
870 if self.config == Default::default() {
871 let prepared = self.prepared(ctx);
872 return Ok(self.fix_with_inputs(ctx, &prepared.withheld, &prepared.blocks, &prepared.fallback));
873 }
874 Ok(self.fix_with_structure_impl(ctx, &self.withheld_fixes(ctx)))
875 }
876
877 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
878 ctx.content.is_empty() || ctx.list_blocks.is_empty()
881 }
882
883 fn category(&self) -> RuleCategory {
884 RuleCategory::List
885 }
886
887 fn as_any(&self) -> &dyn std::any::Any {
888 self
889 }
890
891 crate::impl_rule_config_methods!(MD032Config);
892}
893
894impl MD032BlanksAroundLists {
895 fn check_native(&self, ctx: &crate::lint_context::LintContext) -> Vec<LintWarning> {
896 let (mut warnings, separating_from) = self.unguarded_warnings(ctx);
897 drop_structure_changing_fixes(ctx, &mut warnings[separating_from..], Allowed::Nothing);
898 protect_literal_code(ctx, &mut warnings);
899 warnings
900 }
901
902 fn prepared<'a>(
903 &self,
904 ctx: &'a crate::lint_context::LintContext,
905 ) -> &'a crate::lint_context::NativeListSpacingPlan {
906 debug_assert!(self.config == Default::default());
909 ctx.native_list_spacing_plan(|| {
910 let blocks = self.convert_list_blocks(ctx);
911 let (mut warnings, separating_from) = self.unguarded_warnings_with_blocks(ctx, &blocks);
912 let fallback = warnings[..separating_from].to_vec();
913 let withheld = Self::guard_warnings(ctx, &mut warnings, separating_from);
914 crate::lint_context::NativeListSpacingPlan {
915 warnings,
916 withheld,
917 blocks,
918 fallback,
919 }
920 })
921 }
922
923 fn unguarded_warnings(&self, ctx: &crate::lint_context::LintContext) -> (Vec<LintWarning>, usize) {
926 let list_blocks = self.convert_list_blocks(ctx);
927 self.unguarded_warnings_with_blocks(ctx, &list_blocks)
928 }
929
930 fn unguarded_warnings_with_blocks(
931 &self,
932 ctx: &crate::lint_context::LintContext,
933 list_blocks: &[(usize, usize, String)],
934 ) -> (Vec<LintWarning>, usize) {
935 let lines = ctx.raw_lines();
936 if lines.is_empty() {
938 return (Vec::new(), 0);
939 }
940
941 if list_blocks.is_empty() {
942 return (Vec::new(), 0);
943 }
944
945 let (mut warnings, separating_from) = self.perform_checks(ctx, lines, list_blocks);
946
947 if !self.config.allow_lazy_continuation {
952 let lazy_cont_lines = ctx.lazy_continuation_lines();
953 let coverage = ListCoverage::new(list_blocks);
954
955 for lazy_info in lazy_cont_lines.iter() {
956 let line_num = lazy_info.line_num;
957
958 if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
962 continue;
963 }
964
965 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
967 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
968
969 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
971 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
972 } else {
973 None
974 };
975
976 warnings.push(LintWarning {
977 line: start_line,
978 column: start_col,
979 end_line,
980 end_column: end_col,
981 severity: Severity::Warning,
982 rule_name: Some(self.name().to_string()),
983 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
984 fix,
985 });
986 }
987 }
988
989 (warnings, separating_from)
990 }
991
992 fn withheld_fixes(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<(usize, bool)> {
998 let (mut guarded, separating_from) = self.unguarded_warnings(ctx);
999 Self::guard_warnings(ctx, &mut guarded, separating_from)
1000 }
1001
1002 fn guard_warnings(
1003 ctx: &crate::lint_context::LintContext,
1004 guarded: &mut Vec<LintWarning>,
1005 separating_from: usize,
1006 ) -> std::collections::HashSet<(usize, bool)> {
1007 let fix_key = |fix: &Fix| {
1008 let line = ctx.offset_to_line_col(fix.range.start).0;
1009 (line, fix.range.is_empty() && fix.replacement.ends_with('\n'))
1010 };
1011 let mut withheld: std::collections::HashSet<_> = guarded
1012 .iter()
1013 .filter_map(|warning| warning.fix.as_ref())
1014 .map(fix_key)
1015 .collect();
1016 drop_structure_changing_fixes(ctx, &mut guarded[separating_from..], Allowed::Nothing);
1017 protect_literal_code(ctx, guarded);
1018 for fix in guarded.iter().filter_map(|warning| warning.fix.as_ref()) {
1019 withheld.remove(&fix_key(fix));
1020 }
1021 withheld
1022 }
1023
1024 fn fix_with_structure_impl(
1026 &self,
1027 ctx: &crate::lint_context::LintContext,
1028 withheld: &std::collections::HashSet<(usize, bool)>,
1029 ) -> String {
1030 let list_blocks = self.convert_list_blocks(ctx);
1031 let (mut fallback, separating_from) = self.unguarded_warnings(ctx);
1032 fallback.truncate(separating_from);
1033 self.fix_with_inputs(ctx, withheld, &list_blocks, &fallback)
1034 }
1035
1036 fn fix_with_inputs(
1037 &self,
1038 ctx: &crate::lint_context::LintContext,
1039 withheld: &std::collections::HashSet<(usize, bool)>,
1040 list_blocks: &[(usize, usize, String)],
1041 fallback_warnings: &[LintWarning],
1042 ) -> String {
1043 let lines = ctx.raw_lines();
1044 let num_lines = lines.len();
1045 if num_lines == 0 {
1046 return String::new();
1047 }
1048
1049 if list_blocks.is_empty() {
1050 return ctx.content.to_string();
1051 }
1052
1053 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
1056 if !self.config.allow_lazy_continuation {
1057 let lazy_cont_lines = ctx.lazy_continuation_lines();
1058 let coverage = ListCoverage::new(list_blocks);
1059 for lazy_info in lazy_cont_lines.iter() {
1060 let line_num = lazy_info.line_num;
1061 if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
1063 continue;
1064 }
1065 if !Self::should_apply_lazy_fix(ctx, line_num)
1067 || ctx.inline_config().is_rule_disabled(self.name(), line_num)
1068 {
1069 continue;
1070 }
1071 if !withheld.contains(&(line_num, false)) {
1072 lazy_fixes.insert(line_num, lazy_info.clone());
1073 }
1074 }
1075 }
1076
1077 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
1078
1079 for warning in fallback_warnings {
1083 if ctx.inline_config().is_rule_disabled(self.name(), warning.line) {
1084 continue;
1085 }
1086 if let Some(fix) = &warning.fix
1087 && fix.range.is_empty()
1088 && let Some(prefix) = fix.replacement.strip_suffix('\n')
1089 {
1090 let line_num = ctx.offset_to_line_col(fix.range.start).0;
1091 if !withheld.contains(&(line_num, true)) {
1092 insertions.entry(line_num).or_insert_with(|| prefix.to_string());
1093 }
1094 }
1095 }
1096
1097 for &(start_line, end_line, ref prefix) in list_blocks {
1099 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
1100 if ctx
1102 .line_info(start_line)
1103 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
1104 {
1105 continue;
1106 }
1107
1108 if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
1110 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1112
1113 if !has_blank_separation && content_line > 0 {
1115 let prev_line_str = lines[content_line - 1];
1116 let is_prev_excluded = ctx
1117 .line_info(content_line)
1118 .is_some_and(|info| info.in_code_block || info.in_front_matter);
1119 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
1120
1121 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1122 if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
1124 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1126 if !withheld.contains(&(start_line, true)) {
1127 insertions.insert(start_line, bq_prefix);
1128 }
1129 }
1130 }
1131 }
1132
1133 if end_line < num_lines
1135 && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
1136 && !Self::block_ends_in_comment_line(lines, end_line)
1137 {
1138 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1140
1141 if !has_blank_separation && content_line > 0 {
1143 let next_line_str = lines[content_line - 1];
1144 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
1147 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
1148
1149 let end_line_str = lines[end_line - 1];
1151 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1152 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1153
1154 if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1157 let bq_prefix = Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level);
1159 if !withheld.contains(&(end_line + 1, true)) {
1160 insertions.insert(end_line + 1, bq_prefix);
1161 }
1162 }
1163 }
1164 }
1165 }
1166
1167 if insertions.is_empty() && lazy_fixes.is_empty() {
1168 return ctx.content.to_string();
1169 }
1170
1171 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1173 for (i, line) in lines.iter().enumerate() {
1174 let current_line_num = i + 1;
1175 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1176 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1177 {
1178 result_lines.push(prefix_to_insert.clone());
1179 }
1180
1181 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1183 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1184 result_lines.push(fixed_line);
1185 } else {
1186 result_lines.push(line.to_string());
1187 }
1188 }
1189
1190 let line_ending = crate::utils::detect_line_ending(ctx.content);
1192 let mut result = result_lines.join(line_ending);
1193 if ctx.content.ends_with('\n') {
1194 result.push_str(line_ending);
1195 }
1196 result
1197 }
1198}
1199
1200fn is_blank_in_context(line: &str) -> bool {
1203 parse_blockquote_prefix(line)
1204 .map_or(line, |bq| bq.content)
1205 .trim()
1206 .is_empty()
1207 || crate::utils::blank_lines::is_blank_or_comment_only(line)
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213 use crate::lint_context::LintContext;
1214 use crate::rule::Rule;
1215
1216 #[test]
1217 fn prepared_native_list_spacing_preserves_diagnostics_fixes_and_configuration() {
1218 use crate::config::MarkdownFlavor::*;
1219 let flavors = [
1220 Standard,
1221 MkDocs,
1222 MDX,
1223 Pandoc,
1224 Quarto,
1225 Obsidian,
1226 Kramdown,
1227 AzureDevOps,
1228 MyST,
1229 Hugo,
1230 MDG,
1231 GhAw,
1232 ];
1233 let bodies = [
1234 "text\n- first\n- second\nfollowing text",
1235 "- first\nlazy paragraph\n- second\nafter",
1236 "- first\n\n```rust\n- literal\n```\n- second",
1237 "- first\n ```rust\n - literal\n ```\n- second",
1238 "---\ntitle: List\n---\ntext\n- first\nnext",
1239 "<!-- rumdl-disable MD032 -->\ntext\n- first\nnext",
1240 "text\n- first\n<!-- rumdl-disable-next-line MD032 -->\n- second\nnext",
1241 "<!--\n- literal\n-->\ntext\n- first\nnext",
1242 "text\n2. fallback\n3. next",
1243 "text\n- first | cell\n| --- | --- |\n- second",
1244 "É 🦀 text\n- `literal`\nlazy text\n- next",
1245 "",
1246 ];
1247 for flavor in flavors {
1248 for prefix in ["", "> ", "> > "] {
1249 for body in bodies {
1250 let source = body.lines().fold(String::new(), |mut source, line| {
1251 source.push_str(prefix);
1252 source.push_str(line);
1253 source.push('\n');
1254 source
1255 });
1256 for source in [source.clone(), source.replace('\n', "\r\n")] {
1257 for fix_first in [false, true] {
1258 let ctx = LintContext::new(&source, flavor, None);
1259 for allow_lazy_continuation in [true, false, true] {
1262 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1263 allow_lazy_continuation,
1264 });
1265 let expected_warnings = rule.check_native(&ctx);
1266 let expected_fix = rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx));
1267 if fix_first {
1268 assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
1269 }
1270 assert_eq!(rule.check(&ctx).unwrap(), expected_warnings, "{flavor:?} {source:?}");
1271 assert_eq!(rule.fix(&ctx).unwrap(), expected_fix, "{flavor:?} {source:?}");
1272 assert_eq!(rule.check(&ctx).unwrap(), expected_warnings);
1273 }
1274 let rule = MD032BlanksAroundLists::default();
1277 let fixed = rule.fix(&ctx).unwrap();
1278 let next = LintContext::new(&fixed, flavor, None);
1279 assert_eq!(rule.check(&next).unwrap(), rule.check_native(&next));
1280 assert_eq!(
1281 rule.fix(&next).unwrap(),
1282 rule.fix_with_structure_impl(&next, &rule.withheld_fixes(&next))
1283 );
1284 }
1285 }
1286 }
1287 }
1288 }
1289 }
1290
1291 #[test]
1292 fn test_list_coverage_matches_every_block_whatever_their_order() {
1293 let blocks: Vec<(usize, usize, String)> = [(20, 22), (3, 10), (5, 6), (9, 14), (30, 30), (17, 17)]
1296 .into_iter()
1297 .map(|(start, end)| (start, end, String::new()))
1298 .collect();
1299 let coverage = ListCoverage::new(&blocks);
1300 for line in 0..=35 {
1301 let expected = blocks.iter().any(|&(start, end, _)| start <= line && line <= end);
1302 assert_eq!(coverage.contains(line), expected, "line {line}");
1303 }
1304 assert!(!ListCoverage::new(&[]).contains(1));
1305 }
1306
1307 fn lint(content: &str) -> Vec<LintWarning> {
1308 let rule = MD032BlanksAroundLists::default();
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 rule.check(&ctx).expect("Lint check failed")
1311 }
1312
1313 fn fix(content: &str) -> String {
1314 let rule = MD032BlanksAroundLists::default();
1315 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316 rule.fix(&ctx).expect("Lint fix failed")
1317 }
1318
1319 #[test]
1320 fn test_spaced_nested_blockquotes_list_separation() {
1321 for (list_prefix, surrounding_prefix) in [
1322 ("> >", "> >"),
1323 ("> >", "> >"),
1324 ("> > >", "> > >"),
1325 ("> >", ">>"),
1326 (">>", "> >"),
1327 ] {
1328 let content = format!(
1329 "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1330 );
1331 let expected = format!(
1332 "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1333 );
1334 let warnings = lint(&content);
1335 assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1336 assert!(warnings.iter().all(|warning| warning.line == 2));
1337 let mut edited = content.clone();
1338 for warning in warnings.iter().rev() {
1339 let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1340 edited.replace_range(edit.range.clone(), &edit.replacement);
1341 }
1342 assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1343 assert_eq!(fix(&content), expected);
1344 assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1345 assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1346 }
1347 }
1348
1349 #[test]
1350 fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1351 for content in [
1352 "> > - item\n> > ```\n> > code\n> > ```\n",
1353 "> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
1354 "> > - item\n> ~~~\n> code\n> ~~~\n",
1355 "> > - item\n~~~\ncode\n~~~\n",
1356 "> > - item\n>> - next item\n",
1357 ] {
1358 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1359 assert_eq!(fix(content), content);
1360 }
1361 }
1362
1363 #[test]
1364 fn test_fix_after_a_list_split_by_a_fence_keeps_its_continuation_lines() {
1365 for (content, expected) in [
1368 (
1369 "1. a\n2. b\n c\n```\nx\n```\n3. d\n",
1370 "1. a\n2. b\n c\n\n```\nx\n```\n3. d\n",
1371 ),
1372 (
1373 "* a\n```\n```\n* b\n](x)\n\ntext\n",
1374 "* a\n\n```\n```\n* b\n](x)\n\ntext\n",
1375 ),
1376 ] {
1377 assert_eq!(fix(content), expected, "{content:?}");
1378 }
1379 let warnings = lint("1. a\n2. b\n c\n```\nx\n```\n3. d\n");
1380 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![3]);
1381 }
1382
1383 #[test]
1384 fn test_backtick_line_short_of_the_content_column_is_lazy_text() {
1385 let content = " 1. a\n ```\n code\n";
1388 assert!(lint(content).is_empty(), "{:?}", lint(content));
1389 assert_eq!(fix(content), content);
1390 }
1391
1392 #[test]
1393 fn test_blank_after_a_quoted_list_ending_in_lazy_text_stays_in_the_quote() {
1394 let content = "> 1. a\nlazy\n> - b\n";
1395 let warnings = lint(content);
1396 assert_eq!(warnings.len(), 1, "{warnings:?}");
1397 assert_eq!(fix(content), "> 1. a\nlazy\n>\n> - b\n");
1398 }
1399
1400 #[test]
1401 fn test_fix_never_changes_how_the_lists_parse() {
1402 for (content, expected) in [
1408 ("1. item 41\n + item 51\n+ item 52\n", None),
1409 (" * item 17\n 10. item 21\n1. item 22\n", None),
1410 ("- item 6\n> ```\n 10. item 23\n 1. item 24\n", None),
1411 (
1412 " 10. item 43\n> + item 47\n> continuation text\n> 1. item 50\n",
1413 Some(" 10. item 43\n>\n> + item 47\n> continuation text\n> 1. item 50\n"),
1414 ),
1415 (
1416 " 1. item 63\n> + item 65\n> + item 66\n",
1417 Some(" 1. item 63\n>\n> + item 65\n> + item 66\n"),
1418 ),
1419 ] {
1420 let expected = expected.unwrap_or(content);
1421 assert_eq!(fix(content), expected, "{content:?}");
1422 let fixable = lint(content).iter().filter(|w| w.fix.is_some()).count();
1423 assert_eq!(fixable, usize::from(expected != content), "{content:?}");
1424 }
1425 }
1426
1427 #[test]
1428 fn test_fix_separates_list_from_standalone_code_fence() {
1429 for (content, expected) in [
1430 (
1431 "# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
1432 "# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
1433 ),
1434 ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1435 ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1436 (
1437 ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1438 ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1439 ),
1440 ] {
1441 let warnings = lint(content);
1442 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1443 assert_eq!(warnings[0].message, "List should be followed by blank line");
1444 let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1445 let mut edited = content.to_string();
1446 edited.replace_range(edit.range.clone(), &edit.replacement);
1447 assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1448 assert_eq!(fix(content), expected, "{content:?}");
1449 assert!(lint(expected).is_empty(), "{expected:?}");
1450 assert_eq!(fix(expected), expected, "Fix must be idempotent");
1451 }
1452 }
1453
1454 #[test]
1455 fn test_fix_preserves_code_fence_inside_list_item() {
1456 for content in [
1457 "- item\n ```\n code\n ```\n",
1458 "1. item\n ~~~\n code\n ~~~\n",
1459 "> - item\n> ```\n> code\n> ```\n",
1460 ] {
1461 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1462 assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1463 }
1464 }
1465
1466 #[test]
1467 fn test_fix_does_not_split_item_before_different_list_type() {
1468 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1472 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1473
1474 let warnings = lint(content);
1477 assert_eq!(warnings.len(), 2);
1478 assert_eq!(warnings[0].line, 2);
1479 assert_eq!(warnings[1].line, 3);
1480 }
1481
1482 #[test]
1483 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1484 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1485 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1486 }
1487
1488 #[test]
1489 fn test_fix_keeps_lazy_continuation_with_its_item() {
1490 let content = "- alpha beta\nlazy\n1. ordered item\n";
1494 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1495
1496 let warnings = lint(content);
1497 assert_eq!(warnings.len(), 2);
1498 assert_eq!(warnings[0].line, 2);
1499 assert_eq!(warnings[1].line, 3);
1500 }
1501
1502 #[test]
1503 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1504 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1505 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1506 }
1507
1508 #[test]
1509 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1510 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1513 allow_lazy_continuation: false,
1514 });
1515 let content = "- alpha beta\nlazy\n1. ordered item\n";
1516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1517 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1518 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1519 }
1520
1521 #[test]
1522 fn test_lazy_continuation_does_not_indent_template_values() {
1523 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1524 allow_lazy_continuation: false,
1525 });
1526 for (flavor, code) in [
1527 (
1528 crate::config::MarkdownFlavor::Standard,
1529 "{% set unused=\"first\nLiteral\nlast\" %}{{ unused|length }}",
1530 ),
1531 (
1532 crate::config::MarkdownFlavor::Hugo,
1533 "{{< note title=`first\nLiteral\nlast` >}}",
1534 ),
1535 ] {
1536 let source = format!("- Visible {code}\n- Sibling\n\nOther:\n- Item\n");
1537 let expected = format!("- Visible {code}\n- Sibling\n\nOther:\n\n- Item\n");
1538 let ctx = LintContext::new(&source, flavor, None);
1539 assert_eq!(rule.check(&ctx).unwrap().len(), 1, "{source}");
1540 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
1541 }
1542 }
1543
1544 #[test]
1545 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1546 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1550 allow_lazy_continuation: false,
1551 });
1552 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1554 let warnings = rule.check(&ctx).expect("Lint check failed");
1555 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1556 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1557 assert_eq!(fixed, content);
1558 }
1559
1560 #[test]
1561 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1562 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1566 allow_lazy_continuation: false,
1567 });
1568 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1570 let warnings = rule.check(&ctx).expect("Lint check failed");
1571 assert_eq!(
1572 warnings.len(),
1573 1,
1574 "Expected one lazy-continuation warning, got: {warnings:?}"
1575 );
1576 assert_eq!(warnings[0].line, 3);
1577 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1578 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1579 }
1580
1581 #[test]
1582 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1583 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1588 allow_lazy_continuation: false,
1589 });
1590 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let warnings = rule.check(&ctx).unwrap();
1593 assert_eq!(warnings.len(), 1, "{warnings:?}");
1594 assert_eq!(warnings[0].line, 5);
1595 assert!(warnings[0].fix.is_none(), "{warnings:?}");
1596 assert_eq!(rule.fix(&ctx).unwrap(), content);
1597
1598 let content = "Intro\n\n- List item 1\n- List item 2\nlazy\n";
1599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1600 assert_eq!(
1601 rule.fix(&ctx).unwrap(),
1602 "Intro\n\n- List item 1\n- List item 2\n lazy\n"
1603 );
1604 }
1605
1606 fn check_warnings_have_fixes(content: &str) {
1608 let warnings = lint(content);
1609 for warning in &warnings {
1610 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1611 }
1612 }
1613
1614 #[test]
1615 fn test_list_at_start() {
1616 let content = "- Item 1\n- Item 2\nText";
1619 let warnings = lint(content);
1620 assert_eq!(
1621 warnings.len(),
1622 0,
1623 "Trailing text is lazy continuation per CommonMark - no warning expected"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_list_at_end() {
1629 let content = "Text\n- Item 1\n- Item 2";
1630 let warnings = lint(content);
1631 assert_eq!(
1632 warnings.len(),
1633 1,
1634 "Expected 1 warning for list at end without preceding blank line"
1635 );
1636 assert_eq!(
1637 warnings[0].line, 2,
1638 "Warning should be on the first line of the list (line 2)"
1639 );
1640 assert!(warnings[0].message.contains("preceded by blank line"));
1641
1642 check_warnings_have_fixes(content);
1644
1645 let fixed_content = fix(content);
1646 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1647
1648 let warnings_after_fix = lint(&fixed_content);
1650 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1651 }
1652
1653 #[test]
1654 fn test_list_in_middle() {
1655 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1658 let warnings = lint(content);
1659 assert_eq!(
1660 warnings.len(),
1661 1,
1662 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1663 );
1664 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1665 assert!(warnings[0].message.contains("preceded by blank line"));
1666
1667 check_warnings_have_fixes(content);
1669
1670 let fixed_content = fix(content);
1671 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1672
1673 let warnings_after_fix = lint(&fixed_content);
1675 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1676 }
1677
1678 #[test]
1679 fn test_correct_spacing() {
1680 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1681 let warnings = lint(content);
1682 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1683
1684 let fixed_content = fix(content);
1685 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1686 }
1687
1688 #[test]
1689 fn test_list_with_content() {
1690 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1693 let warnings = lint(content);
1694 assert_eq!(
1695 warnings.len(),
1696 1,
1697 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1698 );
1699 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1700 assert!(warnings[0].message.contains("preceded by blank line"));
1701
1702 check_warnings_have_fixes(content);
1704
1705 let fixed_content = fix(content);
1706 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1707 assert_eq!(
1708 fixed_content, expected_fixed,
1709 "Fix did not produce the expected output. Got:\n{fixed_content}"
1710 );
1711
1712 let warnings_after_fix = lint(&fixed_content);
1714 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1715 }
1716
1717 #[test]
1718 fn test_nested_list() {
1719 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1721 let warnings = lint(content);
1722 assert_eq!(
1723 warnings.len(),
1724 1,
1725 "Nested list block needs preceding blank only. Got: {warnings:?}"
1726 );
1727 assert_eq!(warnings[0].line, 2);
1728 assert!(warnings[0].message.contains("preceded by blank line"));
1729
1730 check_warnings_have_fixes(content);
1732
1733 let fixed_content = fix(content);
1734 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1735
1736 let warnings_after_fix = lint(&fixed_content);
1738 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1739 }
1740
1741 #[test]
1742 fn test_list_with_internal_blanks() {
1743 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1745 let warnings = lint(content);
1746 assert_eq!(
1747 warnings.len(),
1748 1,
1749 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1750 );
1751 assert_eq!(warnings[0].line, 2);
1752 assert!(warnings[0].message.contains("preceded by blank line"));
1753
1754 check_warnings_have_fixes(content);
1756
1757 let fixed_content = fix(content);
1758 assert_eq!(
1759 fixed_content,
1760 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1761 );
1762
1763 let warnings_after_fix = lint(&fixed_content);
1765 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1766 }
1767
1768 #[test]
1769 fn test_ignore_code_blocks() {
1770 let content = "```\n- Not a list item\n```\nText";
1771 let warnings = lint(content);
1772 assert_eq!(warnings.len(), 0);
1773 let fixed_content = fix(content);
1774 assert_eq!(fixed_content, content);
1775 }
1776
1777 #[test]
1778 fn test_ignore_front_matter() {
1779 let content = "---\ntitle: Test\n---\n- List Item\nText";
1781 let warnings = lint(content);
1782 assert_eq!(
1783 warnings.len(),
1784 0,
1785 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1786 );
1787
1788 let fixed_content = fix(content);
1790 assert_eq!(fixed_content, content, "No changes when no warnings");
1791 }
1792
1793 #[test]
1794 fn test_multiple_lists() {
1795 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1800 let warnings = lint(content);
1801 assert!(
1803 !warnings.is_empty(),
1804 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1805 );
1806
1807 check_warnings_have_fixes(content);
1809
1810 let fixed_content = fix(content);
1811 let warnings_after_fix = lint(&fixed_content);
1813 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1814 }
1815
1816 #[test]
1817 fn test_adjacent_lists() {
1818 let content = "- List 1\n\n* List 2";
1819 let warnings = lint(content);
1820 assert_eq!(warnings.len(), 0);
1821 let fixed_content = fix(content);
1822 assert_eq!(fixed_content, content);
1823 }
1824
1825 #[test]
1826 fn test_list_in_blockquote() {
1827 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1829 let warnings = lint(content);
1830 assert_eq!(
1831 warnings.len(),
1832 1,
1833 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1834 );
1835 assert_eq!(warnings[0].line, 2);
1836
1837 check_warnings_have_fixes(content);
1839
1840 let fixed_content = fix(content);
1841 assert_eq!(
1843 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1844 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1845 );
1846
1847 let warnings_after_fix = lint(&fixed_content);
1849 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1850 }
1851
1852 #[test]
1853 fn test_ordered_list() {
1854 let content = "Text\n1. Item 1\n2. Item 2\nText";
1856 let warnings = lint(content);
1857 assert_eq!(warnings.len(), 1);
1858
1859 check_warnings_have_fixes(content);
1861
1862 let fixed_content = fix(content);
1863 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1864
1865 let warnings_after_fix = lint(&fixed_content);
1867 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1868 }
1869
1870 #[test]
1871 fn test_no_double_blank_fix() {
1872 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1875 assert_eq!(
1876 warnings.len(),
1877 0,
1878 "Should have no warnings - properly preceded, trailing is lazy"
1879 );
1880
1881 let fixed_content = fix(content);
1882 assert_eq!(
1883 fixed_content, content,
1884 "No fix needed when no warnings. Got:\n{fixed_content}"
1885 );
1886
1887 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1889 assert_eq!(warnings2.len(), 1);
1890 if !warnings2.is_empty() {
1891 assert_eq!(
1892 warnings2[0].line, 2,
1893 "Warning line for missing blank before should be the first line of the block"
1894 );
1895 }
1896
1897 check_warnings_have_fixes(content2);
1899
1900 let fixed_content2 = fix(content2);
1901 assert_eq!(
1902 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1903 "Fix added extra blank before. Got:\n{fixed_content2}"
1904 );
1905 }
1906
1907 #[test]
1908 fn test_empty_input() {
1909 let content = "";
1910 let warnings = lint(content);
1911 assert_eq!(warnings.len(), 0);
1912 let fixed_content = fix(content);
1913 assert_eq!(fixed_content, "");
1914 }
1915
1916 #[test]
1917 fn test_only_list() {
1918 let content = "- Item 1\n- Item 2";
1919 let warnings = lint(content);
1920 assert_eq!(warnings.len(), 0);
1921 let fixed_content = fix(content);
1922 assert_eq!(fixed_content, content);
1923 }
1924
1925 #[test]
1928 fn test_fix_complex_nested_blockquote() {
1929 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1931 let warnings = lint(content);
1932 assert_eq!(
1933 warnings.len(),
1934 1,
1935 "Should warn for missing preceding blank only. Got: {warnings:?}"
1936 );
1937
1938 check_warnings_have_fixes(content);
1940
1941 let fixed_content = fix(content);
1942 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1944 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1945
1946 let warnings_after_fix = lint(&fixed_content);
1947 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1948 }
1949
1950 #[test]
1951 fn test_fix_mixed_list_markers() {
1952 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1955 let warnings = lint(content);
1956 assert!(
1958 !warnings.is_empty(),
1959 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1960 );
1961
1962 check_warnings_have_fixes(content);
1964
1965 let fixed_content = fix(content);
1966 assert!(
1968 fixed_content.contains("Text\n\n-"),
1969 "Fix should add blank line before first list item"
1970 );
1971
1972 let warnings_after_fix = lint(&fixed_content);
1974 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1975 }
1976
1977 #[test]
1978 fn test_fix_ordered_list_with_different_numbers() {
1979 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1981 let warnings = lint(content);
1982 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1983
1984 check_warnings_have_fixes(content);
1986
1987 let fixed_content = fix(content);
1988 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1989 assert_eq!(
1990 fixed_content, expected,
1991 "Fix should handle ordered lists with non-sequential numbers"
1992 );
1993
1994 let warnings_after_fix = lint(&fixed_content);
1996 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1997 }
1998
1999 #[test]
2000 fn test_fix_list_with_code_blocks_inside() {
2001 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
2003 let warnings = lint(content);
2004 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
2005
2006 check_warnings_have_fixes(content);
2008
2009 let fixed_content = fix(content);
2010 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
2011 assert_eq!(
2012 fixed_content, expected,
2013 "Fix should handle lists with internal code blocks"
2014 );
2015
2016 let warnings_after_fix = lint(&fixed_content);
2018 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2019 }
2020
2021 #[test]
2022 fn test_fix_deeply_nested_lists() {
2023 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
2025 let warnings = lint(content);
2026 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
2027
2028 check_warnings_have_fixes(content);
2030
2031 let fixed_content = fix(content);
2032 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
2033 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
2034
2035 let warnings_after_fix = lint(&fixed_content);
2037 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2038 }
2039
2040 #[test]
2041 fn test_fix_list_with_multiline_items() {
2042 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
2045 let warnings = lint(content);
2046 assert_eq!(
2047 warnings.len(),
2048 1,
2049 "Should only warn for missing blank before list (trailing text is lazy continuation)"
2050 );
2051
2052 check_warnings_have_fixes(content);
2054
2055 let fixed_content = fix(content);
2056 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
2057 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
2058
2059 let warnings_after_fix = lint(&fixed_content);
2061 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2062 }
2063
2064 #[test]
2065 fn test_fix_list_at_document_boundaries() {
2066 let content1 = "- Item 1\n- Item 2";
2068 let warnings1 = lint(content1);
2069 assert_eq!(
2070 warnings1.len(),
2071 0,
2072 "List at document start should not need blank before"
2073 );
2074 let fixed1 = fix(content1);
2075 assert_eq!(fixed1, content1, "No fix needed for list at start");
2076
2077 let content2 = "Text\n- Item 1\n- Item 2";
2079 let warnings2 = lint(content2);
2080 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
2081 check_warnings_have_fixes(content2);
2082 let fixed2 = fix(content2);
2083 assert_eq!(
2084 fixed2, "Text\n\n- Item 1\n- Item 2",
2085 "Should add blank before list at end"
2086 );
2087 }
2088
2089 #[test]
2090 fn test_fix_preserves_existing_blank_lines() {
2091 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
2092 let warnings = lint(content);
2093 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
2094 let fixed_content = fix(content);
2095 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
2096 }
2097
2098 #[test]
2099 fn test_fix_handles_tabs_and_spaces() {
2100 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
2103 let warnings = lint(content);
2104 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
2106
2107 check_warnings_have_fixes(content);
2109
2110 let fixed_content = fix(content);
2111 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
2114 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
2115
2116 let warnings_after_fix = lint(&fixed_content);
2118 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
2119 }
2120
2121 #[test]
2122 fn test_fix_warning_objects_have_correct_ranges() {
2123 let content = "Text\n- Item 1\n- Item 2\nText";
2125 let warnings = lint(content);
2126 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
2127
2128 for warning in &warnings {
2130 assert!(warning.fix.is_some(), "Warning should have fix");
2131 let fix = warning.fix.as_ref().unwrap();
2132 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
2133 assert!(
2134 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
2135 "Fix should have replacement or be insertion"
2136 );
2137 }
2138 }
2139
2140 #[test]
2141 fn test_fix_idempotent() {
2142 let content = "Text\n- Item 1\n- Item 2\nText";
2144
2145 let fixed_once = fix(content);
2147 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
2148
2149 let fixed_twice = fix(&fixed_once);
2151 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
2152
2153 let warnings_after_fix = lint(&fixed_once);
2155 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
2156 }
2157
2158 #[test]
2159 fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
2160 let rule = MD032BlanksAroundLists::default();
2161 for (content, expected) in [
2162 ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
2163 (
2164 "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
2165 "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
2166 ),
2167 ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
2168 ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
2169 ] {
2170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2171 let warnings = rule.check(&ctx).unwrap();
2172 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
2173 assert_eq!(edited, expected);
2174 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2175 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
2176 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
2177 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2178 }
2179 }
2180
2181 #[test]
2182 fn test_fix_respects_inline_config_at_each_list_boundary() {
2183 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2184
2185 let rule = MD032BlanksAroundLists::default();
2186 for (content, expected, warning_lines) in [
2189 (
2190 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
2191 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
2192 vec![4],
2193 ),
2194 (
2195 "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2196 "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2197 vec![2],
2198 ),
2199 (
2200 "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
2201 "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
2202 vec![2, 8],
2203 ),
2204 (
2205 "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
2206 "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
2207 vec![2, 8],
2208 ),
2209 (
2210 "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
2215 "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
2216 vec![3],
2217 ),
2218 ] {
2219 for ending in ["\n", "\r\n"] {
2220 for final_newline in [true, false] {
2221 let content = if final_newline {
2222 content
2223 } else {
2224 content.trim_end_matches('\n')
2225 };
2226 let expected = if final_newline {
2227 expected
2228 } else {
2229 expected.trim_end_matches('\n')
2230 };
2231 let content = content.replace('\n', ending);
2232 let expected = expected.replace('\n', ending);
2233 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2234 let warnings =
2235 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
2236 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
2237 assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
2238 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2239 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2240 assert!(
2241 filter_warnings_by_inline_config(
2242 rule.check(&fixed_ctx).unwrap(),
2243 fixed_ctx.inline_config(),
2244 rule.name()
2245 )
2246 .is_empty()
2247 );
2248 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2249 }
2250 }
2251 }
2252 }
2253
2254 #[test]
2255 fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
2256 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2257
2258 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
2259 allow_lazy_continuation: false,
2260 });
2261 let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
2262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2263 let raw = rule.check(&ctx).unwrap();
2264 assert_eq!(raw.len(), 1);
2265 assert!(raw[0].fix.is_some());
2266 let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
2267 assert!(warnings.is_empty());
2268 assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
2269 assert_eq!(rule.fix(&ctx).unwrap(), content);
2270 }
2271
2272 #[test]
2273 fn test_fix_with_normalized_line_endings() {
2274 let content = "Text\n- Item 1\n- Item 2\nText";
2278 let warnings = lint(content);
2279 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
2280
2281 check_warnings_have_fixes(content);
2283
2284 let fixed_content = fix(content);
2285 let expected = "Text\n\n- Item 1\n- Item 2\nText";
2287 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
2288 }
2289
2290 #[test]
2291 fn test_fix_preserves_final_newline() {
2292 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
2295 let fixed_with_newline = fix(content_with_newline);
2296 assert!(
2297 fixed_with_newline.ends_with('\n'),
2298 "Fix should preserve final newline when present"
2299 );
2300 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
2302
2303 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
2305 let fixed_without_newline = fix(content_without_newline);
2306 assert!(
2307 !fixed_without_newline.ends_with('\n'),
2308 "Fix should not add final newline when not present"
2309 );
2310 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
2312 }
2313
2314 #[test]
2315 fn test_fix_multiline_list_items_no_indent() {
2316 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";
2317
2318 let warnings = lint(content);
2319 assert_eq!(
2321 warnings.len(),
2322 0,
2323 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
2324 );
2325
2326 let fixed_content = fix(content);
2327 assert_eq!(
2329 fixed_content, content,
2330 "Should not modify correctly formatted multi-line list items"
2331 );
2332 }
2333
2334 #[test]
2335 fn test_nested_list_with_lazy_continuation() {
2336 let content = r#"# Test
2342
2343- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2344 1. Switch/case dispatcher statements (original Phase 3.2)
2345 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2346`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2347 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2348 references"#;
2349
2350 let warnings = lint(content);
2351 let md032_warnings: Vec<_> = warnings
2354 .iter()
2355 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2356 .collect();
2357 assert_eq!(
2358 md032_warnings.len(),
2359 0,
2360 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2361 );
2362 }
2363
2364 #[test]
2365 fn test_pipes_in_code_spans_not_detected_as_table() {
2366 let content = r#"# Test
2368
2369- Item with `a | b` inline code
2370 - Nested item should work
2371
2372"#;
2373
2374 let warnings = lint(content);
2375 let md032_warnings: Vec<_> = warnings
2376 .iter()
2377 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2378 .collect();
2379 assert_eq!(
2380 md032_warnings.len(),
2381 0,
2382 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2383 );
2384 }
2385
2386 #[test]
2387 fn test_multiple_code_spans_with_pipes() {
2388 let content = r#"# Test
2390
2391- Item with `a | b` and `c || d` operators
2392 - Nested item should work
2393
2394"#;
2395
2396 let warnings = lint(content);
2397 let md032_warnings: Vec<_> = warnings
2398 .iter()
2399 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2400 .collect();
2401 assert_eq!(
2402 md032_warnings.len(),
2403 0,
2404 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_actual_table_breaks_list() {
2410 let content = r#"# Test
2412
2413- Item before table
2414
2415| Col1 | Col2 |
2416|------|------|
2417| A | B |
2418
2419- Item after table
2420
2421"#;
2422
2423 let warnings = lint(content);
2424 let md032_warnings: Vec<_> = warnings
2426 .iter()
2427 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2428 .collect();
2429 assert_eq!(
2430 md032_warnings.len(),
2431 0,
2432 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2433 );
2434 }
2435
2436 #[test]
2437 fn test_thematic_break_not_lazy_continuation() {
2438 let content = r#"- Item 1
2441- Item 2
2442***
2443
2444More text.
2445"#;
2446
2447 let warnings = lint(content);
2448 let md032_warnings: Vec<_> = warnings
2449 .iter()
2450 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2451 .collect();
2452 assert_eq!(
2453 md032_warnings.len(),
2454 1,
2455 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2456 );
2457 assert!(
2458 md032_warnings[0].message.contains("followed by blank line"),
2459 "Warning should be about missing blank after list"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_thematic_break_with_blank_line() {
2465 let content = r#"- Item 1
2467- Item 2
2468
2469***
2470
2471More text.
2472"#;
2473
2474 let warnings = lint(content);
2475 let md032_warnings: Vec<_> = warnings
2476 .iter()
2477 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2478 .collect();
2479 assert_eq!(
2480 md032_warnings.len(),
2481 0,
2482 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2483 );
2484 }
2485
2486 #[test]
2487 fn test_various_thematic_break_styles() {
2488 for hr in ["---", "***", "___"] {
2493 let content = format!(
2494 r#"- Item 1
2495- Item 2
2496{hr}
2497
2498More text.
2499"#
2500 );
2501
2502 let warnings = lint(&content);
2503 let md032_warnings: Vec<_> = warnings
2504 .iter()
2505 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2506 .collect();
2507 assert_eq!(
2508 md032_warnings.len(),
2509 1,
2510 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2511 );
2512 }
2513 }
2514
2515 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2518 let rule = MD032BlanksAroundLists::from_config_struct(config);
2519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2520 rule.check(&ctx).expect("Lint check failed")
2521 }
2522
2523 fn fix_with_config(content: &str, config: MD032Config) -> String {
2524 let rule = MD032BlanksAroundLists::from_config_struct(config);
2525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526 rule.fix(&ctx).expect("Lint fix failed")
2527 }
2528
2529 #[test]
2530 fn test_lazy_continuation_allowed_by_default() {
2531 let content = "# Heading\n\n1. List\nSome text.";
2533 let warnings = lint(content);
2534 assert_eq!(
2535 warnings.len(),
2536 0,
2537 "Default behavior should allow lazy continuation. Got: {warnings:?}"
2538 );
2539 }
2540
2541 #[test]
2542 fn test_lazy_continuation_disallowed() {
2543 let content = "# Heading\n\n1. List\nSome text.";
2545 let config = MD032Config {
2546 allow_lazy_continuation: false,
2547 };
2548 let warnings = lint_with_config(content, config);
2549 assert_eq!(
2550 warnings.len(),
2551 1,
2552 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2553 );
2554 assert!(
2555 warnings[0].message.contains("Lazy continuation"),
2556 "Warning message should mention lazy continuation"
2557 );
2558 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2559 }
2560
2561 #[test]
2562 fn test_lazy_continuation_fix() {
2563 let content = "# Heading\n\n1. List\nSome text.";
2565 let config = MD032Config {
2566 allow_lazy_continuation: false,
2567 };
2568 let fixed = fix_with_config(content, config.clone());
2569 assert_eq!(
2571 fixed, "# Heading\n\n1. List\n Some text.",
2572 "Fix should add proper indentation to lazy continuation"
2573 );
2574
2575 let warnings_after = lint_with_config(&fixed, config);
2577 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2578 }
2579
2580 #[test]
2581 fn test_lazy_continuation_multiple_lines() {
2582 let content = "- Item 1\nLine 2\nLine 3";
2584 let config = MD032Config {
2585 allow_lazy_continuation: false,
2586 };
2587 let warnings = lint_with_config(content, config.clone());
2588 assert_eq!(
2590 warnings.len(),
2591 2,
2592 "Should warn for each lazy continuation line. Got: {warnings:?}"
2593 );
2594
2595 let fixed = fix_with_config(content, config.clone());
2596 assert_eq!(
2598 fixed, "- Item 1\n Line 2\n Line 3",
2599 "Fix should add proper indentation to lazy continuation lines"
2600 );
2601
2602 let warnings_after = lint_with_config(&fixed, config);
2604 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2605 }
2606
2607 #[test]
2608 fn test_lazy_continuation_with_indented_content() {
2609 let content = "- Item 1\n Indented content\nLazy text";
2611 let config = MD032Config {
2612 allow_lazy_continuation: false,
2613 };
2614 let warnings = lint_with_config(content, config);
2615 assert_eq!(
2616 warnings.len(),
2617 1,
2618 "Should warn for lazy text after indented content. Got: {warnings:?}"
2619 );
2620 }
2621
2622 #[test]
2623 fn test_lazy_continuation_properly_separated() {
2624 let content = "- Item 1\n\nSome text.";
2626 let config = MD032Config {
2627 allow_lazy_continuation: false,
2628 };
2629 let warnings = lint_with_config(content, config);
2630 assert_eq!(
2631 warnings.len(),
2632 0,
2633 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2634 );
2635 }
2636
2637 #[test]
2640 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2641 let content = "1) First item\nLazy continuation";
2643 let config = MD032Config {
2644 allow_lazy_continuation: false,
2645 };
2646 let warnings = lint_with_config(content, config.clone());
2647 assert_eq!(
2648 warnings.len(),
2649 1,
2650 "Should warn for lazy continuation with parenthesis marker"
2651 );
2652
2653 let fixed = fix_with_config(content, config);
2654 assert_eq!(fixed, "1) First item\n Lazy continuation");
2656 }
2657
2658 #[test]
2659 fn test_lazy_continuation_followed_by_another_list() {
2660 let content = "- Item 1\nSome text\n- Item 2";
2666 let config = MD032Config {
2667 allow_lazy_continuation: false,
2668 };
2669 let warnings = lint_with_config(content, config);
2670 assert_eq!(
2672 warnings.len(),
2673 1,
2674 "Should warn about lazy continuation within list. Got: {warnings:?}"
2675 );
2676 assert!(
2677 warnings[0].message.contains("Lazy continuation"),
2678 "Warning should be about lazy continuation"
2679 );
2680 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2681 }
2682
2683 #[test]
2684 fn test_lazy_continuation_multiple_in_document() {
2685 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2690 let config = MD032Config {
2691 allow_lazy_continuation: false,
2692 };
2693 let warnings = lint_with_config(content, config.clone());
2694 assert_eq!(
2696 warnings.len(),
2697 2,
2698 "Should warn for both lazy continuations. Got: {warnings:?}"
2699 );
2700
2701 let fixed = fix_with_config(content, config.clone());
2702 assert!(
2704 fixed.contains(" Lazy 1"),
2705 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2706 );
2707 assert!(
2708 fixed.contains(" Lazy 2"),
2709 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2710 );
2711
2712 let warnings_after = lint_with_config(&fixed, config);
2713 assert_eq!(
2715 warnings_after.len(),
2716 0,
2717 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2718 );
2719 }
2720
2721 #[test]
2722 fn test_lazy_continuation_end_of_document_no_newline() {
2723 let content = "- Item\nNo trailing newline";
2725 let config = MD032Config {
2726 allow_lazy_continuation: false,
2727 };
2728 let warnings = lint_with_config(content, config.clone());
2729 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2730
2731 let fixed = fix_with_config(content, config);
2732 assert_eq!(fixed, "- Item\n No trailing newline");
2734 }
2735
2736 #[test]
2737 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2738 let content = "- Item 1\n---";
2741 let config = MD032Config {
2742 allow_lazy_continuation: false,
2743 };
2744 let warnings = lint_with_config(content, config.clone());
2745 assert_eq!(
2747 warnings.len(),
2748 1,
2749 "List should need blank line before thematic break. Got: {warnings:?}"
2750 );
2751
2752 let fixed = fix_with_config(content, config);
2754 assert_eq!(fixed, "- Item 1\n\n---");
2755 }
2756
2757 #[test]
2758 fn test_lazy_continuation_heading_not_flagged() {
2759 let content = "- Item 1\n# Heading";
2762 let config = MD032Config {
2763 allow_lazy_continuation: false,
2764 };
2765 let warnings = lint_with_config(content, config);
2766 assert!(
2769 warnings.iter().all(|w| !w.message.contains("lazy")),
2770 "Heading should not trigger lazy continuation warning"
2771 );
2772 }
2773
2774 #[test]
2775 fn test_lazy_continuation_mixed_list_types() {
2776 let content = "- Unordered\n1. Ordered\nLazy text";
2778 let config = MD032Config {
2779 allow_lazy_continuation: false,
2780 };
2781 let warnings = lint_with_config(content, config.clone());
2782 assert!(!warnings.is_empty(), "Should warn about structure issues");
2783 }
2784
2785 #[test]
2786 fn test_lazy_continuation_deep_nesting() {
2787 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2789 let config = MD032Config {
2790 allow_lazy_continuation: false,
2791 };
2792 let warnings = lint_with_config(content, config.clone());
2793 assert!(
2794 !warnings.is_empty(),
2795 "Should warn about lazy continuation after nested list"
2796 );
2797
2798 let fixed = fix_with_config(content, config.clone());
2799 let warnings_after = lint_with_config(&fixed, config);
2800 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2801 }
2802
2803 #[test]
2804 fn test_lazy_continuation_with_emphasis_in_text() {
2805 let content = "- Item\n*emphasized* continuation";
2807 let config = MD032Config {
2808 allow_lazy_continuation: false,
2809 };
2810 let warnings = lint_with_config(content, config.clone());
2811 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2812
2813 let fixed = fix_with_config(content, config);
2814 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2816 }
2817
2818 #[test]
2819 fn test_lazy_continuation_with_code_span() {
2820 let content = "- Item\n`code` continuation";
2822 let config = MD032Config {
2823 allow_lazy_continuation: false,
2824 };
2825 let warnings = lint_with_config(content, config.clone());
2826 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2827
2828 let fixed = fix_with_config(content, config);
2829 assert_eq!(fixed, "- Item\n `code` continuation");
2831 }
2832
2833 #[test]
2840 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2841 let content = r#"1. Create a new Chat conversation:
2844 - On the sidebar, select **New Chat**.
2845 - In the box, type `/new`.
2846 A new Chat conversation replaces the previous one.
28471. Under the Chat text box, turn off the toggle."#;
2848 let config = MD032Config {
2849 allow_lazy_continuation: false,
2850 };
2851 let warnings = lint_with_config(content, config);
2852 let lazy_warnings: Vec<_> = warnings
2854 .iter()
2855 .filter(|w| w.message.contains("Lazy continuation"))
2856 .collect();
2857 assert!(
2858 !lazy_warnings.is_empty(),
2859 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2860 );
2861 assert!(
2862 lazy_warnings.iter().any(|w| w.line == 4),
2863 "Should warn on line 4. Got: {lazy_warnings:?}"
2864 );
2865 }
2866
2867 #[test]
2868 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2869 let content = r#"- `field`: Is the specific key:
2872 - `password`: Accesses the password.
2873 - `api_key`: Accesses the api_key.
2874 `token`: Specifies which ID token to use.
2875- `version_id`: Is the unique identifier."#;
2876 let config = MD032Config {
2877 allow_lazy_continuation: false,
2878 };
2879 let warnings = lint_with_config(content, config);
2880 let lazy_warnings: Vec<_> = warnings
2882 .iter()
2883 .filter(|w| w.message.contains("Lazy continuation"))
2884 .collect();
2885 assert!(
2886 !lazy_warnings.is_empty(),
2887 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2888 );
2889 assert!(
2890 lazy_warnings.iter().any(|w| w.line == 4),
2891 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2892 );
2893 }
2894
2895 #[test]
2896 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2897 let content = r#"- Check out the branch, and test locally.
2899 - If the MR requires significant modifications:
2900 - **Skip local testing** and review instead.
2901 - **Request verification** from the author.
2902 - **Identify the minimal change** needed.
2903 Your testing might result in opportunities.
2904- If you don't understand, _say so_."#;
2905 let config = MD032Config {
2906 allow_lazy_continuation: false,
2907 };
2908 let warnings = lint_with_config(content, config);
2909 let lazy_warnings: Vec<_> = warnings
2911 .iter()
2912 .filter(|w| w.message.contains("Lazy continuation"))
2913 .collect();
2914 assert!(
2915 !lazy_warnings.is_empty(),
2916 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2917 );
2918 assert!(
2919 lazy_warnings.iter().any(|w| w.line == 6),
2920 "Should warn on line 6. Got: {lazy_warnings:?}"
2921 );
2922 }
2923
2924 #[test]
2925 fn test_issue295_ordered_list_nested_bullets_continuation() {
2926 let content = r#"# Test
2929
29301. First item.
2931 - Nested A.
2932 - Nested B.
2933 Continuation at outer level.
29341. Second item."#;
2935 let config = MD032Config {
2936 allow_lazy_continuation: false,
2937 };
2938 let warnings = lint_with_config(content, config);
2939 let lazy_warnings: Vec<_> = warnings
2941 .iter()
2942 .filter(|w| w.message.contains("Lazy continuation"))
2943 .collect();
2944 assert!(
2945 !lazy_warnings.is_empty(),
2946 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2947 );
2948 assert!(
2950 lazy_warnings.iter().any(|w| w.line == 6),
2951 "Should warn on line 6. Got: {lazy_warnings:?}"
2952 );
2953 }
2954
2955 #[test]
2956 fn test_issue295_multiple_lazy_lines_after_nested() {
2957 let content = r#"1. The device client receives a response.
2959 - Those defined by OAuth Framework.
2960 - Those specific to device authorization.
2961 Those error responses are described below.
2962 For more information on each response,
2963 see the documentation.
29641. Next step in the process."#;
2965 let config = MD032Config {
2966 allow_lazy_continuation: false,
2967 };
2968 let warnings = lint_with_config(content, config);
2969 let lazy_warnings: Vec<_> = warnings
2971 .iter()
2972 .filter(|w| w.message.contains("Lazy continuation"))
2973 .collect();
2974 assert!(
2975 lazy_warnings.len() >= 3,
2976 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2977 lazy_warnings.len()
2978 );
2979 }
2980
2981 #[test]
2982 fn test_issue295_properly_indented_not_lazy() {
2983 let content = r#"1. First item.
2985 - Nested A.
2986 - Nested B.
2987
2988 Properly indented continuation.
29891. Second item."#;
2990 let config = MD032Config {
2991 allow_lazy_continuation: false,
2992 };
2993 let warnings = lint_with_config(content, config);
2994 let lazy_warnings: Vec<_> = warnings
2996 .iter()
2997 .filter(|w| w.message.contains("Lazy continuation"))
2998 .collect();
2999 assert_eq!(
3000 lazy_warnings.len(),
3001 0,
3002 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
3003 );
3004 }
3005
3006 #[test]
3013 fn test_html_comment_before_list_with_preceding_blank() {
3014 let content = "Some text.\n\n<!-- comment -->\n- List item";
3017 let warnings = lint(content);
3018 assert_eq!(
3019 warnings.len(),
3020 0,
3021 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
3022 );
3023 }
3024
3025 #[test]
3026 fn test_html_comment_after_list_with_following_blank() {
3027 let content = "- List item\n<!-- comment -->\n\nSome text.";
3029 let warnings = lint(content);
3030 assert_eq!(
3031 warnings.len(),
3032 0,
3033 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
3034 );
3035 }
3036
3037 #[test]
3038 fn test_list_inside_html_comment_ignored() {
3039 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
3041 let warnings = lint(content);
3042 assert_eq!(
3043 warnings.len(),
3044 0,
3045 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
3046 );
3047 }
3048
3049 #[test]
3050 fn test_multiline_html_comment_before_list() {
3051 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
3053 let warnings = lint(content);
3054 assert_eq!(
3055 warnings.len(),
3056 0,
3057 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_a_comment_line_separates_the_paragraph_from_the_list() {
3063 let content = "Some text.\n<!-- comment -->\n- List item";
3066 let warnings = lint(content);
3067 assert_eq!(
3068 warnings.len(),
3069 0,
3070 "A comment-only line separates the blocks around it. Got: {warnings:?}"
3071 );
3072 }
3073
3074 #[test]
3075 fn test_a_line_carrying_text_beside_a_comment_still_warns() {
3076 let content = "Some text. <!-- comment -->\n- List item";
3079 let warnings = lint(content);
3080 assert_eq!(
3081 warnings.len(),
3082 1,
3083 "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
3084 );
3085 assert!(
3086 warnings[0].message.contains("preceded by blank line"),
3087 "Should be 'preceded by blank line' warning"
3088 );
3089 }
3090
3091 #[test]
3092 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
3093 let content = "- List item\n<!-- comment -->\nSome text.";
3096 let warnings = lint(content);
3097 assert_eq!(
3098 warnings.len(),
3099 0,
3100 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
3101 );
3102 }
3103
3104 #[test]
3105 fn test_list_followed_by_heading_through_comment_should_warn() {
3106 let content = "- List item\n<!-- comment -->\n# Heading";
3108 let warnings = lint(content);
3109 assert!(
3112 warnings.len() <= 1,
3113 "Should handle heading after comment gracefully. Got: {warnings:?}"
3114 );
3115 }
3116
3117 #[test]
3118 fn test_html_comment_between_list_and_text_both_directions() {
3119 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
3121 let warnings = lint(content);
3122 assert_eq!(
3123 warnings.len(),
3124 0,
3125 "Should not warn with proper separation through comments. Got: {warnings:?}"
3126 );
3127 }
3128
3129 #[test]
3130 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
3131 let content = "Text.\n\n<!-- comment -->\n- Item";
3133 let fixed = fix(content);
3134 assert_eq!(fixed, content, "Fix should not modify already-correct content");
3135 }
3136
3137 #[test]
3138 fn test_html_comment_fix_adds_blank_when_needed() {
3139 let separated = "Text.\n<!-- comment -->\n- Item";
3142 assert_eq!(
3143 fix(separated),
3144 separated,
3145 "A comment-only line needs no blank line inserted around it"
3146 );
3147
3148 let content = "Text. <!-- comment -->\n- Item";
3149 let fixed = fix(content);
3150 assert!(
3151 fixed.contains("Text. <!-- comment -->\n\n- Item"),
3152 "Fix should add blank line before list. Got: {fixed}"
3153 );
3154 }
3155
3156 #[test]
3157 fn test_ordered_list_inside_html_comment() {
3158 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
3160 let warnings = lint(content);
3161 assert_eq!(
3162 warnings.len(),
3163 0,
3164 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
3165 );
3166 }
3167
3168 #[test]
3175 fn test_blockquote_list_exit_no_warning() {
3176 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
3178 let warnings = lint(content);
3179 assert_eq!(
3180 warnings.len(),
3181 0,
3182 "Should not warn when exiting blockquote. Got: {warnings:?}"
3183 );
3184 }
3185
3186 #[test]
3187 fn test_nested_blockquote_list_exit() {
3188 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
3190 let warnings = lint(content);
3191 assert_eq!(
3192 warnings.len(),
3193 0,
3194 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
3195 );
3196 }
3197
3198 #[test]
3199 fn test_blockquote_same_level_no_warning() {
3200 let content = "> - item 1\n> - item 2\n> Text after";
3203 let warnings = lint(content);
3204 assert_eq!(
3205 warnings.len(),
3206 0,
3207 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
3208 );
3209 }
3210
3211 #[test]
3212 fn test_blockquote_list_with_special_chars() {
3213 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
3215 let warnings = lint(content);
3216 assert_eq!(
3217 warnings.len(),
3218 0,
3219 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
3220 );
3221 }
3222
3223 #[test]
3224 fn test_lazy_continuation_whitespace_only_line() {
3225 let content = "- Item\n \nText after whitespace-only line";
3228 let config = MD032Config {
3229 allow_lazy_continuation: false,
3230 };
3231 let warnings = lint_with_config(content, config);
3232 assert_eq!(
3234 warnings.len(),
3235 0,
3236 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
3237 );
3238 }
3239
3240 #[test]
3241 fn test_lazy_continuation_blockquote_context() {
3242 let content = "> - Item\n> Lazy in quote";
3244 let config = MD032Config {
3245 allow_lazy_continuation: false,
3246 };
3247 let warnings = lint_with_config(content, config);
3248 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
3251 }
3252
3253 #[test]
3254 fn test_lazy_continuation_fix_preserves_content() {
3255 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
3257 let config = MD032Config {
3258 allow_lazy_continuation: false,
3259 };
3260 let fixed = fix_with_config(content, config);
3261 assert!(fixed.contains("<>&"), "Should preserve special chars");
3262 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
3263 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
3265 }
3266
3267 #[test]
3268 fn test_lazy_continuation_fix_idempotent() {
3269 let content = "- Item\nLazy";
3271 let config = MD032Config {
3272 allow_lazy_continuation: false,
3273 };
3274 let fixed_once = fix_with_config(content, config.clone());
3275 let fixed_twice = fix_with_config(&fixed_once, config);
3276 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
3277 }
3278
3279 #[test]
3280 fn test_lazy_continuation_config_default_allows() {
3281 let content = "- Item\nLazy text that continues";
3283 let default_config = MD032Config::default();
3284 assert!(
3285 default_config.allow_lazy_continuation,
3286 "Default should allow lazy continuation"
3287 );
3288 let warnings = lint_with_config(content, default_config);
3289 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
3290 }
3291
3292 #[test]
3293 fn test_lazy_continuation_after_multi_line_item() {
3294 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
3296 let config = MD032Config {
3297 allow_lazy_continuation: false,
3298 };
3299 let warnings = lint_with_config(content, config.clone());
3300 assert_eq!(
3301 warnings.len(),
3302 1,
3303 "Should warn only for the lazy line, not the indented line"
3304 );
3305 }
3306
3307 #[test]
3309 fn test_blockquote_list_with_continuation_and_nested() {
3310 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
3313 let warnings = lint(content);
3314 assert_eq!(
3315 warnings.len(),
3316 0,
3317 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
3318 );
3319 }
3320
3321 #[test]
3322 fn test_blockquote_list_simple() {
3323 let content = "> - item 1\n> - item 2";
3325 let warnings = lint(content);
3326 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
3327 }
3328
3329 #[test]
3330 fn test_blockquote_list_with_continuation_only() {
3331 let content = "> - item 1\n> continuation\n> - item 2";
3333 let warnings = lint(content);
3334 assert_eq!(
3335 warnings.len(),
3336 0,
3337 "Blockquoted list with continuation should have no warnings"
3338 );
3339 }
3340
3341 #[test]
3342 fn test_blockquote_list_with_lazy_continuation() {
3343 let content = "> - item 1\n> lazy continuation\n> - item 2";
3345 let warnings = lint(content);
3346 assert_eq!(
3347 warnings.len(),
3348 0,
3349 "Blockquoted list with lazy continuation should have no warnings"
3350 );
3351 }
3352
3353 #[test]
3354 fn test_nested_blockquote_list() {
3355 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
3357 let warnings = lint(content);
3358 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3359 }
3360
3361 #[test]
3362 fn test_blockquote_list_needs_preceding_blank() {
3363 let content = "> Text before\n> - item 1\n> - item 2";
3365 let warnings = lint(content);
3366 assert_eq!(
3367 warnings.len(),
3368 1,
3369 "Should warn for missing blank before blockquoted list"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_blockquote_list_properly_separated() {
3375 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3377 let warnings = lint(content);
3378 assert_eq!(
3379 warnings.len(),
3380 0,
3381 "Properly separated blockquoted list should have no warnings"
3382 );
3383 }
3384
3385 #[test]
3386 fn test_blockquote_ordered_list() {
3387 let content = "> 1. item 1\n> continuation\n> 2. item 2";
3389 let warnings = lint(content);
3390 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3391 }
3392
3393 #[test]
3394 fn test_blockquote_list_with_empty_blockquote_line() {
3395 let content = "> - item 1\n>\n> - item 2";
3397 let warnings = lint(content);
3398 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3399 }
3400
3401 #[test]
3403 fn test_blockquote_list_multi_paragraph_items() {
3404 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
3407 let warnings = lint(content);
3408 assert_eq!(
3409 warnings.len(),
3410 0,
3411 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3412 );
3413 }
3414
3415 #[test]
3417 fn test_blockquote_ordered_list_multi_paragraph_items() {
3418 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
3419 let warnings = lint(content);
3420 assert_eq!(
3421 warnings.len(),
3422 0,
3423 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3424 );
3425 }
3426
3427 #[test]
3429 fn test_blockquote_list_multiple_continuations() {
3430 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
3431 let warnings = lint(content);
3432 assert_eq!(
3433 warnings.len(),
3434 0,
3435 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3436 );
3437 }
3438
3439 #[test]
3441 fn test_nested_blockquote_multi_paragraph_list() {
3442 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
3443 let warnings = lint(content);
3444 assert_eq!(
3445 warnings.len(),
3446 0,
3447 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3448 );
3449 }
3450
3451 #[test]
3453 fn test_triple_nested_blockquote_multi_paragraph_list() {
3454 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
3455 let warnings = lint(content);
3456 assert_eq!(
3457 warnings.len(),
3458 0,
3459 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3460 );
3461 }
3462
3463 #[test]
3465 fn test_blockquote_list_last_item_continuation() {
3466 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
3467 let warnings = lint(content);
3468 assert_eq!(
3469 warnings.len(),
3470 0,
3471 "Last item with continuation should have no warnings. Got: {warnings:?}"
3472 );
3473 }
3474
3475 #[test]
3477 fn test_blockquote_list_first_item_only_continuation() {
3478 let content = "> - Item 1\n> \n> Continuation of item 1\n";
3479 let warnings = lint(content);
3480 assert_eq!(
3481 warnings.len(),
3482 0,
3483 "Single item with continuation should have no warnings. Got: {warnings:?}"
3484 );
3485 }
3486
3487 #[test]
3491 fn test_blockquote_level_change_breaks_list() {
3492 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3494 let warnings = lint(content);
3495 assert!(
3499 warnings.len() <= 2,
3500 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3501 );
3502 }
3503
3504 #[test]
3506 fn test_exit_blockquote_needs_blank_before_list() {
3507 let content = "> Blockquote text\n\n- List outside blockquote\n";
3509 let warnings = lint(content);
3510 assert_eq!(
3511 warnings.len(),
3512 0,
3513 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3514 );
3515
3516 let content2 = "> Blockquote text\n- List outside blockquote\n";
3520 let warnings2 = lint(content2);
3521 assert!(
3523 warnings2.len() <= 1,
3524 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3525 );
3526 }
3527
3528 #[test]
3530 fn test_blockquote_multi_paragraph_all_unordered_markers() {
3531 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
3533 let warnings = lint(content_dash);
3534 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3535
3536 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
3538 let warnings = lint(content_asterisk);
3539 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3540
3541 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
3543 let warnings = lint(content_plus);
3544 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3545 }
3546
3547 #[test]
3549 fn test_blockquote_multi_paragraph_parenthesis_marker() {
3550 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
3551 let warnings = lint(content);
3552 assert_eq!(
3553 warnings.len(),
3554 0,
3555 "Parenthesis ordered markers should work. Got: {warnings:?}"
3556 );
3557 }
3558
3559 #[test]
3561 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3562 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
3564 let warnings = lint(content);
3565 assert_eq!(
3566 warnings.len(),
3567 0,
3568 "Multi-digit ordered list should work. Got: {warnings:?}"
3569 );
3570 }
3571
3572 #[test]
3574 fn test_blockquote_multi_paragraph_with_formatting() {
3575 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3576 let warnings = lint(content);
3577 assert_eq!(
3578 warnings.len(),
3579 0,
3580 "Continuation with inline formatting should work. Got: {warnings:?}"
3581 );
3582 }
3583
3584 #[test]
3586 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3587 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3588 let warnings = lint(content);
3589 assert_eq!(
3590 warnings.len(),
3591 0,
3592 "All items with continuations should work. Got: {warnings:?}"
3593 );
3594 }
3595
3596 #[test]
3598 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3599 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3600 let warnings = lint(content);
3601 assert_eq!(
3602 warnings.len(),
3603 0,
3604 "Lowercase continuation should work. Got: {warnings:?}"
3605 );
3606 }
3607
3608 #[test]
3610 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3611 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3612 let warnings = lint(content);
3613 assert_eq!(
3614 warnings.len(),
3615 0,
3616 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3617 );
3618 }
3619
3620 #[test]
3622 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3623 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3625 let warnings = lint(content);
3626 assert!(
3628 warnings.len() <= 1,
3629 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3630 );
3631 }
3632
3633 #[test]
3635 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3636 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3638 let warnings = lint(content);
3639 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3640 }
3641
3642 #[test]
3643 fn test_blockquote_list_varying_spaces_after_marker() {
3644 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3646 let warnings = lint(content);
3647 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3648 }
3649
3650 #[test]
3651 fn test_deeply_nested_blockquote_list() {
3652 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3654 let warnings = lint(content);
3655 assert_eq!(
3656 warnings.len(),
3657 0,
3658 "Deeply nested blockquote list should have no warnings"
3659 );
3660 }
3661
3662 #[test]
3663 fn test_blockquote_level_change_in_list() {
3664 let content = "> - item 1\n>> - deeper item\n> - item 2";
3666 let warnings = lint(content);
3669 assert!(
3670 !warnings.is_empty(),
3671 "Blockquote level change should break list and trigger warnings"
3672 );
3673 }
3674
3675 #[test]
3676 fn test_blockquote_list_with_code_span() {
3677 let content = "> - item with `code`\n> continuation\n> - item 2";
3679 let warnings = lint(content);
3680 assert_eq!(
3681 warnings.len(),
3682 0,
3683 "Blockquote list with code span should have no warnings"
3684 );
3685 }
3686
3687 #[test]
3688 fn test_code_span_html_comment_delimiters_no_false_positive() {
3689 let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
3695 let warnings = lint(content);
3696 assert_eq!(
3697 warnings.len(),
3698 0,
3699 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3700 );
3701 }
3702
3703 #[test]
3704 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3705 let content = "Text before list.\n\n1. A list item with `<!--` in a code span\n\n### Heading After\n\n1. Another item with `-->` in it\n";
3710 let fixed = fix(content);
3711 assert_eq!(
3712 fixed, content,
3713 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3714 );
3715 }
3716
3717 #[test]
3718 fn test_blockquote_list_at_document_end() {
3719 let content = "> Some text\n>\n> - item 1\n> - item 2";
3721 let warnings = lint(content);
3722 assert_eq!(
3723 warnings.len(),
3724 0,
3725 "Blockquote list at document end should have no warnings"
3726 );
3727 }
3728
3729 #[test]
3730 fn test_fix_preserves_blockquote_prefix_before_list() {
3731 let content = "> Text before
3733> - Item 1
3734> - Item 2";
3735 let fixed = fix(content);
3736
3737 let expected = "> Text before
3739>
3740> - Item 1
3741> - Item 2";
3742 assert_eq!(
3743 fixed, expected,
3744 "Fix should insert '>' blank line, not plain blank line"
3745 );
3746 }
3747
3748 #[test]
3749 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3750 let content = ">>> Triple nested
3753>>> - Item 1
3754>>> - Item 2
3755>>> More text";
3756 let fixed = fix(content);
3757
3758 let expected = ">>> Triple nested
3760>>>
3761>>> - Item 1
3762>>> - Item 2
3763>>> More text";
3764 assert_eq!(
3765 fixed, expected,
3766 "Fix should preserve triple-nested blockquote prefix '>>>'"
3767 );
3768 }
3769
3770 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3773 let rule = MD032BlanksAroundLists::default();
3774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3775 rule.check(&ctx).unwrap()
3776 }
3777
3778 #[test]
3779 fn test_quarto_list_after_div_open() {
3780 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3782 let warnings = lint_quarto(content);
3783 assert!(
3785 warnings.is_empty(),
3786 "Quarto div marker should be transparent before list: {warnings:?}"
3787 );
3788 }
3789
3790 #[test]
3791 fn test_quarto_list_before_div_close() {
3792 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3794 let warnings = lint_quarto(content);
3795 assert!(
3797 warnings.is_empty(),
3798 "Quarto div marker should be transparent after list: {warnings:?}"
3799 );
3800 }
3801
3802 #[test]
3803 fn test_quarto_list_needs_blank_without_div() {
3804 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3806 let warnings = lint_quarto(content);
3807 assert!(
3810 !warnings.is_empty(),
3811 "Should still require blank when not present: {warnings:?}"
3812 );
3813 }
3814
3815 #[test]
3816 fn test_quarto_list_in_callout_with_content() {
3817 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3819 let warnings = lint_quarto(content);
3820 assert!(
3821 warnings.is_empty(),
3822 "List with proper blanks inside callout should pass: {warnings:?}"
3823 );
3824 }
3825
3826 #[test]
3827 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3828 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3830 let warnings = lint(content); assert!(
3833 !warnings.is_empty(),
3834 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_quarto_nested_divs_with_list() {
3840 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3842 let warnings = lint_quarto(content);
3843 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3844 }
3845
3846 #[test]
3847 fn test_issue512_complex_nested_list_with_continuation() {
3848 let content = "\
3851- First level of indentation.
3852 - Second level of indentation.
3853 - Third level of indentation.
3854 - Third level of indentation.
3855
3856 Second level list continuation.
3857
3858 First level list continuation.
3859- First level of indentation.
3860";
3861 let warnings = lint(content);
3862 assert!(
3863 warnings.is_empty(),
3864 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3865 );
3866 }
3867
3868 #[test]
3869 fn test_issue512_continuation_at_root_level() {
3870 let content = "\
3874- First level.
3875 - Second level.
3876
3877 First level continuation.
3878
3879Root level lazy continuation.
3880- Another first level item.
3881";
3882 let warnings = lint(content);
3883 assert_eq!(
3884 warnings.len(),
3885 1,
3886 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3887 );
3888 assert_eq!(warnings[0].line, 7);
3889 }
3890
3891 #[test]
3892 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3893 let content = "\
3895- Level 1 item.
3896 - Level 2 item.
3897 - Level 3 item.
3898
3899 Level 3 continuation.
3900
3901 Level 2 continuation.
3902
3903 Level 1 continuation (indented under marker).
3904- Another level 1 item.
3905";
3906 let warnings = lint(content);
3907 assert!(
3908 warnings.is_empty(),
3909 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3910 );
3911 }
3912
3913 #[test]
3914 fn test_pandoc_list_after_div_open() {
3915 let rule = MD032BlanksAroundLists::default();
3918 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3920 let warnings = rule.check(&ctx).unwrap();
3921 assert!(
3922 warnings.is_empty(),
3923 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3924 );
3925 }
3926
3927 #[test]
3928 fn test_md032_html_comment() {
3929 let rule = MD032BlanksAroundLists::default();
3930 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3931 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3932 let warnings = rule.check(&ctx).unwrap();
3933 assert!(
3934 warnings.is_empty(),
3935 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3936 );
3937 }
3938
3939 #[test]
3940 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3941 let rule = MD032BlanksAroundLists::default();
3947 let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
3948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3949 let warnings = rule.check(&ctx).unwrap();
3950 assert!(
3951 warnings.is_empty(),
3952 "admonition-nested ordered list should not be flagged: {warnings:?}"
3953 );
3954 }
3955
3956 #[test]
3957 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3958 let rule = MD032BlanksAroundLists::default();
3962 let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a period.\n 3. no error here\n";
3963 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3964 let warnings = rule.check(&ctx).unwrap();
3965 assert!(
3966 warnings.is_empty(),
3967 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3968 );
3969 }
3970
3971 #[test]
3972 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3973 let rule = MD032BlanksAroundLists::default();
3975 let content = "1. no error here\n\n=== \"Tab A\"\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
3976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3977 let warnings = rule.check(&ctx).unwrap();
3978 assert!(
3979 warnings.is_empty(),
3980 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3981 );
3982 }
3983
3984 #[test]
3985 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3986 let rule = MD032BlanksAroundLists::default();
3992 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3994 let warnings = rule.check(&ctx).unwrap();
3995 assert_eq!(
3996 warnings.len(),
3997 1,
3998 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3999 );
4000 }
4001
4002 #[test]
4003 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
4004 let rule = MD032BlanksAroundLists::default();
4009 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
4010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4011 let warnings = rule.check(&ctx).unwrap();
4012 assert_eq!(
4013 warnings.len(),
4014 1,
4015 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
4016 );
4017 }
4018
4019 #[test]
4020 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
4021 let rule = MD032BlanksAroundLists::default();
4026 let content = "1. no error here\n\n!!! example\n\n 1. item one that wraps\n onto a second line.\n 2. item two\n";
4027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4028 let warnings = rule.check(&ctx).unwrap();
4029 assert!(
4030 warnings.is_empty(),
4031 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
4032 );
4033 }
4034
4035 #[test]
4036 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
4037 let rule = MD032BlanksAroundLists::default();
4043 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
4044 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4045 let warnings = rule.check(&ctx).unwrap();
4046 assert_eq!(
4047 warnings.len(),
4048 1,
4049 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
4050 );
4051 assert_eq!(warnings[0].line, 4);
4052 assert!(warnings[0].message.contains("non-1"));
4053 }
4054
4055 #[test]
4056 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
4057 let rule = MD032BlanksAroundLists::default();
4063 let content = "1. no error here\n\n!!! example\n\n 1. no error here\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
4064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4065 let warnings = rule.check(&ctx).unwrap();
4066 assert!(
4067 warnings.is_empty(),
4068 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
4069 );
4070 }
4071
4072 #[test]
4073 fn test_standard_flavor_admonition_indented_list_unchanged() {
4074 let rule = MD032BlanksAroundLists::default();
4079 let content = "1. no error here\n\n!!! example\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n";
4080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4081 let warnings = rule.check(&ctx).unwrap();
4082 assert!(
4083 warnings.is_empty(),
4084 "indented code block under standard flavor should not be flagged: {warnings:?}"
4085 );
4086 }
4087
4088 #[test]
4089 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
4090 let rule = MD032BlanksAroundLists::default();
4095 let content = "1. no error here\n\n<div markdown=\"1\">\n\n 1. no error here.\n 2. error here because previous line ends with a \".\"\n 3. no error here\n\n</div>\n";
4096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
4097 let warnings = rule.check(&ctx).unwrap();
4098 assert_eq!(
4099 warnings.len(),
4100 1,
4101 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
4102 );
4103 assert_eq!(warnings[0].line, 6);
4104 }
4105
4106 #[test]
4107 fn test_pseudo_list_marker_after_list() {
4108 let content = indoc::indoc! {"
4109 - Item 1
4110 Item 1 content.
4111
4112 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
4113 8. Unsigned integer types wrap around on overflow; we strongly advise that they
4114 are not used except when those semantics are desired.
4115 "};
4116 let warnings = lint(content);
4117 assert!(
4118 warnings.is_empty(),
4119 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
4120 );
4121 }
4122
4123 #[test]
4124 fn test_pseudo_list_marker_without_preceding_list() {
4125 let content = indoc::indoc! {"
4126 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
4127 8. Unsigned integer types wrap around on overflow; we strongly advise that they
4128 are not used except when those semantics are desired.
4129 "};
4130 let warnings = lint(content);
4131 assert!(
4132 warnings.is_empty(),
4133 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
4134 );
4135 }
4136
4137 #[test]
4138 fn test_no_space_hash_continuation_line_stays_in_its_item() {
4139 let content = indoc::indoc! {"
4144 5. **`M.md`** - the deltas (esp. items #1,
4145 #2, #3, #5, #8).
4146
4147 ---
4148
4149 ## Plan
4150
4151 ### Phase 0
4152 - [ ] task one
4153 wrapped
4154 "};
4155 let warnings = lint(content);
4156 assert_eq!(
4157 warnings.len(),
4158 1,
4159 "only the task list is missing a blank line, got: {warnings:?}"
4160 );
4161 assert_eq!(warnings[0].line, 9);
4162 assert_eq!(warnings[0].message, "List should be preceded by blank line");
4163
4164 let expected = indoc::indoc! {"
4165 5. **`M.md`** - the deltas (esp. items #1,
4166 #2, #3, #5, #8).
4167
4168 ---
4169
4170 ## Plan
4171
4172 ### Phase 0
4173
4174 - [ ] task one
4175 wrapped
4176 "};
4177 assert_eq!(fix(content), expected);
4178 }
4179
4180 #[test]
4181 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
4182 let content = indoc::indoc! {"
4188 1. first
4189
4190 3. item
4191 continuation
4192
4193 1. nested
4194 2. nested
4195
4196 ## Heading
4197 - task
4198 "};
4199 let warnings = lint(content);
4200 assert_eq!(
4201 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4202 vec![10],
4203 "only the list after the heading is missing a blank line, got: {warnings:?}"
4204 );
4205
4206 let expected = indoc::indoc! {"
4207 1. first
4208
4209 3. item
4210 continuation
4211
4212 1. nested
4213 2. nested
4214
4215 ## Heading
4216
4217 - task
4218 "};
4219 assert_eq!(fix(content), expected);
4220 }
4221
4222 #[test]
4223 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
4224 let content = indoc::indoc! {"
4228 - item (esp. #1,
4229 #2, #3).
4230 - next item
4231
4232 ## Heading
4233 - task
4234 "};
4235 let warnings = lint(content);
4236 assert_eq!(
4237 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4238 vec![6],
4239 "only the list after the heading is missing a blank line, got: {warnings:?}"
4240 );
4241
4242 let expected = indoc::indoc! {"
4243 - item (esp. #1,
4244 #2, #3).
4245 - next item
4246
4247 ## Heading
4248
4249 - task
4250 "};
4251 assert_eq!(fix(content), expected);
4252 }
4253
4254 #[test]
4255 fn test_under_indented_continuation_lines_stay_in_their_item() {
4256 for content in [
4260 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
4261 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
4262 "- Item\n lazy continuation\n- another item\n",
4263 "> - Item\n> lazy continuation\n> - another item\n",
4264 ] {
4265 let warnings = lint(content);
4266 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4267 assert_eq!(fix(content), content, "{content:?}");
4268 }
4269 }
4270
4271 #[test]
4272 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
4273 let config = MD032Config {
4276 allow_lazy_continuation: false,
4277 };
4278 for (content, lazy_lines) in [
4279 ("- Item\n lazy continuation\n- another item\n", vec![2]),
4280 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
4281 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
4282 ] {
4283 let warnings = lint_with_config(content, config.clone());
4284 assert!(
4285 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
4286 "{content:?}: got {warnings:?}"
4287 );
4288 assert_eq!(
4289 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4290 lazy_lines,
4291 "{content:?}: got {warnings:?}"
4292 );
4293 }
4294 }
4295
4296 #[test]
4297 fn test_structural_line_at_short_indent_ends_the_list() {
4298 for (content, expected) in [
4302 ("1. item\n ---\n", "1. item\n\n ---\n"),
4303 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
4304 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4305 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4306 ] {
4307 let warnings = lint(content);
4308 assert_eq!(
4309 warnings
4310 .iter()
4311 .map(|w| (w.line, w.message.as_str()))
4312 .collect::<Vec<_>>(),
4313 vec![(1, "List should be followed by blank line")],
4314 "{content:?}: got {warnings:?}"
4315 );
4316 assert_eq!(fix(content), expected, "{content:?}");
4317 }
4318 }
4319
4320 #[test]
4321 fn test_html_block_at_short_indent_ends_the_list() {
4322 for (content, expected) in [
4329 (
4330 "- item\n<script>\nx\n</script>\n- next\n",
4331 "- item\n\n<script>\nx\n</script>\n\n- next\n",
4332 ),
4333 (
4334 "- item\n <script>\n x\n </script>\n- next\n",
4335 "- item\n\n <script>\n x\n </script>\n\n- next\n",
4336 ),
4337 (
4338 "- item\n <pre>\n x\n </pre>\n- next\n",
4339 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
4340 ),
4341 (
4342 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
4343 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4344 ),
4345 (
4346 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4347 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4348 ),
4349 ] {
4350 let warnings = lint(content);
4351 assert_eq!(
4352 warnings
4353 .iter()
4354 .map(|w| (w.line, w.message.as_str()))
4355 .collect::<Vec<_>>(),
4356 vec![
4357 (1, "List should be followed by blank line"),
4358 (5, "List should be preceded by blank line"),
4359 ],
4360 "{content:?}: got {warnings:?}"
4361 );
4362 assert_eq!(fix(content), expected, "{content:?}");
4363 }
4364 }
4365
4366 #[test]
4367 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4368 for content in [
4380 "100. item\n <div>\n101. next\n",
4381 "> 100. item\n> <div>\n> 101. next\n",
4382 "100. item\n <div>\ntext\n101. next\n",
4383 "- item\n<div.class>\n- next\n",
4384 "> - item\n> <div.class>\n> - next\n",
4385 "100. item\n\t<div>\n101. next\n",
4386 "- item\n \t<div>\n- next\n",
4387 "> - item\n> \t<div>\n> - next\n",
4388 "> - item\n>\t<div>\n> - next\n",
4389 "> 100. item\n> \t<div>\n> 101. next\n",
4390 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4391 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4392 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
4393 ] {
4394 let warnings = lint(content);
4395 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4396 assert_eq!(fix(content), content, "{content:?}");
4397 }
4398
4399 for (content, last_item_line) in [
4403 ("100. item\n <div>\n101. next\n", 1),
4404 ("> 100. item\n> <div>\n> 101. next\n", 1),
4405 ("- item\n <div>\n- next\n", 1),
4406 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4407 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4408 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
4409 ] {
4410 let warnings = lint(content);
4411 assert_eq!(
4412 warnings
4413 .iter()
4414 .map(|w| (w.line, w.message.as_str()))
4415 .collect::<Vec<_>>(),
4416 vec![(last_item_line, "List should be followed by blank line")],
4417 "{content:?}: got {warnings:?}"
4418 );
4419 }
4420 }
4421
4422 #[test]
4423 fn test_tab_indented_nested_list_stays_inside_its_item() {
4424 for content in [
4433 "* item text\n\t1. nested\n\t more\n",
4434 "* item text\n\tcontinuation\n\t1. nested\n",
4435 "1. item text\n\t- nested\n",
4436 "> * item text\n>\t1. nested\n",
4437 "> * item text\n> \t1. nested\n",
4438 "* item text\n\tcontinuation\n\t- nested\n",
4439 ] {
4440 let warnings = lint(content);
4441 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4442 assert_eq!(fix(content), content, "{content:?}");
4443 }
4444
4445 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
4448 let warnings = lint(content);
4449 assert_eq!(
4450 warnings
4451 .iter()
4452 .map(|w| (w.line, w.message.as_str()))
4453 .collect::<Vec<_>>(),
4454 vec![
4455 (1, "List should be followed by blank line"),
4456 (2, "List should be preceded by blank line"),
4457 ],
4458 "{content:?}: got {warnings:?}"
4459 );
4460 }
4461 }
4462
4463 #[test]
4464 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4465 for (content, expected) in [
4470 (
4471 "- item\n<div>\nx\n</div>\n- next\n",
4472 "- item\n\n<div>\nx\n</div>\n- next\n",
4473 ),
4474 (
4475 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4476 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4477 ),
4478 ] {
4479 let warnings = lint(content);
4480 assert_eq!(
4481 warnings
4482 .iter()
4483 .map(|w| (w.line, w.message.as_str()))
4484 .collect::<Vec<_>>(),
4485 vec![(1, "List should be followed by blank line")],
4486 "{content:?}: got {warnings:?}"
4487 );
4488 assert_eq!(fix(content), expected, "{content:?}");
4489 }
4490 }
4491
4492 #[test]
4493 fn test_html_block_at_content_column_is_item_content() {
4494 for content in [
4497 "- item\n <script>\n x\n </script>\n- next\n",
4498 "- item\n <div>\n x\n </div>\n- next\n",
4499 "1. item\n <pre>\n x\n </pre>\n2. next\n",
4500 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4501 ] {
4502 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4503 assert_eq!(fix(content), content, "{content:?}");
4504 }
4505 }
4506 #[test]
4507 fn test_ordered_fallback_fix_applies_advertised_insertions() {
4508 use crate::utils::fix_utils::apply_warning_fixes;
4509 let rule = MD032BlanksAroundLists::default();
4510 for body in [
4511 "First.\n3. Item\nLast.\n",
4512 "First.\n3. Item\n4. Second\nLast.\n",
4513 "First.\n3. Item.\n4. Second.\nLast.\n",
4514 "First.\n3. Item\n# Heading\n",
4515 "First.\n3. Item\nLast.\n\nSecond.\n8. Another\nTail.\n",
4516 ] {
4517 for ending in ["\n", "\r\n"] {
4518 for trailing in [true, false] {
4519 let source = format!("- Real\n\n{body}");
4520 let source = if trailing {
4521 source
4522 } else {
4523 source.trim_end_matches('\n').to_string()
4524 };
4525 let source = source.replace('\n', ending);
4526 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
4527 let warnings = rule.check(&ctx).unwrap();
4528 assert!(!warnings.is_empty());
4529 let expected = apply_warning_fixes(&source, &warnings).unwrap();
4530 assert_ne!(expected, source);
4531 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
4532 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
4533 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
4534 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
4535 }
4536 }
4537 }
4538 }
4539
4540 #[test]
4541 fn test_ordered_fallback_fix_keeps_literal_and_disabled_insertions_withheld() {
4542 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
4543 let rule = MD032BlanksAroundLists::default();
4544 for (flavor, source) in [
4545 (
4546 crate::config::MarkdownFlavor::Standard,
4547 "{% set unused=\"First.\n3. Literal\nLast.\" %}{{ unused|length }}\n\n- Real\n",
4548 ),
4549 (
4550 crate::config::MarkdownFlavor::MDX,
4551 "export const text = `First.\n3. Literal\nLast.`\n\n- Real\n",
4552 ),
4553 (
4554 crate::config::MarkdownFlavor::Standard,
4555 "- Real\n\nFirst.\n<!-- rumdl-disable-next-line MD032 -->\n3. Item\nLast.\n",
4556 ),
4557 (
4558 crate::config::MarkdownFlavor::Standard,
4559 "- Real\n\nFirst.\n3. Item <!-- rumdl-disable-line MD032 -->\nLast.\n",
4560 ),
4561 (crate::config::MarkdownFlavor::Standard, "First.\n3. Item\nLast.\n"),
4562 ] {
4563 for ending in ["\n", "\r\n"] {
4564 let source = source.replace('\n', ending);
4565 let ctx = LintContext::new(&source, flavor, None);
4566 let warnings =
4567 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
4568 assert!(warnings.is_empty(), "{source}: {warnings:?}");
4569 assert_eq!(apply_warning_fixes(&source, &warnings).unwrap(), source);
4570 assert_eq!(rule.fix(&ctx).unwrap(), source, "{source}");
4571 }
4572 }
4573 }
4574}
4575
4576#[cfg(test)]
4577mod configured_plan_regressions {
4578 use super::*;
4579
4580 #[test]
4581 fn cached_default_plan_never_crosses_rule_configuration() {
4582 for source in [
4583 "- item\nunindented continuation\n",
4584 "before\n\n- item\n\n3. third\n",
4585 "<!-- rumdl-configure-file {\"MD032\": {\"allow-lazy-continuation\": false}} -->\n- item\ntail\n",
4586 ] {
4587 for custom_first in [false, true] {
4588 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
4589 let default = MD032BlanksAroundLists::default();
4590 let custom = MD032BlanksAroundLists {
4591 config: MD032Config {
4592 allow_lazy_continuation: false,
4593 },
4594 };
4595 let rules = if custom_first {
4596 [&custom, &default, &custom, &default]
4597 } else {
4598 [&default, &custom, &default, &custom]
4599 };
4600 for rule in rules {
4601 assert_eq!(rule.check(&ctx).unwrap(), rule.check_native(&ctx));
4602 assert_eq!(
4603 rule.fix(&ctx).unwrap(),
4604 rule.fix_with_structure_impl(&ctx, &rule.withheld_fixes(&ctx))
4605 );
4606 }
4607 }
4608 }
4609 }
4610}