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};
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 name(&self) -> &'static str {
851 "MD032"
852 }
853
854 fn description(&self) -> &'static str {
855 "Lists should be surrounded by blank lines"
856 }
857
858 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
859 let (mut warnings, separating_from) = self.unguarded_warnings(ctx);
860 drop_structure_changing_fixes(ctx, &mut warnings[separating_from..], Allowed::Nothing);
861 Ok(warnings)
862 }
863
864 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
865 Ok(self.fix_with_structure_impl(ctx, &self.withheld_fixes(ctx)))
866 }
867
868 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
869 ctx.content.is_empty() || ctx.list_blocks.is_empty()
872 }
873
874 fn category(&self) -> RuleCategory {
875 RuleCategory::List
876 }
877
878 fn as_any(&self) -> &dyn std::any::Any {
879 self
880 }
881
882 crate::impl_rule_config_methods!(MD032Config);
883}
884
885impl MD032BlanksAroundLists {
886 fn unguarded_warnings(&self, ctx: &crate::lint_context::LintContext) -> (Vec<LintWarning>, usize) {
889 let lines = ctx.raw_lines();
890 if lines.is_empty() {
892 return (Vec::new(), 0);
893 }
894
895 let list_blocks = self.convert_list_blocks(ctx);
896
897 if list_blocks.is_empty() {
898 return (Vec::new(), 0);
899 }
900
901 let (mut warnings, separating_from) = self.perform_checks(ctx, lines, &list_blocks);
902
903 if !self.config.allow_lazy_continuation {
908 let lazy_cont_lines = ctx.lazy_continuation_lines();
909 let coverage = ListCoverage::new(&list_blocks);
910
911 for lazy_info in lazy_cont_lines.iter() {
912 let line_num = lazy_info.line_num;
913
914 if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
918 continue;
919 }
920
921 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
923 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
924
925 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
927 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
928 } else {
929 None
930 };
931
932 warnings.push(LintWarning {
933 line: start_line,
934 column: start_col,
935 end_line,
936 end_column: end_col,
937 severity: Severity::Warning,
938 rule_name: Some(self.name().to_string()),
939 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
940 fix,
941 });
942 }
943 }
944
945 (warnings, separating_from)
946 }
947
948 fn withheld_fixes(&self, ctx: &crate::lint_context::LintContext) -> std::collections::HashSet<(usize, bool)> {
953 let (unguarded, separating_from) = self.unguarded_warnings(ctx);
954 let mut guarded = unguarded[separating_from..].to_vec();
955 drop_structure_changing_fixes(ctx, &mut guarded, Allowed::Nothing);
956 unguarded[separating_from..]
957 .iter()
958 .zip(&guarded)
959 .filter(|(_, after)| after.fix.is_none())
960 .filter_map(|(before, _)| before.fix.as_ref())
961 .map(|fix| {
962 let line = ctx.offset_to_line_col(fix.range.start).0;
963 (line, fix.range.is_empty() && fix.replacement.ends_with('\n'))
964 })
965 .collect()
966 }
967
968 fn fix_with_structure_impl(
970 &self,
971 ctx: &crate::lint_context::LintContext,
972 withheld: &std::collections::HashSet<(usize, bool)>,
973 ) -> String {
974 let lines = ctx.raw_lines();
975 let num_lines = lines.len();
976 if num_lines == 0 {
977 return String::new();
978 }
979
980 let list_blocks = self.convert_list_blocks(ctx);
981 if list_blocks.is_empty() {
982 return ctx.content.to_string();
983 }
984
985 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
988 if !self.config.allow_lazy_continuation {
989 let lazy_cont_lines = ctx.lazy_continuation_lines();
990 let coverage = ListCoverage::new(&list_blocks);
991 for lazy_info in lazy_cont_lines.iter() {
992 let line_num = lazy_info.line_num;
993 if !Self::is_reportable_lazy_line(ctx, &coverage, line_num) {
995 continue;
996 }
997 if !Self::should_apply_lazy_fix(ctx, line_num)
999 || ctx.inline_config().is_rule_disabled(self.name(), line_num)
1000 {
1001 continue;
1002 }
1003 if !withheld.contains(&(line_num, false)) {
1004 lazy_fixes.insert(line_num, lazy_info.clone());
1005 }
1006 }
1007 }
1008
1009 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
1010
1011 for &(start_line, end_line, ref prefix) in &list_blocks {
1013 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
1014 if ctx
1016 .line_info(start_line)
1017 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
1018 {
1019 continue;
1020 }
1021
1022 if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
1024 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
1026
1027 if !has_blank_separation && content_line > 0 {
1029 let prev_line_str = lines[content_line - 1];
1030 let is_prev_excluded = ctx
1031 .line_info(content_line)
1032 .is_some_and(|info| info.in_code_block || info.in_front_matter);
1033 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
1034
1035 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
1036 if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
1038 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
1040 if !withheld.contains(&(start_line, true)) {
1041 insertions.insert(start_line, bq_prefix);
1042 }
1043 }
1044 }
1045 }
1046
1047 if end_line < num_lines
1049 && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
1050 && !Self::block_ends_in_comment_line(lines, end_line)
1051 {
1052 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
1054
1055 if !has_blank_separation && content_line > 0 {
1057 let next_line_str = lines[content_line - 1];
1058 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
1061 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
1062
1063 let end_line_str = lines[end_line - 1];
1065 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1066 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1067
1068 if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1071 let bq_prefix = Self::prefix_for_blank_after(ctx, end_line, content_line, block_bq_level);
1073 if !withheld.contains(&(end_line + 1, true)) {
1074 insertions.insert(end_line + 1, bq_prefix);
1075 }
1076 }
1077 }
1078 }
1079 }
1080
1081 if insertions.is_empty() && lazy_fixes.is_empty() {
1082 return ctx.content.to_string();
1083 }
1084
1085 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1087 for (i, line) in lines.iter().enumerate() {
1088 let current_line_num = i + 1;
1089 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1090 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1091 {
1092 result_lines.push(prefix_to_insert.clone());
1093 }
1094
1095 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1097 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1098 result_lines.push(fixed_line);
1099 } else {
1100 result_lines.push(line.to_string());
1101 }
1102 }
1103
1104 let line_ending = crate::utils::detect_line_ending(ctx.content);
1106 let mut result = result_lines.join(line_ending);
1107 if ctx.content.ends_with('\n') {
1108 result.push_str(line_ending);
1109 }
1110 result
1111 }
1112}
1113
1114fn is_blank_in_context(line: &str) -> bool {
1117 parse_blockquote_prefix(line)
1118 .map_or(line, |bq| bq.content)
1119 .trim()
1120 .is_empty()
1121 || crate::utils::blank_lines::is_blank_or_comment_only(line)
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127 use crate::lint_context::LintContext;
1128 use crate::rule::Rule;
1129
1130 #[test]
1131 fn test_list_coverage_matches_every_block_whatever_their_order() {
1132 let blocks: Vec<(usize, usize, String)> = [(20, 22), (3, 10), (5, 6), (9, 14), (30, 30), (17, 17)]
1135 .into_iter()
1136 .map(|(start, end)| (start, end, String::new()))
1137 .collect();
1138 let coverage = ListCoverage::new(&blocks);
1139 for line in 0..=35 {
1140 let expected = blocks.iter().any(|&(start, end, _)| start <= line && line <= end);
1141 assert_eq!(coverage.contains(line), expected, "line {line}");
1142 }
1143 assert!(!ListCoverage::new(&[]).contains(1));
1144 }
1145
1146 fn lint(content: &str) -> Vec<LintWarning> {
1147 let rule = MD032BlanksAroundLists::default();
1148 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1149 rule.check(&ctx).expect("Lint check failed")
1150 }
1151
1152 fn fix(content: &str) -> String {
1153 let rule = MD032BlanksAroundLists::default();
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 rule.fix(&ctx).expect("Lint fix failed")
1156 }
1157
1158 #[test]
1159 fn test_spaced_nested_blockquotes_list_separation() {
1160 for (list_prefix, surrounding_prefix) in [
1161 ("> >", "> >"),
1162 ("> >", "> >"),
1163 ("> > >", "> > >"),
1164 ("> >", ">>"),
1165 (">>", "> >"),
1166 ] {
1167 let content = format!(
1168 "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1169 );
1170 let expected = format!(
1171 "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1172 );
1173 let warnings = lint(&content);
1174 assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1175 assert!(warnings.iter().all(|warning| warning.line == 2));
1176 let mut edited = content.clone();
1177 for warning in warnings.iter().rev() {
1178 let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1179 edited.replace_range(edit.range.clone(), &edit.replacement);
1180 }
1181 assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1182 assert_eq!(fix(&content), expected);
1183 assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1184 assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1185 }
1186 }
1187
1188 #[test]
1189 fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1190 for content in [
1191 "> > - item\n> > ```\n> > code\n> > ```\n",
1192 "> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
1193 "> > - item\n> ~~~\n> code\n> ~~~\n",
1194 "> > - item\n~~~\ncode\n~~~\n",
1195 "> > - item\n>> - next item\n",
1196 ] {
1197 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1198 assert_eq!(fix(content), content);
1199 }
1200 }
1201
1202 #[test]
1203 fn test_fix_after_a_list_split_by_a_fence_keeps_its_continuation_lines() {
1204 for (content, expected) in [
1207 (
1208 "1. a\n2. b\n c\n```\nx\n```\n3. d\n",
1209 "1. a\n2. b\n c\n\n```\nx\n```\n3. d\n",
1210 ),
1211 (
1212 "* a\n```\n```\n* b\n](x)\n\ntext\n",
1213 "* a\n\n```\n```\n* b\n](x)\n\ntext\n",
1214 ),
1215 ] {
1216 assert_eq!(fix(content), expected, "{content:?}");
1217 }
1218 let warnings = lint("1. a\n2. b\n c\n```\nx\n```\n3. d\n");
1219 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), vec![3]);
1220 }
1221
1222 #[test]
1223 fn test_backtick_line_short_of_the_content_column_is_lazy_text() {
1224 let content = " 1. a\n ```\n code\n";
1227 assert!(lint(content).is_empty(), "{:?}", lint(content));
1228 assert_eq!(fix(content), content);
1229 }
1230
1231 #[test]
1232 fn test_blank_after_a_quoted_list_ending_in_lazy_text_stays_in_the_quote() {
1233 let content = "> 1. a\nlazy\n> - b\n";
1234 let warnings = lint(content);
1235 assert_eq!(warnings.len(), 1, "{warnings:?}");
1236 assert_eq!(fix(content), "> 1. a\nlazy\n>\n> - b\n");
1237 }
1238
1239 #[test]
1240 fn test_fix_never_changes_how_the_lists_parse() {
1241 for (content, expected) in [
1247 ("1. item 41\n + item 51\n+ item 52\n", None),
1248 (" * item 17\n 10. item 21\n1. item 22\n", None),
1249 ("- item 6\n> ```\n 10. item 23\n 1. item 24\n", None),
1250 (
1251 " 10. item 43\n> + item 47\n> continuation text\n> 1. item 50\n",
1252 Some(" 10. item 43\n>\n> + item 47\n> continuation text\n> 1. item 50\n"),
1253 ),
1254 (
1255 " 1. item 63\n> + item 65\n> + item 66\n",
1256 Some(" 1. item 63\n>\n> + item 65\n> + item 66\n"),
1257 ),
1258 ] {
1259 let expected = expected.unwrap_or(content);
1260 assert_eq!(fix(content), expected, "{content:?}");
1261 let fixable = lint(content).iter().filter(|w| w.fix.is_some()).count();
1262 assert_eq!(fixable, usize::from(expected != content), "{content:?}");
1263 }
1264 }
1265
1266 #[test]
1267 fn test_fix_separates_list_from_standalone_code_fence() {
1268 for (content, expected) in [
1269 (
1270 "# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
1271 "# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
1272 ),
1273 ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1274 ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1275 (
1276 ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1277 ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1278 ),
1279 ] {
1280 let warnings = lint(content);
1281 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1282 assert_eq!(warnings[0].message, "List should be followed by blank line");
1283 let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1284 let mut edited = content.to_string();
1285 edited.replace_range(edit.range.clone(), &edit.replacement);
1286 assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1287 assert_eq!(fix(content), expected, "{content:?}");
1288 assert!(lint(expected).is_empty(), "{expected:?}");
1289 assert_eq!(fix(expected), expected, "Fix must be idempotent");
1290 }
1291 }
1292
1293 #[test]
1294 fn test_fix_preserves_code_fence_inside_list_item() {
1295 for content in [
1296 "- item\n ```\n code\n ```\n",
1297 "1. item\n ~~~\n code\n ~~~\n",
1298 "> - item\n> ```\n> code\n> ```\n",
1299 ] {
1300 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1301 assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1302 }
1303 }
1304
1305 #[test]
1306 fn test_fix_does_not_split_item_before_different_list_type() {
1307 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1311 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1312
1313 let warnings = lint(content);
1316 assert_eq!(warnings.len(), 2);
1317 assert_eq!(warnings[0].line, 2);
1318 assert_eq!(warnings[1].line, 3);
1319 }
1320
1321 #[test]
1322 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1323 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1324 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1325 }
1326
1327 #[test]
1328 fn test_fix_keeps_lazy_continuation_with_its_item() {
1329 let content = "- alpha beta\nlazy\n1. ordered item\n";
1333 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1334
1335 let warnings = lint(content);
1336 assert_eq!(warnings.len(), 2);
1337 assert_eq!(warnings[0].line, 2);
1338 assert_eq!(warnings[1].line, 3);
1339 }
1340
1341 #[test]
1342 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1343 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1344 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1345 }
1346
1347 #[test]
1348 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1349 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1352 allow_lazy_continuation: false,
1353 });
1354 let content = "- alpha beta\nlazy\n1. ordered item\n";
1355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1356 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1357 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1358 }
1359
1360 #[test]
1361 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1362 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1366 allow_lazy_continuation: false,
1367 });
1368 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1370 let warnings = rule.check(&ctx).expect("Lint check failed");
1371 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1372 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1373 assert_eq!(fixed, content);
1374 }
1375
1376 #[test]
1377 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1378 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1382 allow_lazy_continuation: false,
1383 });
1384 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1386 let warnings = rule.check(&ctx).expect("Lint check failed");
1387 assert_eq!(
1388 warnings.len(),
1389 1,
1390 "Expected one lazy-continuation warning, got: {warnings:?}"
1391 );
1392 assert_eq!(warnings[0].line, 3);
1393 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1394 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1395 }
1396
1397 #[test]
1398 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1399 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1404 allow_lazy_continuation: false,
1405 });
1406 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let warnings = rule.check(&ctx).unwrap();
1409 assert_eq!(warnings.len(), 1, "{warnings:?}");
1410 assert_eq!(warnings[0].line, 5);
1411 assert!(warnings[0].fix.is_none(), "{warnings:?}");
1412 assert_eq!(rule.fix(&ctx).unwrap(), content);
1413
1414 let content = "Intro\n\n- List item 1\n- List item 2\nlazy\n";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 assert_eq!(
1417 rule.fix(&ctx).unwrap(),
1418 "Intro\n\n- List item 1\n- List item 2\n lazy\n"
1419 );
1420 }
1421
1422 fn check_warnings_have_fixes(content: &str) {
1424 let warnings = lint(content);
1425 for warning in &warnings {
1426 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1427 }
1428 }
1429
1430 #[test]
1431 fn test_list_at_start() {
1432 let content = "- Item 1\n- Item 2\nText";
1435 let warnings = lint(content);
1436 assert_eq!(
1437 warnings.len(),
1438 0,
1439 "Trailing text is lazy continuation per CommonMark - no warning expected"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_list_at_end() {
1445 let content = "Text\n- Item 1\n- Item 2";
1446 let warnings = lint(content);
1447 assert_eq!(
1448 warnings.len(),
1449 1,
1450 "Expected 1 warning for list at end without preceding blank line"
1451 );
1452 assert_eq!(
1453 warnings[0].line, 2,
1454 "Warning should be on the first line of the list (line 2)"
1455 );
1456 assert!(warnings[0].message.contains("preceded by blank line"));
1457
1458 check_warnings_have_fixes(content);
1460
1461 let fixed_content = fix(content);
1462 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1463
1464 let warnings_after_fix = lint(&fixed_content);
1466 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1467 }
1468
1469 #[test]
1470 fn test_list_in_middle() {
1471 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1474 let warnings = lint(content);
1475 assert_eq!(
1476 warnings.len(),
1477 1,
1478 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1479 );
1480 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1481 assert!(warnings[0].message.contains("preceded by blank line"));
1482
1483 check_warnings_have_fixes(content);
1485
1486 let fixed_content = fix(content);
1487 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1488
1489 let warnings_after_fix = lint(&fixed_content);
1491 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1492 }
1493
1494 #[test]
1495 fn test_correct_spacing() {
1496 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1497 let warnings = lint(content);
1498 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1499
1500 let fixed_content = fix(content);
1501 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1502 }
1503
1504 #[test]
1505 fn test_list_with_content() {
1506 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1509 let warnings = lint(content);
1510 assert_eq!(
1511 warnings.len(),
1512 1,
1513 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1514 );
1515 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1516 assert!(warnings[0].message.contains("preceded by blank line"));
1517
1518 check_warnings_have_fixes(content);
1520
1521 let fixed_content = fix(content);
1522 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1523 assert_eq!(
1524 fixed_content, expected_fixed,
1525 "Fix did not produce the expected output. Got:\n{fixed_content}"
1526 );
1527
1528 let warnings_after_fix = lint(&fixed_content);
1530 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1531 }
1532
1533 #[test]
1534 fn test_nested_list() {
1535 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1537 let warnings = lint(content);
1538 assert_eq!(
1539 warnings.len(),
1540 1,
1541 "Nested list block needs preceding blank only. Got: {warnings:?}"
1542 );
1543 assert_eq!(warnings[0].line, 2);
1544 assert!(warnings[0].message.contains("preceded by blank line"));
1545
1546 check_warnings_have_fixes(content);
1548
1549 let fixed_content = fix(content);
1550 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1551
1552 let warnings_after_fix = lint(&fixed_content);
1554 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1555 }
1556
1557 #[test]
1558 fn test_list_with_internal_blanks() {
1559 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1561 let warnings = lint(content);
1562 assert_eq!(
1563 warnings.len(),
1564 1,
1565 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1566 );
1567 assert_eq!(warnings[0].line, 2);
1568 assert!(warnings[0].message.contains("preceded by blank line"));
1569
1570 check_warnings_have_fixes(content);
1572
1573 let fixed_content = fix(content);
1574 assert_eq!(
1575 fixed_content,
1576 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1577 );
1578
1579 let warnings_after_fix = lint(&fixed_content);
1581 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1582 }
1583
1584 #[test]
1585 fn test_ignore_code_blocks() {
1586 let content = "```\n- Not a list item\n```\nText";
1587 let warnings = lint(content);
1588 assert_eq!(warnings.len(), 0);
1589 let fixed_content = fix(content);
1590 assert_eq!(fixed_content, content);
1591 }
1592
1593 #[test]
1594 fn test_ignore_front_matter() {
1595 let content = "---\ntitle: Test\n---\n- List Item\nText";
1597 let warnings = lint(content);
1598 assert_eq!(
1599 warnings.len(),
1600 0,
1601 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1602 );
1603
1604 let fixed_content = fix(content);
1606 assert_eq!(fixed_content, content, "No changes when no warnings");
1607 }
1608
1609 #[test]
1610 fn test_multiple_lists() {
1611 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1616 let warnings = lint(content);
1617 assert!(
1619 !warnings.is_empty(),
1620 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1621 );
1622
1623 check_warnings_have_fixes(content);
1625
1626 let fixed_content = fix(content);
1627 let warnings_after_fix = lint(&fixed_content);
1629 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1630 }
1631
1632 #[test]
1633 fn test_adjacent_lists() {
1634 let content = "- List 1\n\n* List 2";
1635 let warnings = lint(content);
1636 assert_eq!(warnings.len(), 0);
1637 let fixed_content = fix(content);
1638 assert_eq!(fixed_content, content);
1639 }
1640
1641 #[test]
1642 fn test_list_in_blockquote() {
1643 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1645 let warnings = lint(content);
1646 assert_eq!(
1647 warnings.len(),
1648 1,
1649 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1650 );
1651 assert_eq!(warnings[0].line, 2);
1652
1653 check_warnings_have_fixes(content);
1655
1656 let fixed_content = fix(content);
1657 assert_eq!(
1659 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1660 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1661 );
1662
1663 let warnings_after_fix = lint(&fixed_content);
1665 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1666 }
1667
1668 #[test]
1669 fn test_ordered_list() {
1670 let content = "Text\n1. Item 1\n2. Item 2\nText";
1672 let warnings = lint(content);
1673 assert_eq!(warnings.len(), 1);
1674
1675 check_warnings_have_fixes(content);
1677
1678 let fixed_content = fix(content);
1679 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1680
1681 let warnings_after_fix = lint(&fixed_content);
1683 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1684 }
1685
1686 #[test]
1687 fn test_no_double_blank_fix() {
1688 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1691 assert_eq!(
1692 warnings.len(),
1693 0,
1694 "Should have no warnings - properly preceded, trailing is lazy"
1695 );
1696
1697 let fixed_content = fix(content);
1698 assert_eq!(
1699 fixed_content, content,
1700 "No fix needed when no warnings. Got:\n{fixed_content}"
1701 );
1702
1703 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1705 assert_eq!(warnings2.len(), 1);
1706 if !warnings2.is_empty() {
1707 assert_eq!(
1708 warnings2[0].line, 2,
1709 "Warning line for missing blank before should be the first line of the block"
1710 );
1711 }
1712
1713 check_warnings_have_fixes(content2);
1715
1716 let fixed_content2 = fix(content2);
1717 assert_eq!(
1718 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1719 "Fix added extra blank before. Got:\n{fixed_content2}"
1720 );
1721 }
1722
1723 #[test]
1724 fn test_empty_input() {
1725 let content = "";
1726 let warnings = lint(content);
1727 assert_eq!(warnings.len(), 0);
1728 let fixed_content = fix(content);
1729 assert_eq!(fixed_content, "");
1730 }
1731
1732 #[test]
1733 fn test_only_list() {
1734 let content = "- Item 1\n- Item 2";
1735 let warnings = lint(content);
1736 assert_eq!(warnings.len(), 0);
1737 let fixed_content = fix(content);
1738 assert_eq!(fixed_content, content);
1739 }
1740
1741 #[test]
1744 fn test_fix_complex_nested_blockquote() {
1745 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1747 let warnings = lint(content);
1748 assert_eq!(
1749 warnings.len(),
1750 1,
1751 "Should warn for missing preceding blank only. Got: {warnings:?}"
1752 );
1753
1754 check_warnings_have_fixes(content);
1756
1757 let fixed_content = fix(content);
1758 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1760 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1761
1762 let warnings_after_fix = lint(&fixed_content);
1763 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1764 }
1765
1766 #[test]
1767 fn test_fix_mixed_list_markers() {
1768 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1771 let warnings = lint(content);
1772 assert!(
1774 !warnings.is_empty(),
1775 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1776 );
1777
1778 check_warnings_have_fixes(content);
1780
1781 let fixed_content = fix(content);
1782 assert!(
1784 fixed_content.contains("Text\n\n-"),
1785 "Fix should add blank line before first list item"
1786 );
1787
1788 let warnings_after_fix = lint(&fixed_content);
1790 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1791 }
1792
1793 #[test]
1794 fn test_fix_ordered_list_with_different_numbers() {
1795 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1797 let warnings = lint(content);
1798 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1799
1800 check_warnings_have_fixes(content);
1802
1803 let fixed_content = fix(content);
1804 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1805 assert_eq!(
1806 fixed_content, expected,
1807 "Fix should handle ordered lists with non-sequential numbers"
1808 );
1809
1810 let warnings_after_fix = lint(&fixed_content);
1812 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1813 }
1814
1815 #[test]
1816 fn test_fix_list_with_code_blocks_inside() {
1817 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1819 let warnings = lint(content);
1820 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1821
1822 check_warnings_have_fixes(content);
1824
1825 let fixed_content = fix(content);
1826 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1827 assert_eq!(
1828 fixed_content, expected,
1829 "Fix should handle lists with internal code blocks"
1830 );
1831
1832 let warnings_after_fix = lint(&fixed_content);
1834 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1835 }
1836
1837 #[test]
1838 fn test_fix_deeply_nested_lists() {
1839 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1841 let warnings = lint(content);
1842 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1843
1844 check_warnings_have_fixes(content);
1846
1847 let fixed_content = fix(content);
1848 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1849 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1850
1851 let warnings_after_fix = lint(&fixed_content);
1853 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1854 }
1855
1856 #[test]
1857 fn test_fix_list_with_multiline_items() {
1858 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1861 let warnings = lint(content);
1862 assert_eq!(
1863 warnings.len(),
1864 1,
1865 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1866 );
1867
1868 check_warnings_have_fixes(content);
1870
1871 let fixed_content = fix(content);
1872 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1873 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1874
1875 let warnings_after_fix = lint(&fixed_content);
1877 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1878 }
1879
1880 #[test]
1881 fn test_fix_list_at_document_boundaries() {
1882 let content1 = "- Item 1\n- Item 2";
1884 let warnings1 = lint(content1);
1885 assert_eq!(
1886 warnings1.len(),
1887 0,
1888 "List at document start should not need blank before"
1889 );
1890 let fixed1 = fix(content1);
1891 assert_eq!(fixed1, content1, "No fix needed for list at start");
1892
1893 let content2 = "Text\n- Item 1\n- Item 2";
1895 let warnings2 = lint(content2);
1896 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1897 check_warnings_have_fixes(content2);
1898 let fixed2 = fix(content2);
1899 assert_eq!(
1900 fixed2, "Text\n\n- Item 1\n- Item 2",
1901 "Should add blank before list at end"
1902 );
1903 }
1904
1905 #[test]
1906 fn test_fix_preserves_existing_blank_lines() {
1907 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1908 let warnings = lint(content);
1909 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1910 let fixed_content = fix(content);
1911 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1912 }
1913
1914 #[test]
1915 fn test_fix_handles_tabs_and_spaces() {
1916 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1919 let warnings = lint(content);
1920 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1922
1923 check_warnings_have_fixes(content);
1925
1926 let fixed_content = fix(content);
1927 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1930 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1931
1932 let warnings_after_fix = lint(&fixed_content);
1934 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1935 }
1936
1937 #[test]
1938 fn test_fix_warning_objects_have_correct_ranges() {
1939 let content = "Text\n- Item 1\n- Item 2\nText";
1941 let warnings = lint(content);
1942 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1943
1944 for warning in &warnings {
1946 assert!(warning.fix.is_some(), "Warning should have fix");
1947 let fix = warning.fix.as_ref().unwrap();
1948 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1949 assert!(
1950 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1951 "Fix should have replacement or be insertion"
1952 );
1953 }
1954 }
1955
1956 #[test]
1957 fn test_fix_idempotent() {
1958 let content = "Text\n- Item 1\n- Item 2\nText";
1960
1961 let fixed_once = fix(content);
1963 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1964
1965 let fixed_twice = fix(&fixed_once);
1967 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1968
1969 let warnings_after_fix = lint(&fixed_once);
1971 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1972 }
1973
1974 #[test]
1975 fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
1976 let rule = MD032BlanksAroundLists::default();
1977 for (content, expected) in [
1978 ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1979 (
1980 "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1981 "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1982 ),
1983 ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1984 ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
1985 ] {
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let warnings = rule.check(&ctx).unwrap();
1988 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1989 assert_eq!(edited, expected);
1990 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1991 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1992 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1993 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1994 }
1995 }
1996
1997 #[test]
1998 fn test_fix_respects_inline_config_at_each_list_boundary() {
1999 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2000
2001 let rule = MD032BlanksAroundLists::default();
2002 for (content, expected, warning_lines) in [
2005 (
2006 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
2007 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
2008 vec![4],
2009 ),
2010 (
2011 "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2012 "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
2013 vec![2],
2014 ),
2015 (
2016 "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
2017 "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
2018 vec![2, 8],
2019 ),
2020 (
2021 "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
2022 "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
2023 vec![2, 8],
2024 ),
2025 (
2026 "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
2031 "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
2032 vec![3],
2033 ),
2034 ] {
2035 for ending in ["\n", "\r\n"] {
2036 for final_newline in [true, false] {
2037 let content = if final_newline {
2038 content
2039 } else {
2040 content.trim_end_matches('\n')
2041 };
2042 let expected = if final_newline {
2043 expected
2044 } else {
2045 expected.trim_end_matches('\n')
2046 };
2047 let content = content.replace('\n', ending);
2048 let expected = expected.replace('\n', ending);
2049 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2050 let warnings =
2051 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
2052 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
2053 assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
2054 assert_eq!(rule.fix(&ctx).unwrap(), expected);
2055 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
2056 assert!(
2057 filter_warnings_by_inline_config(
2058 rule.check(&fixed_ctx).unwrap(),
2059 fixed_ctx.inline_config(),
2060 rule.name()
2061 )
2062 .is_empty()
2063 );
2064 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
2065 }
2066 }
2067 }
2068 }
2069
2070 #[test]
2071 fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
2072 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
2073
2074 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
2075 allow_lazy_continuation: false,
2076 });
2077 let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
2078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2079 let raw = rule.check(&ctx).unwrap();
2080 assert_eq!(raw.len(), 1);
2081 assert!(raw[0].fix.is_some());
2082 let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
2083 assert!(warnings.is_empty());
2084 assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
2085 assert_eq!(rule.fix(&ctx).unwrap(), content);
2086 }
2087
2088 #[test]
2089 fn test_fix_with_normalized_line_endings() {
2090 let content = "Text\n- Item 1\n- Item 2\nText";
2094 let warnings = lint(content);
2095 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
2096
2097 check_warnings_have_fixes(content);
2099
2100 let fixed_content = fix(content);
2101 let expected = "Text\n\n- Item 1\n- Item 2\nText";
2103 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
2104 }
2105
2106 #[test]
2107 fn test_fix_preserves_final_newline() {
2108 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
2111 let fixed_with_newline = fix(content_with_newline);
2112 assert!(
2113 fixed_with_newline.ends_with('\n'),
2114 "Fix should preserve final newline when present"
2115 );
2116 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
2118
2119 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
2121 let fixed_without_newline = fix(content_without_newline);
2122 assert!(
2123 !fixed_without_newline.ends_with('\n'),
2124 "Fix should not add final newline when not present"
2125 );
2126 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
2128 }
2129
2130 #[test]
2131 fn test_fix_multiline_list_items_no_indent() {
2132 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";
2133
2134 let warnings = lint(content);
2135 assert_eq!(
2137 warnings.len(),
2138 0,
2139 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
2140 );
2141
2142 let fixed_content = fix(content);
2143 assert_eq!(
2145 fixed_content, content,
2146 "Should not modify correctly formatted multi-line list items"
2147 );
2148 }
2149
2150 #[test]
2151 fn test_nested_list_with_lazy_continuation() {
2152 let content = r#"# Test
2158
2159- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2160 1. Switch/case dispatcher statements (original Phase 3.2)
2161 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2162`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2163 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2164 references"#;
2165
2166 let warnings = lint(content);
2167 let md032_warnings: Vec<_> = warnings
2170 .iter()
2171 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2172 .collect();
2173 assert_eq!(
2174 md032_warnings.len(),
2175 0,
2176 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2177 );
2178 }
2179
2180 #[test]
2181 fn test_pipes_in_code_spans_not_detected_as_table() {
2182 let content = r#"# Test
2184
2185- Item with `a | b` inline code
2186 - Nested item should work
2187
2188"#;
2189
2190 let warnings = lint(content);
2191 let md032_warnings: Vec<_> = warnings
2192 .iter()
2193 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2194 .collect();
2195 assert_eq!(
2196 md032_warnings.len(),
2197 0,
2198 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2199 );
2200 }
2201
2202 #[test]
2203 fn test_multiple_code_spans_with_pipes() {
2204 let content = r#"# Test
2206
2207- Item with `a | b` and `c || d` operators
2208 - Nested item should work
2209
2210"#;
2211
2212 let warnings = lint(content);
2213 let md032_warnings: Vec<_> = warnings
2214 .iter()
2215 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2216 .collect();
2217 assert_eq!(
2218 md032_warnings.len(),
2219 0,
2220 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2221 );
2222 }
2223
2224 #[test]
2225 fn test_actual_table_breaks_list() {
2226 let content = r#"# Test
2228
2229- Item before table
2230
2231| Col1 | Col2 |
2232|------|------|
2233| A | B |
2234
2235- Item after table
2236
2237"#;
2238
2239 let warnings = lint(content);
2240 let md032_warnings: Vec<_> = warnings
2242 .iter()
2243 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2244 .collect();
2245 assert_eq!(
2246 md032_warnings.len(),
2247 0,
2248 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2249 );
2250 }
2251
2252 #[test]
2253 fn test_thematic_break_not_lazy_continuation() {
2254 let content = r#"- Item 1
2257- Item 2
2258***
2259
2260More text.
2261"#;
2262
2263 let warnings = lint(content);
2264 let md032_warnings: Vec<_> = warnings
2265 .iter()
2266 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2267 .collect();
2268 assert_eq!(
2269 md032_warnings.len(),
2270 1,
2271 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2272 );
2273 assert!(
2274 md032_warnings[0].message.contains("followed by blank line"),
2275 "Warning should be about missing blank after list"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_thematic_break_with_blank_line() {
2281 let content = r#"- Item 1
2283- Item 2
2284
2285***
2286
2287More text.
2288"#;
2289
2290 let warnings = lint(content);
2291 let md032_warnings: Vec<_> = warnings
2292 .iter()
2293 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2294 .collect();
2295 assert_eq!(
2296 md032_warnings.len(),
2297 0,
2298 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_various_thematic_break_styles() {
2304 for hr in ["---", "***", "___"] {
2309 let content = format!(
2310 r#"- Item 1
2311- Item 2
2312{hr}
2313
2314More text.
2315"#
2316 );
2317
2318 let warnings = lint(&content);
2319 let md032_warnings: Vec<_> = warnings
2320 .iter()
2321 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2322 .collect();
2323 assert_eq!(
2324 md032_warnings.len(),
2325 1,
2326 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2327 );
2328 }
2329 }
2330
2331 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2334 let rule = MD032BlanksAroundLists::from_config_struct(config);
2335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2336 rule.check(&ctx).expect("Lint check failed")
2337 }
2338
2339 fn fix_with_config(content: &str, config: MD032Config) -> String {
2340 let rule = MD032BlanksAroundLists::from_config_struct(config);
2341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 rule.fix(&ctx).expect("Lint fix failed")
2343 }
2344
2345 #[test]
2346 fn test_lazy_continuation_allowed_by_default() {
2347 let content = "# Heading\n\n1. List\nSome text.";
2349 let warnings = lint(content);
2350 assert_eq!(
2351 warnings.len(),
2352 0,
2353 "Default behavior should allow lazy continuation. Got: {warnings:?}"
2354 );
2355 }
2356
2357 #[test]
2358 fn test_lazy_continuation_disallowed() {
2359 let content = "# Heading\n\n1. List\nSome text.";
2361 let config = MD032Config {
2362 allow_lazy_continuation: false,
2363 };
2364 let warnings = lint_with_config(content, config);
2365 assert_eq!(
2366 warnings.len(),
2367 1,
2368 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2369 );
2370 assert!(
2371 warnings[0].message.contains("Lazy continuation"),
2372 "Warning message should mention lazy continuation"
2373 );
2374 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2375 }
2376
2377 #[test]
2378 fn test_lazy_continuation_fix() {
2379 let content = "# Heading\n\n1. List\nSome text.";
2381 let config = MD032Config {
2382 allow_lazy_continuation: false,
2383 };
2384 let fixed = fix_with_config(content, config.clone());
2385 assert_eq!(
2387 fixed, "# Heading\n\n1. List\n Some text.",
2388 "Fix should add proper indentation to lazy continuation"
2389 );
2390
2391 let warnings_after = lint_with_config(&fixed, config);
2393 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2394 }
2395
2396 #[test]
2397 fn test_lazy_continuation_multiple_lines() {
2398 let content = "- Item 1\nLine 2\nLine 3";
2400 let config = MD032Config {
2401 allow_lazy_continuation: false,
2402 };
2403 let warnings = lint_with_config(content, config.clone());
2404 assert_eq!(
2406 warnings.len(),
2407 2,
2408 "Should warn for each lazy continuation line. Got: {warnings:?}"
2409 );
2410
2411 let fixed = fix_with_config(content, config.clone());
2412 assert_eq!(
2414 fixed, "- Item 1\n Line 2\n Line 3",
2415 "Fix should add proper indentation to lazy continuation lines"
2416 );
2417
2418 let warnings_after = lint_with_config(&fixed, config);
2420 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2421 }
2422
2423 #[test]
2424 fn test_lazy_continuation_with_indented_content() {
2425 let content = "- Item 1\n Indented content\nLazy text";
2427 let config = MD032Config {
2428 allow_lazy_continuation: false,
2429 };
2430 let warnings = lint_with_config(content, config);
2431 assert_eq!(
2432 warnings.len(),
2433 1,
2434 "Should warn for lazy text after indented content. Got: {warnings:?}"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_lazy_continuation_properly_separated() {
2440 let content = "- Item 1\n\nSome text.";
2442 let config = MD032Config {
2443 allow_lazy_continuation: false,
2444 };
2445 let warnings = lint_with_config(content, config);
2446 assert_eq!(
2447 warnings.len(),
2448 0,
2449 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2450 );
2451 }
2452
2453 #[test]
2456 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2457 let content = "1) First item\nLazy continuation";
2459 let config = MD032Config {
2460 allow_lazy_continuation: false,
2461 };
2462 let warnings = lint_with_config(content, config.clone());
2463 assert_eq!(
2464 warnings.len(),
2465 1,
2466 "Should warn for lazy continuation with parenthesis marker"
2467 );
2468
2469 let fixed = fix_with_config(content, config);
2470 assert_eq!(fixed, "1) First item\n Lazy continuation");
2472 }
2473
2474 #[test]
2475 fn test_lazy_continuation_followed_by_another_list() {
2476 let content = "- Item 1\nSome text\n- Item 2";
2482 let config = MD032Config {
2483 allow_lazy_continuation: false,
2484 };
2485 let warnings = lint_with_config(content, config);
2486 assert_eq!(
2488 warnings.len(),
2489 1,
2490 "Should warn about lazy continuation within list. Got: {warnings:?}"
2491 );
2492 assert!(
2493 warnings[0].message.contains("Lazy continuation"),
2494 "Warning should be about lazy continuation"
2495 );
2496 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2497 }
2498
2499 #[test]
2500 fn test_lazy_continuation_multiple_in_document() {
2501 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2506 let config = MD032Config {
2507 allow_lazy_continuation: false,
2508 };
2509 let warnings = lint_with_config(content, config.clone());
2510 assert_eq!(
2512 warnings.len(),
2513 2,
2514 "Should warn for both lazy continuations. Got: {warnings:?}"
2515 );
2516
2517 let fixed = fix_with_config(content, config.clone());
2518 assert!(
2520 fixed.contains(" Lazy 1"),
2521 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2522 );
2523 assert!(
2524 fixed.contains(" Lazy 2"),
2525 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2526 );
2527
2528 let warnings_after = lint_with_config(&fixed, config);
2529 assert_eq!(
2531 warnings_after.len(),
2532 0,
2533 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2534 );
2535 }
2536
2537 #[test]
2538 fn test_lazy_continuation_end_of_document_no_newline() {
2539 let content = "- Item\nNo trailing newline";
2541 let config = MD032Config {
2542 allow_lazy_continuation: false,
2543 };
2544 let warnings = lint_with_config(content, config.clone());
2545 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2546
2547 let fixed = fix_with_config(content, config);
2548 assert_eq!(fixed, "- Item\n No trailing newline");
2550 }
2551
2552 #[test]
2553 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2554 let content = "- Item 1\n---";
2557 let config = MD032Config {
2558 allow_lazy_continuation: false,
2559 };
2560 let warnings = lint_with_config(content, config.clone());
2561 assert_eq!(
2563 warnings.len(),
2564 1,
2565 "List should need blank line before thematic break. Got: {warnings:?}"
2566 );
2567
2568 let fixed = fix_with_config(content, config);
2570 assert_eq!(fixed, "- Item 1\n\n---");
2571 }
2572
2573 #[test]
2574 fn test_lazy_continuation_heading_not_flagged() {
2575 let content = "- Item 1\n# Heading";
2578 let config = MD032Config {
2579 allow_lazy_continuation: false,
2580 };
2581 let warnings = lint_with_config(content, config);
2582 assert!(
2585 warnings.iter().all(|w| !w.message.contains("lazy")),
2586 "Heading should not trigger lazy continuation warning"
2587 );
2588 }
2589
2590 #[test]
2591 fn test_lazy_continuation_mixed_list_types() {
2592 let content = "- Unordered\n1. Ordered\nLazy text";
2594 let config = MD032Config {
2595 allow_lazy_continuation: false,
2596 };
2597 let warnings = lint_with_config(content, config.clone());
2598 assert!(!warnings.is_empty(), "Should warn about structure issues");
2599 }
2600
2601 #[test]
2602 fn test_lazy_continuation_deep_nesting() {
2603 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2605 let config = MD032Config {
2606 allow_lazy_continuation: false,
2607 };
2608 let warnings = lint_with_config(content, config.clone());
2609 assert!(
2610 !warnings.is_empty(),
2611 "Should warn about lazy continuation after nested list"
2612 );
2613
2614 let fixed = fix_with_config(content, config.clone());
2615 let warnings_after = lint_with_config(&fixed, config);
2616 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2617 }
2618
2619 #[test]
2620 fn test_lazy_continuation_with_emphasis_in_text() {
2621 let content = "- Item\n*emphasized* continuation";
2623 let config = MD032Config {
2624 allow_lazy_continuation: false,
2625 };
2626 let warnings = lint_with_config(content, config.clone());
2627 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2628
2629 let fixed = fix_with_config(content, config);
2630 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2632 }
2633
2634 #[test]
2635 fn test_lazy_continuation_with_code_span() {
2636 let content = "- Item\n`code` continuation";
2638 let config = MD032Config {
2639 allow_lazy_continuation: false,
2640 };
2641 let warnings = lint_with_config(content, config.clone());
2642 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2643
2644 let fixed = fix_with_config(content, config);
2645 assert_eq!(fixed, "- Item\n `code` continuation");
2647 }
2648
2649 #[test]
2656 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2657 let content = r#"1. Create a new Chat conversation:
2660 - On the sidebar, select **New Chat**.
2661 - In the box, type `/new`.
2662 A new Chat conversation replaces the previous one.
26631. Under the Chat text box, turn off the toggle."#;
2664 let config = MD032Config {
2665 allow_lazy_continuation: false,
2666 };
2667 let warnings = lint_with_config(content, config);
2668 let lazy_warnings: Vec<_> = warnings
2670 .iter()
2671 .filter(|w| w.message.contains("Lazy continuation"))
2672 .collect();
2673 assert!(
2674 !lazy_warnings.is_empty(),
2675 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2676 );
2677 assert!(
2678 lazy_warnings.iter().any(|w| w.line == 4),
2679 "Should warn on line 4. Got: {lazy_warnings:?}"
2680 );
2681 }
2682
2683 #[test]
2684 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2685 let content = r#"- `field`: Is the specific key:
2688 - `password`: Accesses the password.
2689 - `api_key`: Accesses the api_key.
2690 `token`: Specifies which ID token to use.
2691- `version_id`: Is the unique identifier."#;
2692 let config = MD032Config {
2693 allow_lazy_continuation: false,
2694 };
2695 let warnings = lint_with_config(content, config);
2696 let lazy_warnings: Vec<_> = warnings
2698 .iter()
2699 .filter(|w| w.message.contains("Lazy continuation"))
2700 .collect();
2701 assert!(
2702 !lazy_warnings.is_empty(),
2703 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2704 );
2705 assert!(
2706 lazy_warnings.iter().any(|w| w.line == 4),
2707 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2708 );
2709 }
2710
2711 #[test]
2712 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2713 let content = r#"- Check out the branch, and test locally.
2715 - If the MR requires significant modifications:
2716 - **Skip local testing** and review instead.
2717 - **Request verification** from the author.
2718 - **Identify the minimal change** needed.
2719 Your testing might result in opportunities.
2720- If you don't understand, _say so_."#;
2721 let config = MD032Config {
2722 allow_lazy_continuation: false,
2723 };
2724 let warnings = lint_with_config(content, config);
2725 let lazy_warnings: Vec<_> = warnings
2727 .iter()
2728 .filter(|w| w.message.contains("Lazy continuation"))
2729 .collect();
2730 assert!(
2731 !lazy_warnings.is_empty(),
2732 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2733 );
2734 assert!(
2735 lazy_warnings.iter().any(|w| w.line == 6),
2736 "Should warn on line 6. Got: {lazy_warnings:?}"
2737 );
2738 }
2739
2740 #[test]
2741 fn test_issue295_ordered_list_nested_bullets_continuation() {
2742 let content = r#"# Test
2745
27461. First item.
2747 - Nested A.
2748 - Nested B.
2749 Continuation at outer level.
27501. Second item."#;
2751 let config = MD032Config {
2752 allow_lazy_continuation: false,
2753 };
2754 let warnings = lint_with_config(content, config);
2755 let lazy_warnings: Vec<_> = warnings
2757 .iter()
2758 .filter(|w| w.message.contains("Lazy continuation"))
2759 .collect();
2760 assert!(
2761 !lazy_warnings.is_empty(),
2762 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2763 );
2764 assert!(
2766 lazy_warnings.iter().any(|w| w.line == 6),
2767 "Should warn on line 6. Got: {lazy_warnings:?}"
2768 );
2769 }
2770
2771 #[test]
2772 fn test_issue295_multiple_lazy_lines_after_nested() {
2773 let content = r#"1. The device client receives a response.
2775 - Those defined by OAuth Framework.
2776 - Those specific to device authorization.
2777 Those error responses are described below.
2778 For more information on each response,
2779 see the documentation.
27801. Next step in the process."#;
2781 let config = MD032Config {
2782 allow_lazy_continuation: false,
2783 };
2784 let warnings = lint_with_config(content, config);
2785 let lazy_warnings: Vec<_> = warnings
2787 .iter()
2788 .filter(|w| w.message.contains("Lazy continuation"))
2789 .collect();
2790 assert!(
2791 lazy_warnings.len() >= 3,
2792 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2793 lazy_warnings.len()
2794 );
2795 }
2796
2797 #[test]
2798 fn test_issue295_properly_indented_not_lazy() {
2799 let content = r#"1. First item.
2801 - Nested A.
2802 - Nested B.
2803
2804 Properly indented continuation.
28051. Second item."#;
2806 let config = MD032Config {
2807 allow_lazy_continuation: false,
2808 };
2809 let warnings = lint_with_config(content, config);
2810 let lazy_warnings: Vec<_> = warnings
2812 .iter()
2813 .filter(|w| w.message.contains("Lazy continuation"))
2814 .collect();
2815 assert_eq!(
2816 lazy_warnings.len(),
2817 0,
2818 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2819 );
2820 }
2821
2822 #[test]
2829 fn test_html_comment_before_list_with_preceding_blank() {
2830 let content = "Some text.\n\n<!-- comment -->\n- List item";
2833 let warnings = lint(content);
2834 assert_eq!(
2835 warnings.len(),
2836 0,
2837 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2838 );
2839 }
2840
2841 #[test]
2842 fn test_html_comment_after_list_with_following_blank() {
2843 let content = "- List item\n<!-- comment -->\n\nSome text.";
2845 let warnings = lint(content);
2846 assert_eq!(
2847 warnings.len(),
2848 0,
2849 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_list_inside_html_comment_ignored() {
2855 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2857 let warnings = lint(content);
2858 assert_eq!(
2859 warnings.len(),
2860 0,
2861 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_multiline_html_comment_before_list() {
2867 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2869 let warnings = lint(content);
2870 assert_eq!(
2871 warnings.len(),
2872 0,
2873 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2874 );
2875 }
2876
2877 #[test]
2878 fn test_a_comment_line_separates_the_paragraph_from_the_list() {
2879 let content = "Some text.\n<!-- comment -->\n- List item";
2882 let warnings = lint(content);
2883 assert_eq!(
2884 warnings.len(),
2885 0,
2886 "A comment-only line separates the blocks around it. Got: {warnings:?}"
2887 );
2888 }
2889
2890 #[test]
2891 fn test_a_line_carrying_text_beside_a_comment_still_warns() {
2892 let content = "Some text. <!-- comment -->\n- List item";
2895 let warnings = lint(content);
2896 assert_eq!(
2897 warnings.len(),
2898 1,
2899 "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
2900 );
2901 assert!(
2902 warnings[0].message.contains("preceded by blank line"),
2903 "Should be 'preceded by blank line' warning"
2904 );
2905 }
2906
2907 #[test]
2908 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2909 let content = "- List item\n<!-- comment -->\nSome text.";
2912 let warnings = lint(content);
2913 assert_eq!(
2914 warnings.len(),
2915 0,
2916 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2917 );
2918 }
2919
2920 #[test]
2921 fn test_list_followed_by_heading_through_comment_should_warn() {
2922 let content = "- List item\n<!-- comment -->\n# Heading";
2924 let warnings = lint(content);
2925 assert!(
2928 warnings.len() <= 1,
2929 "Should handle heading after comment gracefully. Got: {warnings:?}"
2930 );
2931 }
2932
2933 #[test]
2934 fn test_html_comment_between_list_and_text_both_directions() {
2935 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2937 let warnings = lint(content);
2938 assert_eq!(
2939 warnings.len(),
2940 0,
2941 "Should not warn with proper separation through comments. Got: {warnings:?}"
2942 );
2943 }
2944
2945 #[test]
2946 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2947 let content = "Text.\n\n<!-- comment -->\n- Item";
2949 let fixed = fix(content);
2950 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2951 }
2952
2953 #[test]
2954 fn test_html_comment_fix_adds_blank_when_needed() {
2955 let separated = "Text.\n<!-- comment -->\n- Item";
2958 assert_eq!(
2959 fix(separated),
2960 separated,
2961 "A comment-only line needs no blank line inserted around it"
2962 );
2963
2964 let content = "Text. <!-- comment -->\n- Item";
2965 let fixed = fix(content);
2966 assert!(
2967 fixed.contains("Text. <!-- comment -->\n\n- Item"),
2968 "Fix should add blank line before list. Got: {fixed}"
2969 );
2970 }
2971
2972 #[test]
2973 fn test_ordered_list_inside_html_comment() {
2974 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2976 let warnings = lint(content);
2977 assert_eq!(
2978 warnings.len(),
2979 0,
2980 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2981 );
2982 }
2983
2984 #[test]
2991 fn test_blockquote_list_exit_no_warning() {
2992 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2994 let warnings = lint(content);
2995 assert_eq!(
2996 warnings.len(),
2997 0,
2998 "Should not warn when exiting blockquote. Got: {warnings:?}"
2999 );
3000 }
3001
3002 #[test]
3003 fn test_nested_blockquote_list_exit() {
3004 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
3006 let warnings = lint(content);
3007 assert_eq!(
3008 warnings.len(),
3009 0,
3010 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
3011 );
3012 }
3013
3014 #[test]
3015 fn test_blockquote_same_level_no_warning() {
3016 let content = "> - item 1\n> - item 2\n> Text after";
3019 let warnings = lint(content);
3020 assert_eq!(
3021 warnings.len(),
3022 0,
3023 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_blockquote_list_with_special_chars() {
3029 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
3031 let warnings = lint(content);
3032 assert_eq!(
3033 warnings.len(),
3034 0,
3035 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
3036 );
3037 }
3038
3039 #[test]
3040 fn test_lazy_continuation_whitespace_only_line() {
3041 let content = "- Item\n \nText after whitespace-only line";
3044 let config = MD032Config {
3045 allow_lazy_continuation: false,
3046 };
3047 let warnings = lint_with_config(content, config);
3048 assert_eq!(
3050 warnings.len(),
3051 0,
3052 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
3053 );
3054 }
3055
3056 #[test]
3057 fn test_lazy_continuation_blockquote_context() {
3058 let content = "> - Item\n> Lazy in quote";
3060 let config = MD032Config {
3061 allow_lazy_continuation: false,
3062 };
3063 let warnings = lint_with_config(content, config);
3064 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
3067 }
3068
3069 #[test]
3070 fn test_lazy_continuation_fix_preserves_content() {
3071 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
3073 let config = MD032Config {
3074 allow_lazy_continuation: false,
3075 };
3076 let fixed = fix_with_config(content, config);
3077 assert!(fixed.contains("<>&"), "Should preserve special chars");
3078 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
3079 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
3081 }
3082
3083 #[test]
3084 fn test_lazy_continuation_fix_idempotent() {
3085 let content = "- Item\nLazy";
3087 let config = MD032Config {
3088 allow_lazy_continuation: false,
3089 };
3090 let fixed_once = fix_with_config(content, config.clone());
3091 let fixed_twice = fix_with_config(&fixed_once, config);
3092 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
3093 }
3094
3095 #[test]
3096 fn test_lazy_continuation_config_default_allows() {
3097 let content = "- Item\nLazy text that continues";
3099 let default_config = MD032Config::default();
3100 assert!(
3101 default_config.allow_lazy_continuation,
3102 "Default should allow lazy continuation"
3103 );
3104 let warnings = lint_with_config(content, default_config);
3105 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
3106 }
3107
3108 #[test]
3109 fn test_lazy_continuation_after_multi_line_item() {
3110 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
3112 let config = MD032Config {
3113 allow_lazy_continuation: false,
3114 };
3115 let warnings = lint_with_config(content, config.clone());
3116 assert_eq!(
3117 warnings.len(),
3118 1,
3119 "Should warn only for the lazy line, not the indented line"
3120 );
3121 }
3122
3123 #[test]
3125 fn test_blockquote_list_with_continuation_and_nested() {
3126 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
3129 let warnings = lint(content);
3130 assert_eq!(
3131 warnings.len(),
3132 0,
3133 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
3134 );
3135 }
3136
3137 #[test]
3138 fn test_blockquote_list_simple() {
3139 let content = "> - item 1\n> - item 2";
3141 let warnings = lint(content);
3142 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
3143 }
3144
3145 #[test]
3146 fn test_blockquote_list_with_continuation_only() {
3147 let content = "> - item 1\n> continuation\n> - item 2";
3149 let warnings = lint(content);
3150 assert_eq!(
3151 warnings.len(),
3152 0,
3153 "Blockquoted list with continuation should have no warnings"
3154 );
3155 }
3156
3157 #[test]
3158 fn test_blockquote_list_with_lazy_continuation() {
3159 let content = "> - item 1\n> lazy continuation\n> - item 2";
3161 let warnings = lint(content);
3162 assert_eq!(
3163 warnings.len(),
3164 0,
3165 "Blockquoted list with lazy continuation should have no warnings"
3166 );
3167 }
3168
3169 #[test]
3170 fn test_nested_blockquote_list() {
3171 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
3173 let warnings = lint(content);
3174 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3175 }
3176
3177 #[test]
3178 fn test_blockquote_list_needs_preceding_blank() {
3179 let content = "> Text before\n> - item 1\n> - item 2";
3181 let warnings = lint(content);
3182 assert_eq!(
3183 warnings.len(),
3184 1,
3185 "Should warn for missing blank before blockquoted list"
3186 );
3187 }
3188
3189 #[test]
3190 fn test_blockquote_list_properly_separated() {
3191 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3193 let warnings = lint(content);
3194 assert_eq!(
3195 warnings.len(),
3196 0,
3197 "Properly separated blockquoted list should have no warnings"
3198 );
3199 }
3200
3201 #[test]
3202 fn test_blockquote_ordered_list() {
3203 let content = "> 1. item 1\n> continuation\n> 2. item 2";
3205 let warnings = lint(content);
3206 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3207 }
3208
3209 #[test]
3210 fn test_blockquote_list_with_empty_blockquote_line() {
3211 let content = "> - item 1\n>\n> - item 2";
3213 let warnings = lint(content);
3214 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3215 }
3216
3217 #[test]
3219 fn test_blockquote_list_multi_paragraph_items() {
3220 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
3223 let warnings = lint(content);
3224 assert_eq!(
3225 warnings.len(),
3226 0,
3227 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3228 );
3229 }
3230
3231 #[test]
3233 fn test_blockquote_ordered_list_multi_paragraph_items() {
3234 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
3235 let warnings = lint(content);
3236 assert_eq!(
3237 warnings.len(),
3238 0,
3239 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3240 );
3241 }
3242
3243 #[test]
3245 fn test_blockquote_list_multiple_continuations() {
3246 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
3247 let warnings = lint(content);
3248 assert_eq!(
3249 warnings.len(),
3250 0,
3251 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3252 );
3253 }
3254
3255 #[test]
3257 fn test_nested_blockquote_multi_paragraph_list() {
3258 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
3259 let warnings = lint(content);
3260 assert_eq!(
3261 warnings.len(),
3262 0,
3263 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3264 );
3265 }
3266
3267 #[test]
3269 fn test_triple_nested_blockquote_multi_paragraph_list() {
3270 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
3271 let warnings = lint(content);
3272 assert_eq!(
3273 warnings.len(),
3274 0,
3275 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3276 );
3277 }
3278
3279 #[test]
3281 fn test_blockquote_list_last_item_continuation() {
3282 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
3283 let warnings = lint(content);
3284 assert_eq!(
3285 warnings.len(),
3286 0,
3287 "Last item with continuation should have no warnings. Got: {warnings:?}"
3288 );
3289 }
3290
3291 #[test]
3293 fn test_blockquote_list_first_item_only_continuation() {
3294 let content = "> - Item 1\n> \n> Continuation of item 1\n";
3295 let warnings = lint(content);
3296 assert_eq!(
3297 warnings.len(),
3298 0,
3299 "Single item with continuation should have no warnings. Got: {warnings:?}"
3300 );
3301 }
3302
3303 #[test]
3307 fn test_blockquote_level_change_breaks_list() {
3308 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3310 let warnings = lint(content);
3311 assert!(
3315 warnings.len() <= 2,
3316 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3317 );
3318 }
3319
3320 #[test]
3322 fn test_exit_blockquote_needs_blank_before_list() {
3323 let content = "> Blockquote text\n\n- List outside blockquote\n";
3325 let warnings = lint(content);
3326 assert_eq!(
3327 warnings.len(),
3328 0,
3329 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3330 );
3331
3332 let content2 = "> Blockquote text\n- List outside blockquote\n";
3336 let warnings2 = lint(content2);
3337 assert!(
3339 warnings2.len() <= 1,
3340 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3341 );
3342 }
3343
3344 #[test]
3346 fn test_blockquote_multi_paragraph_all_unordered_markers() {
3347 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
3349 let warnings = lint(content_dash);
3350 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3351
3352 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
3354 let warnings = lint(content_asterisk);
3355 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3356
3357 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
3359 let warnings = lint(content_plus);
3360 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3361 }
3362
3363 #[test]
3365 fn test_blockquote_multi_paragraph_parenthesis_marker() {
3366 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
3367 let warnings = lint(content);
3368 assert_eq!(
3369 warnings.len(),
3370 0,
3371 "Parenthesis ordered markers should work. Got: {warnings:?}"
3372 );
3373 }
3374
3375 #[test]
3377 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3378 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
3380 let warnings = lint(content);
3381 assert_eq!(
3382 warnings.len(),
3383 0,
3384 "Multi-digit ordered list should work. Got: {warnings:?}"
3385 );
3386 }
3387
3388 #[test]
3390 fn test_blockquote_multi_paragraph_with_formatting() {
3391 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3392 let warnings = lint(content);
3393 assert_eq!(
3394 warnings.len(),
3395 0,
3396 "Continuation with inline formatting should work. Got: {warnings:?}"
3397 );
3398 }
3399
3400 #[test]
3402 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3403 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3404 let warnings = lint(content);
3405 assert_eq!(
3406 warnings.len(),
3407 0,
3408 "All items with continuations should work. Got: {warnings:?}"
3409 );
3410 }
3411
3412 #[test]
3414 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3415 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3416 let warnings = lint(content);
3417 assert_eq!(
3418 warnings.len(),
3419 0,
3420 "Lowercase continuation should work. Got: {warnings:?}"
3421 );
3422 }
3423
3424 #[test]
3426 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3427 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3428 let warnings = lint(content);
3429 assert_eq!(
3430 warnings.len(),
3431 0,
3432 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3433 );
3434 }
3435
3436 #[test]
3438 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3439 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3441 let warnings = lint(content);
3442 assert!(
3444 warnings.len() <= 1,
3445 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3446 );
3447 }
3448
3449 #[test]
3451 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3452 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3454 let warnings = lint(content);
3455 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3456 }
3457
3458 #[test]
3459 fn test_blockquote_list_varying_spaces_after_marker() {
3460 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3462 let warnings = lint(content);
3463 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3464 }
3465
3466 #[test]
3467 fn test_deeply_nested_blockquote_list() {
3468 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3470 let warnings = lint(content);
3471 assert_eq!(
3472 warnings.len(),
3473 0,
3474 "Deeply nested blockquote list should have no warnings"
3475 );
3476 }
3477
3478 #[test]
3479 fn test_blockquote_level_change_in_list() {
3480 let content = "> - item 1\n>> - deeper item\n> - item 2";
3482 let warnings = lint(content);
3485 assert!(
3486 !warnings.is_empty(),
3487 "Blockquote level change should break list and trigger warnings"
3488 );
3489 }
3490
3491 #[test]
3492 fn test_blockquote_list_with_code_span() {
3493 let content = "> - item with `code`\n> continuation\n> - item 2";
3495 let warnings = lint(content);
3496 assert_eq!(
3497 warnings.len(),
3498 0,
3499 "Blockquote list with code span should have no warnings"
3500 );
3501 }
3502
3503 #[test]
3504 fn test_code_span_html_comment_delimiters_no_false_positive() {
3505 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";
3511 let warnings = lint(content);
3512 assert_eq!(
3513 warnings.len(),
3514 0,
3515 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3516 );
3517 }
3518
3519 #[test]
3520 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3521 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";
3526 let fixed = fix(content);
3527 assert_eq!(
3528 fixed, content,
3529 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3530 );
3531 }
3532
3533 #[test]
3534 fn test_blockquote_list_at_document_end() {
3535 let content = "> Some text\n>\n> - item 1\n> - item 2";
3537 let warnings = lint(content);
3538 assert_eq!(
3539 warnings.len(),
3540 0,
3541 "Blockquote list at document end should have no warnings"
3542 );
3543 }
3544
3545 #[test]
3546 fn test_fix_preserves_blockquote_prefix_before_list() {
3547 let content = "> Text before
3549> - Item 1
3550> - Item 2";
3551 let fixed = fix(content);
3552
3553 let expected = "> Text before
3555>
3556> - Item 1
3557> - Item 2";
3558 assert_eq!(
3559 fixed, expected,
3560 "Fix should insert '>' blank line, not plain blank line"
3561 );
3562 }
3563
3564 #[test]
3565 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3566 let content = ">>> Triple nested
3569>>> - Item 1
3570>>> - Item 2
3571>>> More text";
3572 let fixed = fix(content);
3573
3574 let expected = ">>> Triple nested
3576>>>
3577>>> - Item 1
3578>>> - Item 2
3579>>> More text";
3580 assert_eq!(
3581 fixed, expected,
3582 "Fix should preserve triple-nested blockquote prefix '>>>'"
3583 );
3584 }
3585
3586 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3589 let rule = MD032BlanksAroundLists::default();
3590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3591 rule.check(&ctx).unwrap()
3592 }
3593
3594 #[test]
3595 fn test_quarto_list_after_div_open() {
3596 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3598 let warnings = lint_quarto(content);
3599 assert!(
3601 warnings.is_empty(),
3602 "Quarto div marker should be transparent before list: {warnings:?}"
3603 );
3604 }
3605
3606 #[test]
3607 fn test_quarto_list_before_div_close() {
3608 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3610 let warnings = lint_quarto(content);
3611 assert!(
3613 warnings.is_empty(),
3614 "Quarto div marker should be transparent after list: {warnings:?}"
3615 );
3616 }
3617
3618 #[test]
3619 fn test_quarto_list_needs_blank_without_div() {
3620 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3622 let warnings = lint_quarto(content);
3623 assert!(
3626 !warnings.is_empty(),
3627 "Should still require blank when not present: {warnings:?}"
3628 );
3629 }
3630
3631 #[test]
3632 fn test_quarto_list_in_callout_with_content() {
3633 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3635 let warnings = lint_quarto(content);
3636 assert!(
3637 warnings.is_empty(),
3638 "List with proper blanks inside callout should pass: {warnings:?}"
3639 );
3640 }
3641
3642 #[test]
3643 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3644 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3646 let warnings = lint(content); assert!(
3649 !warnings.is_empty(),
3650 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3651 );
3652 }
3653
3654 #[test]
3655 fn test_quarto_nested_divs_with_list() {
3656 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3658 let warnings = lint_quarto(content);
3659 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3660 }
3661
3662 #[test]
3663 fn test_issue512_complex_nested_list_with_continuation() {
3664 let content = "\
3667- First level of indentation.
3668 - Second level of indentation.
3669 - Third level of indentation.
3670 - Third level of indentation.
3671
3672 Second level list continuation.
3673
3674 First level list continuation.
3675- First level of indentation.
3676";
3677 let warnings = lint(content);
3678 assert!(
3679 warnings.is_empty(),
3680 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3681 );
3682 }
3683
3684 #[test]
3685 fn test_issue512_continuation_at_root_level() {
3686 let content = "\
3690- First level.
3691 - Second level.
3692
3693 First level continuation.
3694
3695Root level lazy continuation.
3696- Another first level item.
3697";
3698 let warnings = lint(content);
3699 assert_eq!(
3700 warnings.len(),
3701 1,
3702 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3703 );
3704 assert_eq!(warnings[0].line, 7);
3705 }
3706
3707 #[test]
3708 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3709 let content = "\
3711- Level 1 item.
3712 - Level 2 item.
3713 - Level 3 item.
3714
3715 Level 3 continuation.
3716
3717 Level 2 continuation.
3718
3719 Level 1 continuation (indented under marker).
3720- Another level 1 item.
3721";
3722 let warnings = lint(content);
3723 assert!(
3724 warnings.is_empty(),
3725 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3726 );
3727 }
3728
3729 #[test]
3730 fn test_pandoc_list_after_div_open() {
3731 let rule = MD032BlanksAroundLists::default();
3734 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3736 let warnings = rule.check(&ctx).unwrap();
3737 assert!(
3738 warnings.is_empty(),
3739 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3740 );
3741 }
3742
3743 #[test]
3744 fn test_md032_html_comment() {
3745 let rule = MD032BlanksAroundLists::default();
3746 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3747 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3748 let warnings = rule.check(&ctx).unwrap();
3749 assert!(
3750 warnings.is_empty(),
3751 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3752 );
3753 }
3754
3755 #[test]
3756 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3757 let rule = MD032BlanksAroundLists::default();
3763 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";
3764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3765 let warnings = rule.check(&ctx).unwrap();
3766 assert!(
3767 warnings.is_empty(),
3768 "admonition-nested ordered list should not be flagged: {warnings:?}"
3769 );
3770 }
3771
3772 #[test]
3773 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3774 let rule = MD032BlanksAroundLists::default();
3778 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";
3779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3780 let warnings = rule.check(&ctx).unwrap();
3781 assert!(
3782 warnings.is_empty(),
3783 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3784 );
3785 }
3786
3787 #[test]
3788 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3789 let rule = MD032BlanksAroundLists::default();
3791 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";
3792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3793 let warnings = rule.check(&ctx).unwrap();
3794 assert!(
3795 warnings.is_empty(),
3796 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3797 );
3798 }
3799
3800 #[test]
3801 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3802 let rule = MD032BlanksAroundLists::default();
3808 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3810 let warnings = rule.check(&ctx).unwrap();
3811 assert_eq!(
3812 warnings.len(),
3813 1,
3814 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3815 );
3816 }
3817
3818 #[test]
3819 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3820 let rule = MD032BlanksAroundLists::default();
3825 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
3826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3827 let warnings = rule.check(&ctx).unwrap();
3828 assert_eq!(
3829 warnings.len(),
3830 1,
3831 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3832 );
3833 }
3834
3835 #[test]
3836 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3837 let rule = MD032BlanksAroundLists::default();
3842 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";
3843 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3844 let warnings = rule.check(&ctx).unwrap();
3845 assert!(
3846 warnings.is_empty(),
3847 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3848 );
3849 }
3850
3851 #[test]
3852 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3853 let rule = MD032BlanksAroundLists::default();
3859 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3861 let warnings = rule.check(&ctx).unwrap();
3862 assert_eq!(
3863 warnings.len(),
3864 1,
3865 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3866 );
3867 assert_eq!(warnings[0].line, 4);
3868 assert!(warnings[0].message.contains("non-1"));
3869 }
3870
3871 #[test]
3872 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3873 let rule = MD032BlanksAroundLists::default();
3879 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";
3880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3881 let warnings = rule.check(&ctx).unwrap();
3882 assert!(
3883 warnings.is_empty(),
3884 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3885 );
3886 }
3887
3888 #[test]
3889 fn test_standard_flavor_admonition_indented_list_unchanged() {
3890 let rule = MD032BlanksAroundLists::default();
3895 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";
3896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3897 let warnings = rule.check(&ctx).unwrap();
3898 assert!(
3899 warnings.is_empty(),
3900 "indented code block under standard flavor should not be flagged: {warnings:?}"
3901 );
3902 }
3903
3904 #[test]
3905 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3906 let rule = MD032BlanksAroundLists::default();
3911 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";
3912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3913 let warnings = rule.check(&ctx).unwrap();
3914 assert_eq!(
3915 warnings.len(),
3916 1,
3917 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3918 );
3919 assert_eq!(warnings[0].line, 6);
3920 }
3921
3922 #[test]
3923 fn test_pseudo_list_marker_after_list() {
3924 let content = indoc::indoc! {"
3925 - Item 1
3926 Item 1 content.
3927
3928 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3929 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3930 are not used except when those semantics are desired.
3931 "};
3932 let warnings = lint(content);
3933 assert!(
3934 warnings.is_empty(),
3935 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3936 );
3937 }
3938
3939 #[test]
3940 fn test_pseudo_list_marker_without_preceding_list() {
3941 let content = indoc::indoc! {"
3942 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3943 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3944 are not used except when those semantics are desired.
3945 "};
3946 let warnings = lint(content);
3947 assert!(
3948 warnings.is_empty(),
3949 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3950 );
3951 }
3952
3953 #[test]
3954 fn test_no_space_hash_continuation_line_stays_in_its_item() {
3955 let content = indoc::indoc! {"
3960 5. **`M.md`** - the deltas (esp. items #1,
3961 #2, #3, #5, #8).
3962
3963 ---
3964
3965 ## Plan
3966
3967 ### Phase 0
3968 - [ ] task one
3969 wrapped
3970 "};
3971 let warnings = lint(content);
3972 assert_eq!(
3973 warnings.len(),
3974 1,
3975 "only the task list is missing a blank line, got: {warnings:?}"
3976 );
3977 assert_eq!(warnings[0].line, 9);
3978 assert_eq!(warnings[0].message, "List should be preceded by blank line");
3979
3980 let expected = indoc::indoc! {"
3981 5. **`M.md`** - the deltas (esp. items #1,
3982 #2, #3, #5, #8).
3983
3984 ---
3985
3986 ## Plan
3987
3988 ### Phase 0
3989
3990 - [ ] task one
3991 wrapped
3992 "};
3993 assert_eq!(fix(content), expected);
3994 }
3995
3996 #[test]
3997 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3998 let content = indoc::indoc! {"
4004 1. first
4005
4006 3. item
4007 continuation
4008
4009 1. nested
4010 2. nested
4011
4012 ## Heading
4013 - task
4014 "};
4015 let warnings = lint(content);
4016 assert_eq!(
4017 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4018 vec![10],
4019 "only the list after the heading is missing a blank line, got: {warnings:?}"
4020 );
4021
4022 let expected = indoc::indoc! {"
4023 1. first
4024
4025 3. item
4026 continuation
4027
4028 1. nested
4029 2. nested
4030
4031 ## Heading
4032
4033 - task
4034 "};
4035 assert_eq!(fix(content), expected);
4036 }
4037
4038 #[test]
4039 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
4040 let content = indoc::indoc! {"
4044 - item (esp. #1,
4045 #2, #3).
4046 - next item
4047
4048 ## Heading
4049 - task
4050 "};
4051 let warnings = lint(content);
4052 assert_eq!(
4053 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4054 vec![6],
4055 "only the list after the heading is missing a blank line, got: {warnings:?}"
4056 );
4057
4058 let expected = indoc::indoc! {"
4059 - item (esp. #1,
4060 #2, #3).
4061 - next item
4062
4063 ## Heading
4064
4065 - task
4066 "};
4067 assert_eq!(fix(content), expected);
4068 }
4069
4070 #[test]
4071 fn test_under_indented_continuation_lines_stay_in_their_item() {
4072 for content in [
4076 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
4077 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
4078 "- Item\n lazy continuation\n- another item\n",
4079 "> - Item\n> lazy continuation\n> - another item\n",
4080 ] {
4081 let warnings = lint(content);
4082 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4083 assert_eq!(fix(content), content, "{content:?}");
4084 }
4085 }
4086
4087 #[test]
4088 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
4089 let config = MD032Config {
4092 allow_lazy_continuation: false,
4093 };
4094 for (content, lazy_lines) in [
4095 ("- Item\n lazy continuation\n- another item\n", vec![2]),
4096 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
4097 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
4098 ] {
4099 let warnings = lint_with_config(content, config.clone());
4100 assert!(
4101 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
4102 "{content:?}: got {warnings:?}"
4103 );
4104 assert_eq!(
4105 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
4106 lazy_lines,
4107 "{content:?}: got {warnings:?}"
4108 );
4109 }
4110 }
4111
4112 #[test]
4113 fn test_structural_line_at_short_indent_ends_the_list() {
4114 for (content, expected) in [
4118 ("1. item\n ---\n", "1. item\n\n ---\n"),
4119 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
4120 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4121 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
4122 ] {
4123 let warnings = lint(content);
4124 assert_eq!(
4125 warnings
4126 .iter()
4127 .map(|w| (w.line, w.message.as_str()))
4128 .collect::<Vec<_>>(),
4129 vec![(1, "List should be followed by blank line")],
4130 "{content:?}: got {warnings:?}"
4131 );
4132 assert_eq!(fix(content), expected, "{content:?}");
4133 }
4134 }
4135
4136 #[test]
4137 fn test_html_block_at_short_indent_ends_the_list() {
4138 for (content, expected) in [
4145 (
4146 "- item\n<script>\nx\n</script>\n- next\n",
4147 "- item\n\n<script>\nx\n</script>\n\n- next\n",
4148 ),
4149 (
4150 "- item\n <script>\n x\n </script>\n- next\n",
4151 "- item\n\n <script>\n x\n </script>\n\n- next\n",
4152 ),
4153 (
4154 "- item\n <pre>\n x\n </pre>\n- next\n",
4155 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
4156 ),
4157 (
4158 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
4159 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4160 ),
4161 (
4162 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4163 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4164 ),
4165 ] {
4166 let warnings = lint(content);
4167 assert_eq!(
4168 warnings
4169 .iter()
4170 .map(|w| (w.line, w.message.as_str()))
4171 .collect::<Vec<_>>(),
4172 vec![
4173 (1, "List should be followed by blank line"),
4174 (5, "List should be preceded by blank line"),
4175 ],
4176 "{content:?}: got {warnings:?}"
4177 );
4178 assert_eq!(fix(content), expected, "{content:?}");
4179 }
4180 }
4181
4182 #[test]
4183 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4184 for content in [
4196 "100. item\n <div>\n101. next\n",
4197 "> 100. item\n> <div>\n> 101. next\n",
4198 "100. item\n <div>\ntext\n101. next\n",
4199 "- item\n<div.class>\n- next\n",
4200 "> - item\n> <div.class>\n> - next\n",
4201 "100. item\n\t<div>\n101. next\n",
4202 "- item\n \t<div>\n- next\n",
4203 "> - item\n> \t<div>\n> - next\n",
4204 "> - item\n>\t<div>\n> - next\n",
4205 "> 100. item\n> \t<div>\n> 101. next\n",
4206 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4207 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4208 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
4209 ] {
4210 let warnings = lint(content);
4211 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4212 assert_eq!(fix(content), content, "{content:?}");
4213 }
4214
4215 for (content, last_item_line) in [
4219 ("100. item\n <div>\n101. next\n", 1),
4220 ("> 100. item\n> <div>\n> 101. next\n", 1),
4221 ("- item\n <div>\n- next\n", 1),
4222 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4223 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4224 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
4225 ] {
4226 let warnings = lint(content);
4227 assert_eq!(
4228 warnings
4229 .iter()
4230 .map(|w| (w.line, w.message.as_str()))
4231 .collect::<Vec<_>>(),
4232 vec![(last_item_line, "List should be followed by blank line")],
4233 "{content:?}: got {warnings:?}"
4234 );
4235 }
4236 }
4237
4238 #[test]
4239 fn test_tab_indented_nested_list_stays_inside_its_item() {
4240 for content in [
4249 "* item text\n\t1. nested\n\t more\n",
4250 "* item text\n\tcontinuation\n\t1. nested\n",
4251 "1. item text\n\t- nested\n",
4252 "> * item text\n>\t1. nested\n",
4253 "> * item text\n> \t1. nested\n",
4254 "* item text\n\tcontinuation\n\t- nested\n",
4255 ] {
4256 let warnings = lint(content);
4257 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4258 assert_eq!(fix(content), content, "{content:?}");
4259 }
4260
4261 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
4264 let warnings = lint(content);
4265 assert_eq!(
4266 warnings
4267 .iter()
4268 .map(|w| (w.line, w.message.as_str()))
4269 .collect::<Vec<_>>(),
4270 vec![
4271 (1, "List should be followed by blank line"),
4272 (2, "List should be preceded by blank line"),
4273 ],
4274 "{content:?}: got {warnings:?}"
4275 );
4276 }
4277 }
4278
4279 #[test]
4280 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4281 for (content, expected) in [
4286 (
4287 "- item\n<div>\nx\n</div>\n- next\n",
4288 "- item\n\n<div>\nx\n</div>\n- next\n",
4289 ),
4290 (
4291 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4292 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4293 ),
4294 ] {
4295 let warnings = lint(content);
4296 assert_eq!(
4297 warnings
4298 .iter()
4299 .map(|w| (w.line, w.message.as_str()))
4300 .collect::<Vec<_>>(),
4301 vec![(1, "List should be followed by blank line")],
4302 "{content:?}: got {warnings:?}"
4303 );
4304 assert_eq!(fix(content), expected, "{content:?}");
4305 }
4306 }
4307
4308 #[test]
4309 fn test_html_block_at_content_column_is_item_content() {
4310 for content in [
4313 "- item\n <script>\n x\n </script>\n- next\n",
4314 "- item\n <div>\n x\n </div>\n- next\n",
4315 "1. item\n <pre>\n x\n </pre>\n2. next\n",
4316 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4317 ] {
4318 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4319 assert_eq!(fix(content), content, "{content:?}");
4320 }
4321 }
4322}