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::pandoc;
6use crate::utils::range_utils::calculate_line_range;
7use regex::Regex;
8use std::sync::LazyLock;
9
10mod md032_config;
11pub(super) use md032_config::MD032Config;
12
13static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
15
16fn is_thematic_break(line: &str) -> bool {
19 if calculate_indentation_width_default(line) > 3 {
21 return false;
22 }
23
24 let trimmed = line.trim();
25 if trimmed.len() < 3 {
26 return false;
27 }
28
29 let chars: Vec<char> = trimmed.chars().collect();
30 let first_non_space = chars.iter().find(|&&c| c != ' ');
31
32 if let Some(&marker) = first_non_space {
33 if marker != '-' && marker != '*' && marker != '_' {
34 return false;
35 }
36 let marker_count = chars.iter().filter(|&&c| c == marker).count();
37 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
38 marker_count >= 3 && other_count == 0
39 } else {
40 false
41 }
42}
43
44#[derive(Debug, Clone, Default)]
114pub struct MD032BlanksAroundLists {
115 config: MD032Config,
116}
117
118impl MD032BlanksAroundLists {
119 pub fn from_config_struct(config: MD032Config) -> Self {
120 Self { config }
121 }
122}
123
124impl MD032BlanksAroundLists {
125 fn should_require_blank_line_before(
127 ctx: &crate::lint_context::LintContext,
128 prev_line_num: usize,
129 current_line_num: usize,
130 ) -> bool {
131 if ctx
133 .line_info(prev_line_num)
134 .is_some_and(|info| info.in_code_block || info.in_front_matter)
135 {
136 return true;
137 }
138
139 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
141 return false;
142 }
143
144 true
146 }
147
148 fn is_nested_list(
150 ctx: &crate::lint_context::LintContext,
151 prev_line_num: usize, current_line_num: usize, ) -> bool {
154 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
156 let current_line = &ctx.lines[current_line_num - 1];
157 if current_line.indent >= 2 {
158 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
160 let prev_line = &ctx.lines[prev_line_num - 1];
161 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
163 return true;
164 }
165 }
166 }
167 }
168 false
169 }
170
171 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
174 ctx.lines
175 .get(line_num.saturating_sub(1))
176 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
177 }
178
179 fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
185 if !ctx.flavor.is_pandoc_compatible() {
186 return false;
187 }
188 let trimmed = info.content(ctx.content).trim();
189 pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
190 }
191
192 fn is_reportable_lazy_line(
195 ctx: &crate::lint_context::LintContext,
196 list_blocks: &[(usize, usize, String)],
197 line_num: usize,
198 ) -> bool {
199 let is_within_block = list_blocks
200 .iter()
201 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
202 if !is_within_block {
203 return false;
204 }
205 ctx.lines
206 .get(line_num.saturating_sub(1))
207 .is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
208 }
209
210 fn calculate_lazy_continuation_fix(
213 ctx: &crate::lint_context::LintContext,
214 line_num: usize,
215 lazy_info: &LazyContLine,
216 ) -> Option<Fix> {
217 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
218 let line_content = line_info.content(ctx.content);
219
220 if lazy_info.blockquote_level == 0 {
221 let start_byte = line_info.byte_offset;
223 let end_byte = start_byte + lazy_info.current_indent;
224 let replacement = " ".repeat(lazy_info.expected_indent);
225
226 Some(Fix::new(start_byte..end_byte, replacement))
227 } else {
228 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
230 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
231 if prefix_byte_len == 0 {
232 return None;
233 }
234
235 let current_indent = after_bq.len() - after_bq.trim_start().len();
236 let start_byte = line_info.byte_offset + prefix_byte_len;
237 let end_byte = start_byte + current_indent;
238 let replacement = " ".repeat(lazy_info.expected_indent);
239
240 Some(Fix::new(start_byte..end_byte, replacement))
241 }
242 }
243
244 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
247 if lazy_info.blockquote_level == 0 {
248 let content = line.trim_start();
250 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
251 } else {
252 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
254 let prefix_len = line.len().saturating_sub(after_bq.len());
255 if prefix_len == 0 {
256 return line.to_string();
257 }
258
259 let prefix = &line[..prefix_len];
260 let rest = after_bq.trim_start();
261 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
262 }
263 }
264
265 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
273 for line_num in (1..before_line).rev() {
274 let idx = line_num - 1;
275 if let Some(info) = ctx.lines.get(idx) {
276 if info.in_html_comment || info.in_mdx_comment {
278 continue;
279 }
280 if Self::is_transparent_div_marker(ctx, info) {
282 continue;
283 }
284 return (line_num, is_blank_in_context(info.content(ctx.content)));
285 }
286 }
287 (0, true)
289 }
290
291 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
298 let num_lines = ctx.lines.len();
299 for line_num in (after_line + 1)..=num_lines {
300 let idx = line_num - 1;
301 if let Some(info) = ctx.lines.get(idx) {
302 if info.in_html_comment || info.in_mdx_comment {
304 continue;
305 }
306 if Self::is_transparent_div_marker(ctx, info) {
308 continue;
309 }
310 return (line_num, is_blank_in_context(info.content(ctx.content)));
311 }
312 }
313 (0, true)
315 }
316
317 fn is_following_content_excluded(ctx: &crate::lint_context::LintContext, line_num: usize, prefix: &str) -> bool {
320 ctx.line_info(line_num).is_some_and(|info| {
321 info.in_front_matter
322 || (info.in_code_block
323 && effective_indent_in_blockquote(
324 info.content(ctx.content),
325 prefix.chars().filter(|&c| c == '>').count(),
326 info.indent,
327 ) >= 2)
328 })
329 }
330
331 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
333 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
334
335 for block in &ctx.list_blocks {
336 if ctx
338 .line_info(block.start_line)
339 .is_some_and(|info| info.in_footnote_definition)
340 {
341 continue;
342 }
343
344 let mut segments: Vec<(usize, usize)> = Vec::new();
350 let mut current_start = block.start_line;
351 let mut prev_item_line = 0;
352
353 let get_blockquote_level = |line_num: usize| -> usize {
355 if line_num == 0 || line_num > ctx.lines.len() {
356 return 0;
357 }
358 let line_content = ctx.lines[line_num - 1].content(ctx.content);
359 parse_blockquote_prefix(line_content).map_or(0, |bq| bq.nesting_level)
360 };
361
362 let mut prev_bq_level = 0;
363
364 for &item_line in &block.item_lines {
365 let current_bq_level = get_blockquote_level(item_line);
366
367 if prev_item_line > 0 {
368 let blockquote_level_changed = prev_bq_level != current_bq_level;
370
371 let mut has_standalone_code_fence = false;
374
375 let min_indent_for_content = if block.is_ordered {
377 3 } else {
381 2 };
384
385 for check_line in (prev_item_line + 1)..item_line {
386 if check_line - 1 < ctx.lines.len() {
387 let line = &ctx.lines[check_line - 1];
388 let line_content = line.content(ctx.content);
389 if line.in_code_block
390 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
391 {
392 if line.indent < min_indent_for_content {
395 has_standalone_code_fence = true;
396 break;
397 }
398 }
399 }
400 }
401
402 if has_standalone_code_fence || blockquote_level_changed {
403 segments.push((current_start, prev_item_line));
405 current_start = item_line;
406 }
407 }
408 prev_item_line = item_line;
409 prev_bq_level = current_bq_level;
410 }
411
412 if prev_item_line > 0 {
415 segments.push((current_start, prev_item_line));
416 }
417
418 let has_code_fence_splits = segments.len() > 1 && {
420 let mut found_fence = false;
422 for i in 0..segments.len() - 1 {
423 let seg_end = segments[i].1;
424 let next_start = segments[i + 1].0;
425 for check_line in (seg_end + 1)..next_start {
427 if check_line - 1 < ctx.lines.len() {
428 let line = &ctx.lines[check_line - 1];
429 let line_content = line.content(ctx.content);
430 if line.in_code_block
431 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
432 {
433 found_fence = true;
434 break;
435 }
436 }
437 }
438 if found_fence {
439 break;
440 }
441 }
442 found_fence
443 };
444
445 for (start, end) in &segments {
447 let mut actual_end = *end;
449
450 if !has_code_fence_splits && *end < block.end_line {
453 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
455
456 let min_continuation_indent = if block_bq_level > 0 {
459 if block.is_ordered {
461 block.max_marker_width
462 } else {
463 2 }
465 } else {
466 ctx.lines
467 .get(*end - 1)
468 .and_then(|line_info| line_info.list_item.as_ref())
469 .map_or(2, |item| item.content_column)
470 };
471
472 for check_line in (*end + 1)..=block.end_line {
473 if check_line - 1 < ctx.lines.len() {
474 let line = &ctx.lines[check_line - 1];
475 let line_content = line.content(ctx.content);
476 if block.item_lines.contains(&check_line) || line.is_valid_heading() {
481 break;
482 }
483 if line.in_code_block {
485 break;
486 }
487
488 let effective_indent =
490 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
491
492 if effective_indent >= min_continuation_indent {
494 actual_end = check_line;
495 }
496 else if !line.is_blank
501 && !line.is_valid_heading()
502 && !block.item_lines.contains(&check_line)
503 && !is_thematic_break(line_content)
504 {
505 actual_end = check_line;
507 } else if !line.is_blank {
508 break;
510 }
511 }
512 }
513 }
514
515 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
516 }
517 }
518
519 blocks.retain(|(start, end, _)| {
521 let all_in_comment = (*start..=*end).all(|line_num| {
523 ctx.lines
524 .get(line_num - 1)
525 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
526 });
527 !all_in_comment
528 });
529
530 blocks
531 }
532
533 fn perform_checks(
534 &self,
535 ctx: &crate::lint_context::LintContext,
536 lines: &[&str],
537 list_blocks: &[(usize, usize, String)],
538 ) -> Vec<LintWarning> {
539 let mut warnings = Vec::new();
540 let num_lines = lines.len();
541
542 for (line_idx, line) in lines.iter().enumerate() {
545 let line_num = line_idx + 1;
546
547 let is_in_list = list_blocks
549 .iter()
550 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
551 if is_in_list {
552 continue;
553 }
554
555 if ctx.line_info(line_num).is_some_and(|info| {
557 info.in_code_block
558 || info.in_front_matter
559 || info.in_html_comment
560 || info.in_mdx_comment
561 || info.in_html_block
562 || info.in_jsx_block
563 }) {
564 continue;
565 }
566
567 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
569 if line_idx > 0 {
571 let prev_line = lines[line_idx - 1];
572 let prev_is_blank = is_blank_in_context(prev_line);
573 let prev_line_info = ctx.line_info(line_idx);
574 let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
575
576 let prev_in_mkdocs_container =
592 prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
593 let continues_stale_container_list = prev_in_mkdocs_container && {
594 let item_indent = calculate_indentation_width_default(line);
595 let mut found_marker = false;
596 for j in (0..line_idx).rev() {
597 let in_container = ctx
598 .line_info(j + 1)
599 .is_some_and(|info| info.in_admonition || info.in_content_tab);
600 if !in_container {
601 break;
602 }
603 let candidate = lines[j];
604 if is_blank_in_context(candidate) {
605 continue;
606 }
607 let candidate_indent = calculate_indentation_width_default(candidate);
608 if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
609 && candidate_indent == item_indent
610 {
611 found_marker = true;
612 break;
613 }
614 if candidate_indent <= item_indent {
615 break;
616 }
617 }
618 found_marker
619 };
620
621 let prev_trimmed = prev_line.trim();
626 let is_sentence_continuation = continues_stale_container_list
627 || (!prev_is_blank
628 && !prev_trimmed.is_empty()
629 && !prev_trimmed.ends_with('.')
630 && !prev_trimmed.ends_with('!')
631 && !prev_trimmed.ends_with('?')
632 && !prev_trimmed.ends_with(':')
633 && !prev_trimmed.ends_with(';')
634 && !prev_trimmed.ends_with('>')
635 && !prev_trimmed.ends_with('-')
636 && !prev_trimmed.ends_with('*'));
637
638 if prev_is_blank || !is_sentence_continuation {
639 if !prev_is_blank && !prev_excluded {
640 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
642
643 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
644 warnings.push(LintWarning {
645 line: start_line,
646 column: start_col,
647 end_line,
648 end_column: end_col,
649 severity: Severity::Warning,
650 rule_name: Some(self.name().to_string()),
651 message: "Ordered list starting with non-1 should be preceded by blank line"
652 .to_string(),
653 fix: Some(Fix::new(
654 ctx.line_column_byte_range_with_length(line_num, 1, 0),
655 format!("{bq_prefix}\n"),
656 )),
657 });
658 }
659
660 if line_idx + 1 < num_lines {
663 let next_line = lines[line_idx + 1];
664 let next_is_blank = is_blank_in_context(next_line);
665 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
666
667 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
668 let next_trimmed = next_line.trim_start();
672 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
673 || next_line.starts_with("1. ")
674 || (next_line.len() > next_trimmed.len()
675 && !next_trimmed.starts_with("- ")
676 && !next_trimmed.starts_with("* ")
677 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
680 let (start_line, start_col, end_line, end_col) =
681 calculate_line_range(line_num, line);
682 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
683 warnings.push(LintWarning {
684 line: start_line,
685 column: start_col,
686 end_line,
687 end_column: end_col,
688 severity: Severity::Warning,
689 rule_name: Some(self.name().to_string()),
690 message: "List should be followed by blank line".to_string(),
691 fix: Some(Fix::new(
692 ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
693 format!("{bq_prefix}\n"),
694 )),
695 });
696 }
697 }
698 }
699 }
700 }
701 }
702 }
703
704 for &(start_line, end_line, ref prefix) in list_blocks {
705 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
706 if ctx
708 .line_info(start_line)
709 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
710 {
711 continue;
712 }
713
714 if start_line > 1 {
715 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
717
718 if !has_blank_separation && content_line > 0 {
720 let prev_line_str = lines[content_line - 1];
721 let is_prev_excluded = ctx
722 .line_info(content_line)
723 .is_some_and(|info| info.in_code_block || info.in_front_matter);
724 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
725 let prefixes_match = prev_bq_level == block_bq_level;
726
727 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
730 if !is_prev_excluded && prefixes_match && should_require {
731 let (start_line, start_col, end_line, end_col) =
733 calculate_line_range(start_line, lines[start_line - 1]);
734
735 warnings.push(LintWarning {
736 line: start_line,
737 column: start_col,
738 end_line,
739 end_column: end_col,
740 severity: Severity::Warning,
741 rule_name: Some(self.name().to_string()),
742 message: "List should be preceded by blank line".to_string(),
743 fix: Some(Fix::new(
744 ctx.line_column_byte_range_with_length(start_line, 1, 0),
745 format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
746 )),
747 });
748 }
749 }
750 }
751
752 if end_line < num_lines {
753 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
755
756 if !has_blank_separation && content_line > 0 {
758 let next_line_str = lines[content_line - 1];
759 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
762 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
763
764 let end_line_str = lines[end_line - 1];
769 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
770 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
771
772 let prefixes_match = next_line_bq_level == block_bq_level;
773
774 if !is_next_excluded && prefixes_match && !exits_blockquote {
777 let (start_line_last, start_col_last, end_line_last, end_col_last) =
779 calculate_line_range(end_line, lines[end_line - 1]);
780
781 warnings.push(LintWarning {
782 line: start_line_last,
783 column: start_col_last,
784 end_line: end_line_last,
785 end_column: end_col_last,
786 severity: Severity::Warning,
787 rule_name: Some(self.name().to_string()),
788 message: "List should be followed by blank line".to_string(),
789 fix: Some(Fix::new(
790 ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
791 format!("{}\n", ctx.blockquote_prefix_for_blank_line(end_line - 1)),
792 )),
793 });
794 }
795 }
796 }
797 }
798 warnings
799 }
800}
801
802impl Rule for MD032BlanksAroundLists {
803 fn name(&self) -> &'static str {
804 "MD032"
805 }
806
807 fn description(&self) -> &'static str {
808 "Lists should be surrounded by blank lines"
809 }
810
811 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
812 let lines = ctx.raw_lines();
813 if lines.is_empty() {
815 return Ok(Vec::new());
816 }
817
818 let list_blocks = self.convert_list_blocks(ctx);
819
820 if list_blocks.is_empty() {
821 return Ok(Vec::new());
822 }
823
824 let mut warnings = self.perform_checks(ctx, lines, &list_blocks);
825
826 if !self.config.allow_lazy_continuation {
831 let lazy_cont_lines = ctx.lazy_continuation_lines();
832
833 for lazy_info in lazy_cont_lines.iter() {
834 let line_num = lazy_info.line_num;
835
836 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
840 continue;
841 }
842
843 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
845 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
846
847 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
849 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
850 } else {
851 None
852 };
853
854 warnings.push(LintWarning {
855 line: start_line,
856 column: start_col,
857 end_line,
858 end_column: end_col,
859 severity: Severity::Warning,
860 rule_name: Some(self.name().to_string()),
861 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
862 fix,
863 });
864 }
865 }
866
867 Ok(warnings)
868 }
869
870 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
871 Ok(self.fix_with_structure_impl(ctx))
872 }
873
874 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
875 ctx.content.is_empty() || ctx.list_blocks.is_empty()
878 }
879
880 fn category(&self) -> RuleCategory {
881 RuleCategory::List
882 }
883
884 fn as_any(&self) -> &dyn std::any::Any {
885 self
886 }
887
888 crate::impl_rule_config_methods!(MD032Config);
889}
890
891impl MD032BlanksAroundLists {
892 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
894 let lines = ctx.raw_lines();
895 let num_lines = lines.len();
896 if num_lines == 0 {
897 return String::new();
898 }
899
900 let list_blocks = self.convert_list_blocks(ctx);
901 if list_blocks.is_empty() {
902 return ctx.content.to_string();
903 }
904
905 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
908 if !self.config.allow_lazy_continuation {
909 let lazy_cont_lines = ctx.lazy_continuation_lines();
910 for lazy_info in lazy_cont_lines.iter() {
911 let line_num = lazy_info.line_num;
912 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
914 continue;
915 }
916 if !Self::should_apply_lazy_fix(ctx, line_num)
918 || ctx.inline_config().is_rule_disabled(self.name(), line_num)
919 {
920 continue;
921 }
922 lazy_fixes.insert(line_num, lazy_info.clone());
923 }
924 }
925
926 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
927
928 for &(start_line, end_line, ref prefix) in &list_blocks {
930 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
931 if ctx
933 .line_info(start_line)
934 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
935 {
936 continue;
937 }
938
939 if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
941 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
943
944 if !has_blank_separation && content_line > 0 {
946 let prev_line_str = lines[content_line - 1];
947 let is_prev_excluded = ctx
948 .line_info(content_line)
949 .is_some_and(|info| info.in_code_block || info.in_front_matter);
950 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
951
952 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
953 if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
955 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
957 insertions.insert(start_line, bq_prefix);
958 }
959 }
960 }
961
962 if end_line < num_lines && !ctx.inline_config().is_rule_disabled(self.name(), end_line) {
964 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
966
967 if !has_blank_separation && content_line > 0 {
969 let next_line_str = lines[content_line - 1];
970 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
973 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
974
975 let end_line_str = lines[end_line - 1];
977 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
978 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
979
980 if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
983 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
985 insertions.insert(end_line + 1, bq_prefix);
986 }
987 }
988 }
989 }
990
991 if insertions.is_empty() && lazy_fixes.is_empty() {
992 return ctx.content.to_string();
993 }
994
995 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
997 for (i, line) in lines.iter().enumerate() {
998 let current_line_num = i + 1;
999 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1000 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1001 {
1002 result_lines.push(prefix_to_insert.clone());
1003 }
1004
1005 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1007 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1008 result_lines.push(fixed_line);
1009 } else {
1010 result_lines.push(line.to_string());
1011 }
1012 }
1013
1014 let line_ending = crate::utils::detect_line_ending(ctx.content);
1016 let mut result = result_lines.join(line_ending);
1017 if ctx.content.ends_with('\n') {
1018 result.push_str(line_ending);
1019 }
1020 result
1021 }
1022}
1023
1024fn is_blank_in_context(line: &str) -> bool {
1026 parse_blockquote_prefix(line)
1027 .map_or(line, |bq| bq.content)
1028 .trim()
1029 .is_empty()
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034 use super::*;
1035 use crate::lint_context::LintContext;
1036 use crate::rule::Rule;
1037
1038 fn lint(content: &str) -> Vec<LintWarning> {
1039 let rule = MD032BlanksAroundLists::default();
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 rule.check(&ctx).expect("Lint check failed")
1042 }
1043
1044 fn fix(content: &str) -> String {
1045 let rule = MD032BlanksAroundLists::default();
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 rule.fix(&ctx).expect("Lint fix failed")
1048 }
1049
1050 #[test]
1051 fn test_spaced_nested_blockquotes_list_separation() {
1052 for (list_prefix, surrounding_prefix) in [
1053 ("> >", "> >"),
1054 ("> >", "> >"),
1055 ("> > >", "> > >"),
1056 ("> >", ">>"),
1057 (">>", "> >"),
1058 ] {
1059 let content = format!(
1060 "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1061 );
1062 let expected = format!(
1063 "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1064 );
1065 let warnings = lint(&content);
1066 assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1067 assert!(warnings.iter().all(|warning| warning.line == 2));
1068 let mut edited = content.clone();
1069 for warning in warnings.iter().rev() {
1070 let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1071 edited.replace_range(edit.range.clone(), &edit.replacement);
1072 }
1073 assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1074 assert_eq!(fix(&content), expected);
1075 assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1076 assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1077 }
1078 }
1079
1080 #[test]
1081 fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1082 for content in [
1083 "> > - item\n> > ```\n> > code\n> > ```\n",
1084 "> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
1085 "> > - item\n> ~~~\n> code\n> ~~~\n",
1086 "> > - item\n~~~\ncode\n~~~\n",
1087 "> > - item\n>> - next item\n",
1088 ] {
1089 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1090 assert_eq!(fix(content), content);
1091 }
1092 }
1093
1094 #[test]
1095 fn test_fix_separates_list_from_standalone_code_fence() {
1096 for (content, expected) in [
1097 (
1098 "# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
1099 "# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
1100 ),
1101 ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1102 ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1103 (
1104 ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1105 ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1106 ),
1107 ] {
1108 let warnings = lint(content);
1109 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1110 assert_eq!(warnings[0].message, "List should be followed by blank line");
1111 let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1112 let mut edited = content.to_string();
1113 edited.replace_range(edit.range.clone(), &edit.replacement);
1114 assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1115 assert_eq!(fix(content), expected, "{content:?}");
1116 assert!(lint(expected).is_empty(), "{expected:?}");
1117 assert_eq!(fix(expected), expected, "Fix must be idempotent");
1118 }
1119 }
1120
1121 #[test]
1122 fn test_fix_preserves_code_fence_inside_list_item() {
1123 for content in [
1124 "- item\n ```\n code\n ```\n",
1125 "1. item\n ~~~\n code\n ~~~\n",
1126 "> - item\n> ```\n> code\n> ```\n",
1127 ] {
1128 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1129 assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1130 }
1131 }
1132
1133 #[test]
1134 fn test_fix_does_not_split_item_before_different_list_type() {
1135 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1139 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1140
1141 let warnings = lint(content);
1144 assert_eq!(warnings.len(), 2);
1145 assert_eq!(warnings[0].line, 2);
1146 assert_eq!(warnings[1].line, 3);
1147 }
1148
1149 #[test]
1150 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1151 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1152 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1153 }
1154
1155 #[test]
1156 fn test_fix_keeps_lazy_continuation_with_its_item() {
1157 let content = "- alpha beta\nlazy\n1. ordered item\n";
1161 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1162
1163 let warnings = lint(content);
1164 assert_eq!(warnings.len(), 2);
1165 assert_eq!(warnings[0].line, 2);
1166 assert_eq!(warnings[1].line, 3);
1167 }
1168
1169 #[test]
1170 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1171 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1172 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1173 }
1174
1175 #[test]
1176 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1177 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1180 allow_lazy_continuation: false,
1181 });
1182 let content = "- alpha beta\nlazy\n1. ordered item\n";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1185 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1186 }
1187
1188 #[test]
1189 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1190 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1194 allow_lazy_continuation: false,
1195 });
1196 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1197 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1198 let warnings = rule.check(&ctx).expect("Lint check failed");
1199 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1200 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1201 assert_eq!(fixed, content);
1202 }
1203
1204 #[test]
1205 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1206 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1210 allow_lazy_continuation: false,
1211 });
1212 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1213 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1214 let warnings = rule.check(&ctx).expect("Lint check failed");
1215 assert_eq!(
1216 warnings.len(),
1217 1,
1218 "Expected one lazy-continuation warning, got: {warnings:?}"
1219 );
1220 assert_eq!(warnings[0].line, 3);
1221 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1222 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1223 }
1224
1225 #[test]
1226 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1227 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1230 allow_lazy_continuation: false,
1231 });
1232 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1235 assert_eq!(fixed, "Intro\n\n- List item 1\n- List item 2\n :::\n");
1236 }
1237
1238 fn check_warnings_have_fixes(content: &str) {
1240 let warnings = lint(content);
1241 for warning in &warnings {
1242 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1243 }
1244 }
1245
1246 #[test]
1247 fn test_list_at_start() {
1248 let content = "- Item 1\n- Item 2\nText";
1251 let warnings = lint(content);
1252 assert_eq!(
1253 warnings.len(),
1254 0,
1255 "Trailing text is lazy continuation per CommonMark - no warning expected"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_list_at_end() {
1261 let content = "Text\n- Item 1\n- Item 2";
1262 let warnings = lint(content);
1263 assert_eq!(
1264 warnings.len(),
1265 1,
1266 "Expected 1 warning for list at end without preceding blank line"
1267 );
1268 assert_eq!(
1269 warnings[0].line, 2,
1270 "Warning should be on the first line of the list (line 2)"
1271 );
1272 assert!(warnings[0].message.contains("preceded by blank line"));
1273
1274 check_warnings_have_fixes(content);
1276
1277 let fixed_content = fix(content);
1278 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1279
1280 let warnings_after_fix = lint(&fixed_content);
1282 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1283 }
1284
1285 #[test]
1286 fn test_list_in_middle() {
1287 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1290 let warnings = lint(content);
1291 assert_eq!(
1292 warnings.len(),
1293 1,
1294 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1295 );
1296 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1297 assert!(warnings[0].message.contains("preceded by blank line"));
1298
1299 check_warnings_have_fixes(content);
1301
1302 let fixed_content = fix(content);
1303 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1304
1305 let warnings_after_fix = lint(&fixed_content);
1307 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1308 }
1309
1310 #[test]
1311 fn test_correct_spacing() {
1312 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1313 let warnings = lint(content);
1314 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1315
1316 let fixed_content = fix(content);
1317 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1318 }
1319
1320 #[test]
1321 fn test_list_with_content() {
1322 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1325 let warnings = lint(content);
1326 assert_eq!(
1327 warnings.len(),
1328 1,
1329 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1330 );
1331 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1332 assert!(warnings[0].message.contains("preceded by blank line"));
1333
1334 check_warnings_have_fixes(content);
1336
1337 let fixed_content = fix(content);
1338 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1339 assert_eq!(
1340 fixed_content, expected_fixed,
1341 "Fix did not produce the expected output. Got:\n{fixed_content}"
1342 );
1343
1344 let warnings_after_fix = lint(&fixed_content);
1346 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1347 }
1348
1349 #[test]
1350 fn test_nested_list() {
1351 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1353 let warnings = lint(content);
1354 assert_eq!(
1355 warnings.len(),
1356 1,
1357 "Nested list block needs preceding blank only. Got: {warnings:?}"
1358 );
1359 assert_eq!(warnings[0].line, 2);
1360 assert!(warnings[0].message.contains("preceded by blank line"));
1361
1362 check_warnings_have_fixes(content);
1364
1365 let fixed_content = fix(content);
1366 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1367
1368 let warnings_after_fix = lint(&fixed_content);
1370 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1371 }
1372
1373 #[test]
1374 fn test_list_with_internal_blanks() {
1375 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1377 let warnings = lint(content);
1378 assert_eq!(
1379 warnings.len(),
1380 1,
1381 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1382 );
1383 assert_eq!(warnings[0].line, 2);
1384 assert!(warnings[0].message.contains("preceded by blank line"));
1385
1386 check_warnings_have_fixes(content);
1388
1389 let fixed_content = fix(content);
1390 assert_eq!(
1391 fixed_content,
1392 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1393 );
1394
1395 let warnings_after_fix = lint(&fixed_content);
1397 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1398 }
1399
1400 #[test]
1401 fn test_ignore_code_blocks() {
1402 let content = "```\n- Not a list item\n```\nText";
1403 let warnings = lint(content);
1404 assert_eq!(warnings.len(), 0);
1405 let fixed_content = fix(content);
1406 assert_eq!(fixed_content, content);
1407 }
1408
1409 #[test]
1410 fn test_ignore_front_matter() {
1411 let content = "---\ntitle: Test\n---\n- List Item\nText";
1413 let warnings = lint(content);
1414 assert_eq!(
1415 warnings.len(),
1416 0,
1417 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1418 );
1419
1420 let fixed_content = fix(content);
1422 assert_eq!(fixed_content, content, "No changes when no warnings");
1423 }
1424
1425 #[test]
1426 fn test_multiple_lists() {
1427 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1432 let warnings = lint(content);
1433 assert!(
1435 !warnings.is_empty(),
1436 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1437 );
1438
1439 check_warnings_have_fixes(content);
1441
1442 let fixed_content = fix(content);
1443 let warnings_after_fix = lint(&fixed_content);
1445 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1446 }
1447
1448 #[test]
1449 fn test_adjacent_lists() {
1450 let content = "- List 1\n\n* List 2";
1451 let warnings = lint(content);
1452 assert_eq!(warnings.len(), 0);
1453 let fixed_content = fix(content);
1454 assert_eq!(fixed_content, content);
1455 }
1456
1457 #[test]
1458 fn test_list_in_blockquote() {
1459 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1461 let warnings = lint(content);
1462 assert_eq!(
1463 warnings.len(),
1464 1,
1465 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1466 );
1467 assert_eq!(warnings[0].line, 2);
1468
1469 check_warnings_have_fixes(content);
1471
1472 let fixed_content = fix(content);
1473 assert_eq!(
1475 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1476 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1477 );
1478
1479 let warnings_after_fix = lint(&fixed_content);
1481 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1482 }
1483
1484 #[test]
1485 fn test_ordered_list() {
1486 let content = "Text\n1. Item 1\n2. Item 2\nText";
1488 let warnings = lint(content);
1489 assert_eq!(warnings.len(), 1);
1490
1491 check_warnings_have_fixes(content);
1493
1494 let fixed_content = fix(content);
1495 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1496
1497 let warnings_after_fix = lint(&fixed_content);
1499 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1500 }
1501
1502 #[test]
1503 fn test_no_double_blank_fix() {
1504 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1507 assert_eq!(
1508 warnings.len(),
1509 0,
1510 "Should have no warnings - properly preceded, trailing is lazy"
1511 );
1512
1513 let fixed_content = fix(content);
1514 assert_eq!(
1515 fixed_content, content,
1516 "No fix needed when no warnings. Got:\n{fixed_content}"
1517 );
1518
1519 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1521 assert_eq!(warnings2.len(), 1);
1522 if !warnings2.is_empty() {
1523 assert_eq!(
1524 warnings2[0].line, 2,
1525 "Warning line for missing blank before should be the first line of the block"
1526 );
1527 }
1528
1529 check_warnings_have_fixes(content2);
1531
1532 let fixed_content2 = fix(content2);
1533 assert_eq!(
1534 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1535 "Fix added extra blank before. Got:\n{fixed_content2}"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_empty_input() {
1541 let content = "";
1542 let warnings = lint(content);
1543 assert_eq!(warnings.len(), 0);
1544 let fixed_content = fix(content);
1545 assert_eq!(fixed_content, "");
1546 }
1547
1548 #[test]
1549 fn test_only_list() {
1550 let content = "- Item 1\n- Item 2";
1551 let warnings = lint(content);
1552 assert_eq!(warnings.len(), 0);
1553 let fixed_content = fix(content);
1554 assert_eq!(fixed_content, content);
1555 }
1556
1557 #[test]
1560 fn test_fix_complex_nested_blockquote() {
1561 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1563 let warnings = lint(content);
1564 assert_eq!(
1565 warnings.len(),
1566 1,
1567 "Should warn for missing preceding blank only. Got: {warnings:?}"
1568 );
1569
1570 check_warnings_have_fixes(content);
1572
1573 let fixed_content = fix(content);
1574 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1576 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1577
1578 let warnings_after_fix = lint(&fixed_content);
1579 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1580 }
1581
1582 #[test]
1583 fn test_fix_mixed_list_markers() {
1584 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1587 let warnings = lint(content);
1588 assert!(
1590 !warnings.is_empty(),
1591 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1592 );
1593
1594 check_warnings_have_fixes(content);
1596
1597 let fixed_content = fix(content);
1598 assert!(
1600 fixed_content.contains("Text\n\n-"),
1601 "Fix should add blank line before first list item"
1602 );
1603
1604 let warnings_after_fix = lint(&fixed_content);
1606 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1607 }
1608
1609 #[test]
1610 fn test_fix_ordered_list_with_different_numbers() {
1611 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1613 let warnings = lint(content);
1614 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1615
1616 check_warnings_have_fixes(content);
1618
1619 let fixed_content = fix(content);
1620 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1621 assert_eq!(
1622 fixed_content, expected,
1623 "Fix should handle ordered lists with non-sequential numbers"
1624 );
1625
1626 let warnings_after_fix = lint(&fixed_content);
1628 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1629 }
1630
1631 #[test]
1632 fn test_fix_list_with_code_blocks_inside() {
1633 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1635 let warnings = lint(content);
1636 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1637
1638 check_warnings_have_fixes(content);
1640
1641 let fixed_content = fix(content);
1642 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1643 assert_eq!(
1644 fixed_content, expected,
1645 "Fix should handle lists with internal code blocks"
1646 );
1647
1648 let warnings_after_fix = lint(&fixed_content);
1650 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1651 }
1652
1653 #[test]
1654 fn test_fix_deeply_nested_lists() {
1655 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1657 let warnings = lint(content);
1658 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1659
1660 check_warnings_have_fixes(content);
1662
1663 let fixed_content = fix(content);
1664 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1665 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1666
1667 let warnings_after_fix = lint(&fixed_content);
1669 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1670 }
1671
1672 #[test]
1673 fn test_fix_list_with_multiline_items() {
1674 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1677 let warnings = lint(content);
1678 assert_eq!(
1679 warnings.len(),
1680 1,
1681 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1682 );
1683
1684 check_warnings_have_fixes(content);
1686
1687 let fixed_content = fix(content);
1688 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1689 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1690
1691 let warnings_after_fix = lint(&fixed_content);
1693 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1694 }
1695
1696 #[test]
1697 fn test_fix_list_at_document_boundaries() {
1698 let content1 = "- Item 1\n- Item 2";
1700 let warnings1 = lint(content1);
1701 assert_eq!(
1702 warnings1.len(),
1703 0,
1704 "List at document start should not need blank before"
1705 );
1706 let fixed1 = fix(content1);
1707 assert_eq!(fixed1, content1, "No fix needed for list at start");
1708
1709 let content2 = "Text\n- Item 1\n- Item 2";
1711 let warnings2 = lint(content2);
1712 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1713 check_warnings_have_fixes(content2);
1714 let fixed2 = fix(content2);
1715 assert_eq!(
1716 fixed2, "Text\n\n- Item 1\n- Item 2",
1717 "Should add blank before list at end"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_fix_preserves_existing_blank_lines() {
1723 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1724 let warnings = lint(content);
1725 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1726 let fixed_content = fix(content);
1727 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1728 }
1729
1730 #[test]
1731 fn test_fix_handles_tabs_and_spaces() {
1732 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1735 let warnings = lint(content);
1736 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1738
1739 check_warnings_have_fixes(content);
1741
1742 let fixed_content = fix(content);
1743 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1746 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1747
1748 let warnings_after_fix = lint(&fixed_content);
1750 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1751 }
1752
1753 #[test]
1754 fn test_fix_warning_objects_have_correct_ranges() {
1755 let content = "Text\n- Item 1\n- Item 2\nText";
1757 let warnings = lint(content);
1758 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1759
1760 for warning in &warnings {
1762 assert!(warning.fix.is_some(), "Warning should have fix");
1763 let fix = warning.fix.as_ref().unwrap();
1764 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1765 assert!(
1766 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1767 "Fix should have replacement or be insertion"
1768 );
1769 }
1770 }
1771
1772 #[test]
1773 fn test_fix_idempotent() {
1774 let content = "Text\n- Item 1\n- Item 2\nText";
1776
1777 let fixed_once = fix(content);
1779 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1780
1781 let fixed_twice = fix(&fixed_once);
1783 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1784
1785 let warnings_after_fix = lint(&fixed_once);
1787 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1788 }
1789
1790 #[test]
1791 fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
1792 let rule = MD032BlanksAroundLists::default();
1793 for (content, expected) in [
1794 ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1795 (
1796 "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1797 "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1798 ),
1799 ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1800 ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
1801 ] {
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 let warnings = rule.check(&ctx).unwrap();
1804 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1805 assert_eq!(edited, expected);
1806 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1807 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1808 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1809 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1810 }
1811 }
1812
1813 #[test]
1814 fn test_fix_respects_inline_config_at_each_list_boundary() {
1815 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1816
1817 let rule = MD032BlanksAroundLists::default();
1818 for (content, expected, warning_lines) in [
1821 (
1822 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\n<!-- comment -->\n# Heading\n",
1823 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\n<!-- comment -->\n\n# Heading\n",
1824 vec![4],
1825 ),
1826 (
1827 "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1828 "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1829 vec![2],
1830 ),
1831 (
1832 "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
1833 "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
1834 vec![2, 8],
1835 ),
1836 (
1837 "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
1838 "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
1839 vec![2, 8],
1840 ),
1841 (
1842 "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
1843 "Text\n<!-- rumdl-disable MD013 -->\n\n- item\n\n# Heading\n",
1844 vec![3, 3],
1845 ),
1846 ] {
1847 for ending in ["\n", "\r\n"] {
1848 for final_newline in [true, false] {
1849 let content = if final_newline {
1850 content
1851 } else {
1852 content.trim_end_matches('\n')
1853 };
1854 let expected = if final_newline {
1855 expected
1856 } else {
1857 expected.trim_end_matches('\n')
1858 };
1859 let content = content.replace('\n', ending);
1860 let expected = expected.replace('\n', ending);
1861 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1862 let warnings =
1863 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
1864 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
1865 assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
1866 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1867 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1868 assert!(
1869 filter_warnings_by_inline_config(
1870 rule.check(&fixed_ctx).unwrap(),
1871 fixed_ctx.inline_config(),
1872 rule.name()
1873 )
1874 .is_empty()
1875 );
1876 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1877 }
1878 }
1879 }
1880 }
1881
1882 #[test]
1883 fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
1884 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1885
1886 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1887 allow_lazy_continuation: false,
1888 });
1889 let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
1890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1891 let raw = rule.check(&ctx).unwrap();
1892 assert_eq!(raw.len(), 1);
1893 assert!(raw[0].fix.is_some());
1894 let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
1895 assert!(warnings.is_empty());
1896 assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
1897 assert_eq!(rule.fix(&ctx).unwrap(), content);
1898 }
1899
1900 #[test]
1901 fn test_fix_with_normalized_line_endings() {
1902 let content = "Text\n- Item 1\n- Item 2\nText";
1906 let warnings = lint(content);
1907 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1908
1909 check_warnings_have_fixes(content);
1911
1912 let fixed_content = fix(content);
1913 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1915 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1916 }
1917
1918 #[test]
1919 fn test_fix_preserves_final_newline() {
1920 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1923 let fixed_with_newline = fix(content_with_newline);
1924 assert!(
1925 fixed_with_newline.ends_with('\n'),
1926 "Fix should preserve final newline when present"
1927 );
1928 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1930
1931 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1933 let fixed_without_newline = fix(content_without_newline);
1934 assert!(
1935 !fixed_without_newline.ends_with('\n'),
1936 "Fix should not add final newline when not present"
1937 );
1938 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1940 }
1941
1942 #[test]
1943 fn test_fix_multiline_list_items_no_indent() {
1944 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";
1945
1946 let warnings = lint(content);
1947 assert_eq!(
1949 warnings.len(),
1950 0,
1951 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1952 );
1953
1954 let fixed_content = fix(content);
1955 assert_eq!(
1957 fixed_content, content,
1958 "Should not modify correctly formatted multi-line list items"
1959 );
1960 }
1961
1962 #[test]
1963 fn test_nested_list_with_lazy_continuation() {
1964 let content = r#"# Test
1970
1971- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1972 1. Switch/case dispatcher statements (original Phase 3.2)
1973 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1974`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1975 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1976 references"#;
1977
1978 let warnings = lint(content);
1979 let md032_warnings: Vec<_> = warnings
1982 .iter()
1983 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1984 .collect();
1985 assert_eq!(
1986 md032_warnings.len(),
1987 0,
1988 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_pipes_in_code_spans_not_detected_as_table() {
1994 let content = r#"# Test
1996
1997- Item with `a | b` inline code
1998 - Nested item should work
1999
2000"#;
2001
2002 let warnings = lint(content);
2003 let md032_warnings: Vec<_> = warnings
2004 .iter()
2005 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2006 .collect();
2007 assert_eq!(
2008 md032_warnings.len(),
2009 0,
2010 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn test_multiple_code_spans_with_pipes() {
2016 let content = r#"# Test
2018
2019- Item with `a | b` and `c || d` operators
2020 - Nested item should work
2021
2022"#;
2023
2024 let warnings = lint(content);
2025 let md032_warnings: Vec<_> = warnings
2026 .iter()
2027 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2028 .collect();
2029 assert_eq!(
2030 md032_warnings.len(),
2031 0,
2032 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2033 );
2034 }
2035
2036 #[test]
2037 fn test_actual_table_breaks_list() {
2038 let content = r#"# Test
2040
2041- Item before table
2042
2043| Col1 | Col2 |
2044|------|------|
2045| A | B |
2046
2047- Item after table
2048
2049"#;
2050
2051 let warnings = lint(content);
2052 let md032_warnings: Vec<_> = warnings
2054 .iter()
2055 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2056 .collect();
2057 assert_eq!(
2058 md032_warnings.len(),
2059 0,
2060 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_thematic_break_not_lazy_continuation() {
2066 let content = r#"- Item 1
2069- Item 2
2070***
2071
2072More text.
2073"#;
2074
2075 let warnings = lint(content);
2076 let md032_warnings: Vec<_> = warnings
2077 .iter()
2078 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2079 .collect();
2080 assert_eq!(
2081 md032_warnings.len(),
2082 1,
2083 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2084 );
2085 assert!(
2086 md032_warnings[0].message.contains("followed by blank line"),
2087 "Warning should be about missing blank after list"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_thematic_break_with_blank_line() {
2093 let content = r#"- Item 1
2095- Item 2
2096
2097***
2098
2099More text.
2100"#;
2101
2102 let warnings = lint(content);
2103 let md032_warnings: Vec<_> = warnings
2104 .iter()
2105 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2106 .collect();
2107 assert_eq!(
2108 md032_warnings.len(),
2109 0,
2110 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2111 );
2112 }
2113
2114 #[test]
2115 fn test_various_thematic_break_styles() {
2116 for hr in ["---", "***", "___"] {
2121 let content = format!(
2122 r#"- Item 1
2123- Item 2
2124{hr}
2125
2126More text.
2127"#
2128 );
2129
2130 let warnings = lint(&content);
2131 let md032_warnings: Vec<_> = warnings
2132 .iter()
2133 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2134 .collect();
2135 assert_eq!(
2136 md032_warnings.len(),
2137 1,
2138 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2139 );
2140 }
2141 }
2142
2143 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2146 let rule = MD032BlanksAroundLists::from_config_struct(config);
2147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2148 rule.check(&ctx).expect("Lint check failed")
2149 }
2150
2151 fn fix_with_config(content: &str, config: MD032Config) -> String {
2152 let rule = MD032BlanksAroundLists::from_config_struct(config);
2153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2154 rule.fix(&ctx).expect("Lint fix failed")
2155 }
2156
2157 #[test]
2158 fn test_lazy_continuation_allowed_by_default() {
2159 let content = "# Heading\n\n1. List\nSome text.";
2161 let warnings = lint(content);
2162 assert_eq!(
2163 warnings.len(),
2164 0,
2165 "Default behavior should allow lazy continuation. Got: {warnings:?}"
2166 );
2167 }
2168
2169 #[test]
2170 fn test_lazy_continuation_disallowed() {
2171 let content = "# Heading\n\n1. List\nSome text.";
2173 let config = MD032Config {
2174 allow_lazy_continuation: false,
2175 };
2176 let warnings = lint_with_config(content, config);
2177 assert_eq!(
2178 warnings.len(),
2179 1,
2180 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2181 );
2182 assert!(
2183 warnings[0].message.contains("Lazy continuation"),
2184 "Warning message should mention lazy continuation"
2185 );
2186 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2187 }
2188
2189 #[test]
2190 fn test_lazy_continuation_fix() {
2191 let content = "# Heading\n\n1. List\nSome text.";
2193 let config = MD032Config {
2194 allow_lazy_continuation: false,
2195 };
2196 let fixed = fix_with_config(content, config.clone());
2197 assert_eq!(
2199 fixed, "# Heading\n\n1. List\n Some text.",
2200 "Fix should add proper indentation to lazy continuation"
2201 );
2202
2203 let warnings_after = lint_with_config(&fixed, config);
2205 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2206 }
2207
2208 #[test]
2209 fn test_lazy_continuation_multiple_lines() {
2210 let content = "- Item 1\nLine 2\nLine 3";
2212 let config = MD032Config {
2213 allow_lazy_continuation: false,
2214 };
2215 let warnings = lint_with_config(content, config.clone());
2216 assert_eq!(
2218 warnings.len(),
2219 2,
2220 "Should warn for each lazy continuation line. Got: {warnings:?}"
2221 );
2222
2223 let fixed = fix_with_config(content, config.clone());
2224 assert_eq!(
2226 fixed, "- Item 1\n Line 2\n Line 3",
2227 "Fix should add proper indentation to lazy continuation lines"
2228 );
2229
2230 let warnings_after = lint_with_config(&fixed, config);
2232 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2233 }
2234
2235 #[test]
2236 fn test_lazy_continuation_with_indented_content() {
2237 let content = "- Item 1\n Indented content\nLazy text";
2239 let config = MD032Config {
2240 allow_lazy_continuation: false,
2241 };
2242 let warnings = lint_with_config(content, config);
2243 assert_eq!(
2244 warnings.len(),
2245 1,
2246 "Should warn for lazy text after indented content. Got: {warnings:?}"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_lazy_continuation_properly_separated() {
2252 let content = "- Item 1\n\nSome text.";
2254 let config = MD032Config {
2255 allow_lazy_continuation: false,
2256 };
2257 let warnings = lint_with_config(content, config);
2258 assert_eq!(
2259 warnings.len(),
2260 0,
2261 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2262 );
2263 }
2264
2265 #[test]
2268 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2269 let content = "1) First item\nLazy continuation";
2271 let config = MD032Config {
2272 allow_lazy_continuation: false,
2273 };
2274 let warnings = lint_with_config(content, config.clone());
2275 assert_eq!(
2276 warnings.len(),
2277 1,
2278 "Should warn for lazy continuation with parenthesis marker"
2279 );
2280
2281 let fixed = fix_with_config(content, config);
2282 assert_eq!(fixed, "1) First item\n Lazy continuation");
2284 }
2285
2286 #[test]
2287 fn test_lazy_continuation_followed_by_another_list() {
2288 let content = "- Item 1\nSome text\n- Item 2";
2294 let config = MD032Config {
2295 allow_lazy_continuation: false,
2296 };
2297 let warnings = lint_with_config(content, config);
2298 assert_eq!(
2300 warnings.len(),
2301 1,
2302 "Should warn about lazy continuation within list. Got: {warnings:?}"
2303 );
2304 assert!(
2305 warnings[0].message.contains("Lazy continuation"),
2306 "Warning should be about lazy continuation"
2307 );
2308 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2309 }
2310
2311 #[test]
2312 fn test_lazy_continuation_multiple_in_document() {
2313 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2318 let config = MD032Config {
2319 allow_lazy_continuation: false,
2320 };
2321 let warnings = lint_with_config(content, config.clone());
2322 assert_eq!(
2324 warnings.len(),
2325 2,
2326 "Should warn for both lazy continuations. Got: {warnings:?}"
2327 );
2328
2329 let fixed = fix_with_config(content, config.clone());
2330 assert!(
2332 fixed.contains(" Lazy 1"),
2333 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2334 );
2335 assert!(
2336 fixed.contains(" Lazy 2"),
2337 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2338 );
2339
2340 let warnings_after = lint_with_config(&fixed, config);
2341 assert_eq!(
2343 warnings_after.len(),
2344 0,
2345 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2346 );
2347 }
2348
2349 #[test]
2350 fn test_lazy_continuation_end_of_document_no_newline() {
2351 let content = "- Item\nNo trailing newline";
2353 let config = MD032Config {
2354 allow_lazy_continuation: false,
2355 };
2356 let warnings = lint_with_config(content, config.clone());
2357 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2358
2359 let fixed = fix_with_config(content, config);
2360 assert_eq!(fixed, "- Item\n No trailing newline");
2362 }
2363
2364 #[test]
2365 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2366 let content = "- Item 1\n---";
2369 let config = MD032Config {
2370 allow_lazy_continuation: false,
2371 };
2372 let warnings = lint_with_config(content, config.clone());
2373 assert_eq!(
2375 warnings.len(),
2376 1,
2377 "List should need blank line before thematic break. Got: {warnings:?}"
2378 );
2379
2380 let fixed = fix_with_config(content, config);
2382 assert_eq!(fixed, "- Item 1\n\n---");
2383 }
2384
2385 #[test]
2386 fn test_lazy_continuation_heading_not_flagged() {
2387 let content = "- Item 1\n# Heading";
2390 let config = MD032Config {
2391 allow_lazy_continuation: false,
2392 };
2393 let warnings = lint_with_config(content, config);
2394 assert!(
2397 warnings.iter().all(|w| !w.message.contains("lazy")),
2398 "Heading should not trigger lazy continuation warning"
2399 );
2400 }
2401
2402 #[test]
2403 fn test_lazy_continuation_mixed_list_types() {
2404 let content = "- Unordered\n1. Ordered\nLazy text";
2406 let config = MD032Config {
2407 allow_lazy_continuation: false,
2408 };
2409 let warnings = lint_with_config(content, config.clone());
2410 assert!(!warnings.is_empty(), "Should warn about structure issues");
2411 }
2412
2413 #[test]
2414 fn test_lazy_continuation_deep_nesting() {
2415 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2417 let config = MD032Config {
2418 allow_lazy_continuation: false,
2419 };
2420 let warnings = lint_with_config(content, config.clone());
2421 assert!(
2422 !warnings.is_empty(),
2423 "Should warn about lazy continuation after nested list"
2424 );
2425
2426 let fixed = fix_with_config(content, config.clone());
2427 let warnings_after = lint_with_config(&fixed, config);
2428 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2429 }
2430
2431 #[test]
2432 fn test_lazy_continuation_with_emphasis_in_text() {
2433 let content = "- Item\n*emphasized* continuation";
2435 let config = MD032Config {
2436 allow_lazy_continuation: false,
2437 };
2438 let warnings = lint_with_config(content, config.clone());
2439 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2440
2441 let fixed = fix_with_config(content, config);
2442 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2444 }
2445
2446 #[test]
2447 fn test_lazy_continuation_with_code_span() {
2448 let content = "- Item\n`code` continuation";
2450 let config = MD032Config {
2451 allow_lazy_continuation: false,
2452 };
2453 let warnings = lint_with_config(content, config.clone());
2454 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2455
2456 let fixed = fix_with_config(content, config);
2457 assert_eq!(fixed, "- Item\n `code` continuation");
2459 }
2460
2461 #[test]
2468 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2469 let content = r#"1. Create a new Chat conversation:
2472 - On the sidebar, select **New Chat**.
2473 - In the box, type `/new`.
2474 A new Chat conversation replaces the previous one.
24751. Under the Chat text box, turn off the toggle."#;
2476 let config = MD032Config {
2477 allow_lazy_continuation: false,
2478 };
2479 let warnings = lint_with_config(content, config);
2480 let lazy_warnings: Vec<_> = warnings
2482 .iter()
2483 .filter(|w| w.message.contains("Lazy continuation"))
2484 .collect();
2485 assert!(
2486 !lazy_warnings.is_empty(),
2487 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2488 );
2489 assert!(
2490 lazy_warnings.iter().any(|w| w.line == 4),
2491 "Should warn on line 4. Got: {lazy_warnings:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2497 let content = r#"- `field`: Is the specific key:
2500 - `password`: Accesses the password.
2501 - `api_key`: Accesses the api_key.
2502 `token`: Specifies which ID token to use.
2503- `version_id`: Is the unique identifier."#;
2504 let config = MD032Config {
2505 allow_lazy_continuation: false,
2506 };
2507 let warnings = lint_with_config(content, config);
2508 let lazy_warnings: Vec<_> = warnings
2510 .iter()
2511 .filter(|w| w.message.contains("Lazy continuation"))
2512 .collect();
2513 assert!(
2514 !lazy_warnings.is_empty(),
2515 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2516 );
2517 assert!(
2518 lazy_warnings.iter().any(|w| w.line == 4),
2519 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2525 let content = r#"- Check out the branch, and test locally.
2527 - If the MR requires significant modifications:
2528 - **Skip local testing** and review instead.
2529 - **Request verification** from the author.
2530 - **Identify the minimal change** needed.
2531 Your testing might result in opportunities.
2532- If you don't understand, _say so_."#;
2533 let config = MD032Config {
2534 allow_lazy_continuation: false,
2535 };
2536 let warnings = lint_with_config(content, config);
2537 let lazy_warnings: Vec<_> = warnings
2539 .iter()
2540 .filter(|w| w.message.contains("Lazy continuation"))
2541 .collect();
2542 assert!(
2543 !lazy_warnings.is_empty(),
2544 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2545 );
2546 assert!(
2547 lazy_warnings.iter().any(|w| w.line == 6),
2548 "Should warn on line 6. Got: {lazy_warnings:?}"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_issue295_ordered_list_nested_bullets_continuation() {
2554 let content = r#"# Test
2557
25581. First item.
2559 - Nested A.
2560 - Nested B.
2561 Continuation at outer level.
25621. Second item."#;
2563 let config = MD032Config {
2564 allow_lazy_continuation: false,
2565 };
2566 let warnings = lint_with_config(content, config);
2567 let lazy_warnings: Vec<_> = warnings
2569 .iter()
2570 .filter(|w| w.message.contains("Lazy continuation"))
2571 .collect();
2572 assert!(
2573 !lazy_warnings.is_empty(),
2574 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2575 );
2576 assert!(
2578 lazy_warnings.iter().any(|w| w.line == 6),
2579 "Should warn on line 6. Got: {lazy_warnings:?}"
2580 );
2581 }
2582
2583 #[test]
2584 fn test_issue295_multiple_lazy_lines_after_nested() {
2585 let content = r#"1. The device client receives a response.
2587 - Those defined by OAuth Framework.
2588 - Those specific to device authorization.
2589 Those error responses are described below.
2590 For more information on each response,
2591 see the documentation.
25921. Next step in the process."#;
2593 let config = MD032Config {
2594 allow_lazy_continuation: false,
2595 };
2596 let warnings = lint_with_config(content, config);
2597 let lazy_warnings: Vec<_> = warnings
2599 .iter()
2600 .filter(|w| w.message.contains("Lazy continuation"))
2601 .collect();
2602 assert!(
2603 lazy_warnings.len() >= 3,
2604 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2605 lazy_warnings.len()
2606 );
2607 }
2608
2609 #[test]
2610 fn test_issue295_properly_indented_not_lazy() {
2611 let content = r#"1. First item.
2613 - Nested A.
2614 - Nested B.
2615
2616 Properly indented continuation.
26171. Second item."#;
2618 let config = MD032Config {
2619 allow_lazy_continuation: false,
2620 };
2621 let warnings = lint_with_config(content, config);
2622 let lazy_warnings: Vec<_> = warnings
2624 .iter()
2625 .filter(|w| w.message.contains("Lazy continuation"))
2626 .collect();
2627 assert_eq!(
2628 lazy_warnings.len(),
2629 0,
2630 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2631 );
2632 }
2633
2634 #[test]
2641 fn test_html_comment_before_list_with_preceding_blank() {
2642 let content = "Some text.\n\n<!-- comment -->\n- List item";
2645 let warnings = lint(content);
2646 assert_eq!(
2647 warnings.len(),
2648 0,
2649 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_html_comment_after_list_with_following_blank() {
2655 let content = "- List item\n<!-- comment -->\n\nSome text.";
2657 let warnings = lint(content);
2658 assert_eq!(
2659 warnings.len(),
2660 0,
2661 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2662 );
2663 }
2664
2665 #[test]
2666 fn test_list_inside_html_comment_ignored() {
2667 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2669 let warnings = lint(content);
2670 assert_eq!(
2671 warnings.len(),
2672 0,
2673 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2674 );
2675 }
2676
2677 #[test]
2678 fn test_multiline_html_comment_before_list() {
2679 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2681 let warnings = lint(content);
2682 assert_eq!(
2683 warnings.len(),
2684 0,
2685 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2686 );
2687 }
2688
2689 #[test]
2690 fn test_no_blank_before_html_comment_still_warns() {
2691 let content = "Some text.\n<!-- comment -->\n- List item";
2693 let warnings = lint(content);
2694 assert_eq!(
2695 warnings.len(),
2696 1,
2697 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2698 );
2699 assert!(
2700 warnings[0].message.contains("preceded by blank line"),
2701 "Should be 'preceded by blank line' warning"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2707 let content = "- List item\n<!-- comment -->\nSome text.";
2710 let warnings = lint(content);
2711 assert_eq!(
2712 warnings.len(),
2713 0,
2714 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_list_followed_by_heading_through_comment_should_warn() {
2720 let content = "- List item\n<!-- comment -->\n# Heading";
2722 let warnings = lint(content);
2723 assert!(
2726 warnings.len() <= 1,
2727 "Should handle heading after comment gracefully. Got: {warnings:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn test_html_comment_between_list_and_text_both_directions() {
2733 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2735 let warnings = lint(content);
2736 assert_eq!(
2737 warnings.len(),
2738 0,
2739 "Should not warn with proper separation through comments. Got: {warnings:?}"
2740 );
2741 }
2742
2743 #[test]
2744 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2745 let content = "Text.\n\n<!-- comment -->\n- Item";
2747 let fixed = fix(content);
2748 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2749 }
2750
2751 #[test]
2752 fn test_html_comment_fix_adds_blank_when_needed() {
2753 let content = "Text.\n<!-- comment -->\n- Item";
2756 let fixed = fix(content);
2757 assert!(
2758 fixed.contains("<!-- comment -->\n\n- Item"),
2759 "Fix should add blank line before list. Got: {fixed}"
2760 );
2761 }
2762
2763 #[test]
2764 fn test_ordered_list_inside_html_comment() {
2765 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2767 let warnings = lint(content);
2768 assert_eq!(
2769 warnings.len(),
2770 0,
2771 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2772 );
2773 }
2774
2775 #[test]
2782 fn test_blockquote_list_exit_no_warning() {
2783 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2785 let warnings = lint(content);
2786 assert_eq!(
2787 warnings.len(),
2788 0,
2789 "Should not warn when exiting blockquote. Got: {warnings:?}"
2790 );
2791 }
2792
2793 #[test]
2794 fn test_nested_blockquote_list_exit() {
2795 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2797 let warnings = lint(content);
2798 assert_eq!(
2799 warnings.len(),
2800 0,
2801 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2802 );
2803 }
2804
2805 #[test]
2806 fn test_blockquote_same_level_no_warning() {
2807 let content = "> - item 1\n> - item 2\n> Text after";
2810 let warnings = lint(content);
2811 assert_eq!(
2812 warnings.len(),
2813 0,
2814 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2815 );
2816 }
2817
2818 #[test]
2819 fn test_blockquote_list_with_special_chars() {
2820 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2822 let warnings = lint(content);
2823 assert_eq!(
2824 warnings.len(),
2825 0,
2826 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2827 );
2828 }
2829
2830 #[test]
2831 fn test_lazy_continuation_whitespace_only_line() {
2832 let content = "- Item\n \nText after whitespace-only line";
2835 let config = MD032Config {
2836 allow_lazy_continuation: false,
2837 };
2838 let warnings = lint_with_config(content, config);
2839 assert_eq!(
2841 warnings.len(),
2842 0,
2843 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2844 );
2845 }
2846
2847 #[test]
2848 fn test_lazy_continuation_blockquote_context() {
2849 let content = "> - Item\n> Lazy in quote";
2851 let config = MD032Config {
2852 allow_lazy_continuation: false,
2853 };
2854 let warnings = lint_with_config(content, config);
2855 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2858 }
2859
2860 #[test]
2861 fn test_lazy_continuation_fix_preserves_content() {
2862 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2864 let config = MD032Config {
2865 allow_lazy_continuation: false,
2866 };
2867 let fixed = fix_with_config(content, config);
2868 assert!(fixed.contains("<>&"), "Should preserve special chars");
2869 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2870 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2872 }
2873
2874 #[test]
2875 fn test_lazy_continuation_fix_idempotent() {
2876 let content = "- Item\nLazy";
2878 let config = MD032Config {
2879 allow_lazy_continuation: false,
2880 };
2881 let fixed_once = fix_with_config(content, config.clone());
2882 let fixed_twice = fix_with_config(&fixed_once, config);
2883 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2884 }
2885
2886 #[test]
2887 fn test_lazy_continuation_config_default_allows() {
2888 let content = "- Item\nLazy text that continues";
2890 let default_config = MD032Config::default();
2891 assert!(
2892 default_config.allow_lazy_continuation,
2893 "Default should allow lazy continuation"
2894 );
2895 let warnings = lint_with_config(content, default_config);
2896 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2897 }
2898
2899 #[test]
2900 fn test_lazy_continuation_after_multi_line_item() {
2901 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2903 let config = MD032Config {
2904 allow_lazy_continuation: false,
2905 };
2906 let warnings = lint_with_config(content, config.clone());
2907 assert_eq!(
2908 warnings.len(),
2909 1,
2910 "Should warn only for the lazy line, not the indented line"
2911 );
2912 }
2913
2914 #[test]
2916 fn test_blockquote_list_with_continuation_and_nested() {
2917 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2920 let warnings = lint(content);
2921 assert_eq!(
2922 warnings.len(),
2923 0,
2924 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2925 );
2926 }
2927
2928 #[test]
2929 fn test_blockquote_list_simple() {
2930 let content = "> - item 1\n> - item 2";
2932 let warnings = lint(content);
2933 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2934 }
2935
2936 #[test]
2937 fn test_blockquote_list_with_continuation_only() {
2938 let content = "> - item 1\n> continuation\n> - item 2";
2940 let warnings = lint(content);
2941 assert_eq!(
2942 warnings.len(),
2943 0,
2944 "Blockquoted list with continuation should have no warnings"
2945 );
2946 }
2947
2948 #[test]
2949 fn test_blockquote_list_with_lazy_continuation() {
2950 let content = "> - item 1\n> lazy continuation\n> - item 2";
2952 let warnings = lint(content);
2953 assert_eq!(
2954 warnings.len(),
2955 0,
2956 "Blockquoted list with lazy continuation should have no warnings"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_nested_blockquote_list() {
2962 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2964 let warnings = lint(content);
2965 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2966 }
2967
2968 #[test]
2969 fn test_blockquote_list_needs_preceding_blank() {
2970 let content = "> Text before\n> - item 1\n> - item 2";
2972 let warnings = lint(content);
2973 assert_eq!(
2974 warnings.len(),
2975 1,
2976 "Should warn for missing blank before blockquoted list"
2977 );
2978 }
2979
2980 #[test]
2981 fn test_blockquote_list_properly_separated() {
2982 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2984 let warnings = lint(content);
2985 assert_eq!(
2986 warnings.len(),
2987 0,
2988 "Properly separated blockquoted list should have no warnings"
2989 );
2990 }
2991
2992 #[test]
2993 fn test_blockquote_ordered_list() {
2994 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2996 let warnings = lint(content);
2997 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2998 }
2999
3000 #[test]
3001 fn test_blockquote_list_with_empty_blockquote_line() {
3002 let content = "> - item 1\n>\n> - item 2";
3004 let warnings = lint(content);
3005 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3006 }
3007
3008 #[test]
3010 fn test_blockquote_list_multi_paragraph_items() {
3011 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
3014 let warnings = lint(content);
3015 assert_eq!(
3016 warnings.len(),
3017 0,
3018 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3019 );
3020 }
3021
3022 #[test]
3024 fn test_blockquote_ordered_list_multi_paragraph_items() {
3025 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
3026 let warnings = lint(content);
3027 assert_eq!(
3028 warnings.len(),
3029 0,
3030 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3031 );
3032 }
3033
3034 #[test]
3036 fn test_blockquote_list_multiple_continuations() {
3037 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
3038 let warnings = lint(content);
3039 assert_eq!(
3040 warnings.len(),
3041 0,
3042 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3043 );
3044 }
3045
3046 #[test]
3048 fn test_nested_blockquote_multi_paragraph_list() {
3049 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
3050 let warnings = lint(content);
3051 assert_eq!(
3052 warnings.len(),
3053 0,
3054 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3055 );
3056 }
3057
3058 #[test]
3060 fn test_triple_nested_blockquote_multi_paragraph_list() {
3061 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
3062 let warnings = lint(content);
3063 assert_eq!(
3064 warnings.len(),
3065 0,
3066 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3067 );
3068 }
3069
3070 #[test]
3072 fn test_blockquote_list_last_item_continuation() {
3073 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
3074 let warnings = lint(content);
3075 assert_eq!(
3076 warnings.len(),
3077 0,
3078 "Last item with continuation should have no warnings. Got: {warnings:?}"
3079 );
3080 }
3081
3082 #[test]
3084 fn test_blockquote_list_first_item_only_continuation() {
3085 let content = "> - Item 1\n> \n> Continuation of item 1\n";
3086 let warnings = lint(content);
3087 assert_eq!(
3088 warnings.len(),
3089 0,
3090 "Single item with continuation should have no warnings. Got: {warnings:?}"
3091 );
3092 }
3093
3094 #[test]
3098 fn test_blockquote_level_change_breaks_list() {
3099 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3101 let warnings = lint(content);
3102 assert!(
3106 warnings.len() <= 2,
3107 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3108 );
3109 }
3110
3111 #[test]
3113 fn test_exit_blockquote_needs_blank_before_list() {
3114 let content = "> Blockquote text\n\n- List outside blockquote\n";
3116 let warnings = lint(content);
3117 assert_eq!(
3118 warnings.len(),
3119 0,
3120 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3121 );
3122
3123 let content2 = "> Blockquote text\n- List outside blockquote\n";
3127 let warnings2 = lint(content2);
3128 assert!(
3130 warnings2.len() <= 1,
3131 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3132 );
3133 }
3134
3135 #[test]
3137 fn test_blockquote_multi_paragraph_all_unordered_markers() {
3138 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
3140 let warnings = lint(content_dash);
3141 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3142
3143 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
3145 let warnings = lint(content_asterisk);
3146 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3147
3148 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
3150 let warnings = lint(content_plus);
3151 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3152 }
3153
3154 #[test]
3156 fn test_blockquote_multi_paragraph_parenthesis_marker() {
3157 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
3158 let warnings = lint(content);
3159 assert_eq!(
3160 warnings.len(),
3161 0,
3162 "Parenthesis ordered markers should work. Got: {warnings:?}"
3163 );
3164 }
3165
3166 #[test]
3168 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3169 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
3171 let warnings = lint(content);
3172 assert_eq!(
3173 warnings.len(),
3174 0,
3175 "Multi-digit ordered list should work. Got: {warnings:?}"
3176 );
3177 }
3178
3179 #[test]
3181 fn test_blockquote_multi_paragraph_with_formatting() {
3182 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3183 let warnings = lint(content);
3184 assert_eq!(
3185 warnings.len(),
3186 0,
3187 "Continuation with inline formatting should work. Got: {warnings:?}"
3188 );
3189 }
3190
3191 #[test]
3193 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3194 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3195 let warnings = lint(content);
3196 assert_eq!(
3197 warnings.len(),
3198 0,
3199 "All items with continuations should work. Got: {warnings:?}"
3200 );
3201 }
3202
3203 #[test]
3205 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3206 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3207 let warnings = lint(content);
3208 assert_eq!(
3209 warnings.len(),
3210 0,
3211 "Lowercase continuation should work. Got: {warnings:?}"
3212 );
3213 }
3214
3215 #[test]
3217 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3218 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3219 let warnings = lint(content);
3220 assert_eq!(
3221 warnings.len(),
3222 0,
3223 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3224 );
3225 }
3226
3227 #[test]
3229 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3230 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3232 let warnings = lint(content);
3233 assert!(
3235 warnings.len() <= 1,
3236 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3237 );
3238 }
3239
3240 #[test]
3242 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3243 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3245 let warnings = lint(content);
3246 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3247 }
3248
3249 #[test]
3250 fn test_blockquote_list_varying_spaces_after_marker() {
3251 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3253 let warnings = lint(content);
3254 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3255 }
3256
3257 #[test]
3258 fn test_deeply_nested_blockquote_list() {
3259 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3261 let warnings = lint(content);
3262 assert_eq!(
3263 warnings.len(),
3264 0,
3265 "Deeply nested blockquote list should have no warnings"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_blockquote_level_change_in_list() {
3271 let content = "> - item 1\n>> - deeper item\n> - item 2";
3273 let warnings = lint(content);
3276 assert!(
3277 !warnings.is_empty(),
3278 "Blockquote level change should break list and trigger warnings"
3279 );
3280 }
3281
3282 #[test]
3283 fn test_blockquote_list_with_code_span() {
3284 let content = "> - item with `code`\n> continuation\n> - item 2";
3286 let warnings = lint(content);
3287 assert_eq!(
3288 warnings.len(),
3289 0,
3290 "Blockquote list with code span should have no warnings"
3291 );
3292 }
3293
3294 #[test]
3295 fn test_code_span_html_comment_delimiters_no_false_positive() {
3296 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";
3302 let warnings = lint(content);
3303 assert_eq!(
3304 warnings.len(),
3305 0,
3306 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3307 );
3308 }
3309
3310 #[test]
3311 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3312 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";
3317 let fixed = fix(content);
3318 assert_eq!(
3319 fixed, content,
3320 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3321 );
3322 }
3323
3324 #[test]
3325 fn test_blockquote_list_at_document_end() {
3326 let content = "> Some text\n>\n> - item 1\n> - item 2";
3328 let warnings = lint(content);
3329 assert_eq!(
3330 warnings.len(),
3331 0,
3332 "Blockquote list at document end should have no warnings"
3333 );
3334 }
3335
3336 #[test]
3337 fn test_fix_preserves_blockquote_prefix_before_list() {
3338 let content = "> Text before
3340> - Item 1
3341> - Item 2";
3342 let fixed = fix(content);
3343
3344 let expected = "> Text before
3346>
3347> - Item 1
3348> - Item 2";
3349 assert_eq!(
3350 fixed, expected,
3351 "Fix should insert '>' blank line, not plain blank line"
3352 );
3353 }
3354
3355 #[test]
3356 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3357 let content = ">>> Triple nested
3360>>> - Item 1
3361>>> - Item 2
3362>>> More text";
3363 let fixed = fix(content);
3364
3365 let expected = ">>> Triple nested
3367>>>
3368>>> - Item 1
3369>>> - Item 2
3370>>> More text";
3371 assert_eq!(
3372 fixed, expected,
3373 "Fix should preserve triple-nested blockquote prefix '>>>'"
3374 );
3375 }
3376
3377 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3380 let rule = MD032BlanksAroundLists::default();
3381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3382 rule.check(&ctx).unwrap()
3383 }
3384
3385 #[test]
3386 fn test_quarto_list_after_div_open() {
3387 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3389 let warnings = lint_quarto(content);
3390 assert!(
3392 warnings.is_empty(),
3393 "Quarto div marker should be transparent before list: {warnings:?}"
3394 );
3395 }
3396
3397 #[test]
3398 fn test_quarto_list_before_div_close() {
3399 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3401 let warnings = lint_quarto(content);
3402 assert!(
3404 warnings.is_empty(),
3405 "Quarto div marker should be transparent after list: {warnings:?}"
3406 );
3407 }
3408
3409 #[test]
3410 fn test_quarto_list_needs_blank_without_div() {
3411 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3413 let warnings = lint_quarto(content);
3414 assert!(
3417 !warnings.is_empty(),
3418 "Should still require blank when not present: {warnings:?}"
3419 );
3420 }
3421
3422 #[test]
3423 fn test_quarto_list_in_callout_with_content() {
3424 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3426 let warnings = lint_quarto(content);
3427 assert!(
3428 warnings.is_empty(),
3429 "List with proper blanks inside callout should pass: {warnings:?}"
3430 );
3431 }
3432
3433 #[test]
3434 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3435 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3437 let warnings = lint(content); assert!(
3440 !warnings.is_empty(),
3441 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3442 );
3443 }
3444
3445 #[test]
3446 fn test_quarto_nested_divs_with_list() {
3447 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3449 let warnings = lint_quarto(content);
3450 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3451 }
3452
3453 #[test]
3454 fn test_issue512_complex_nested_list_with_continuation() {
3455 let content = "\
3458- First level of indentation.
3459 - Second level of indentation.
3460 - Third level of indentation.
3461 - Third level of indentation.
3462
3463 Second level list continuation.
3464
3465 First level list continuation.
3466- First level of indentation.
3467";
3468 let warnings = lint(content);
3469 assert!(
3470 warnings.is_empty(),
3471 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3472 );
3473 }
3474
3475 #[test]
3476 fn test_issue512_continuation_at_root_level() {
3477 let content = "\
3481- First level.
3482 - Second level.
3483
3484 First level continuation.
3485
3486Root level lazy continuation.
3487- Another first level item.
3488";
3489 let warnings = lint(content);
3490 assert_eq!(
3491 warnings.len(),
3492 1,
3493 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3494 );
3495 assert_eq!(warnings[0].line, 7);
3496 }
3497
3498 #[test]
3499 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3500 let content = "\
3502- Level 1 item.
3503 - Level 2 item.
3504 - Level 3 item.
3505
3506 Level 3 continuation.
3507
3508 Level 2 continuation.
3509
3510 Level 1 continuation (indented under marker).
3511- Another level 1 item.
3512";
3513 let warnings = lint(content);
3514 assert!(
3515 warnings.is_empty(),
3516 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3517 );
3518 }
3519
3520 #[test]
3521 fn test_pandoc_list_after_div_open() {
3522 let rule = MD032BlanksAroundLists::default();
3525 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3527 let warnings = rule.check(&ctx).unwrap();
3528 assert!(
3529 warnings.is_empty(),
3530 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3531 );
3532 }
3533
3534 #[test]
3535 fn test_md032_html_comment() {
3536 let rule = MD032BlanksAroundLists::default();
3537 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3539 let warnings = rule.check(&ctx).unwrap();
3540 assert!(
3541 warnings.is_empty(),
3542 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3543 );
3544 }
3545
3546 #[test]
3547 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3548 let rule = MD032BlanksAroundLists::default();
3554 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";
3555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3556 let warnings = rule.check(&ctx).unwrap();
3557 assert!(
3558 warnings.is_empty(),
3559 "admonition-nested ordered list should not be flagged: {warnings:?}"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3565 let rule = MD032BlanksAroundLists::default();
3569 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";
3570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3571 let warnings = rule.check(&ctx).unwrap();
3572 assert!(
3573 warnings.is_empty(),
3574 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3575 );
3576 }
3577
3578 #[test]
3579 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3580 let rule = MD032BlanksAroundLists::default();
3582 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";
3583 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3584 let warnings = rule.check(&ctx).unwrap();
3585 assert!(
3586 warnings.is_empty(),
3587 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3588 );
3589 }
3590
3591 #[test]
3592 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3593 let rule = MD032BlanksAroundLists::default();
3599 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3601 let warnings = rule.check(&ctx).unwrap();
3602 assert_eq!(
3603 warnings.len(),
3604 1,
3605 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3606 );
3607 }
3608
3609 #[test]
3610 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3611 let rule = MD032BlanksAroundLists::default();
3616 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
3617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3618 let warnings = rule.check(&ctx).unwrap();
3619 assert_eq!(
3620 warnings.len(),
3621 1,
3622 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3623 );
3624 }
3625
3626 #[test]
3627 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3628 let rule = MD032BlanksAroundLists::default();
3633 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";
3634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3635 let warnings = rule.check(&ctx).unwrap();
3636 assert!(
3637 warnings.is_empty(),
3638 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3639 );
3640 }
3641
3642 #[test]
3643 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3644 let rule = MD032BlanksAroundLists::default();
3650 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3652 let warnings = rule.check(&ctx).unwrap();
3653 assert_eq!(
3654 warnings.len(),
3655 1,
3656 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3657 );
3658 assert_eq!(warnings[0].line, 4);
3659 assert!(warnings[0].message.contains("non-1"));
3660 }
3661
3662 #[test]
3663 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3664 let rule = MD032BlanksAroundLists::default();
3670 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";
3671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3672 let warnings = rule.check(&ctx).unwrap();
3673 assert!(
3674 warnings.is_empty(),
3675 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3676 );
3677 }
3678
3679 #[test]
3680 fn test_standard_flavor_admonition_indented_list_unchanged() {
3681 let rule = MD032BlanksAroundLists::default();
3686 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";
3687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3688 let warnings = rule.check(&ctx).unwrap();
3689 assert!(
3690 warnings.is_empty(),
3691 "indented code block under standard flavor should not be flagged: {warnings:?}"
3692 );
3693 }
3694
3695 #[test]
3696 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3697 let rule = MD032BlanksAroundLists::default();
3702 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";
3703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3704 let warnings = rule.check(&ctx).unwrap();
3705 assert_eq!(
3706 warnings.len(),
3707 1,
3708 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3709 );
3710 assert_eq!(warnings[0].line, 6);
3711 }
3712
3713 #[test]
3714 fn test_pseudo_list_marker_after_list() {
3715 let content = indoc::indoc! {"
3716 - Item 1
3717 Item 1 content.
3718
3719 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3720 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3721 are not used except when those semantics are desired.
3722 "};
3723 let warnings = lint(content);
3724 assert!(
3725 warnings.is_empty(),
3726 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3727 );
3728 }
3729
3730 #[test]
3731 fn test_pseudo_list_marker_without_preceding_list() {
3732 let content = indoc::indoc! {"
3733 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3734 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3735 are not used except when those semantics are desired.
3736 "};
3737 let warnings = lint(content);
3738 assert!(
3739 warnings.is_empty(),
3740 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3741 );
3742 }
3743
3744 #[test]
3745 fn test_no_space_hash_continuation_line_stays_in_its_item() {
3746 let content = indoc::indoc! {"
3751 5. **`M.md`** - the deltas (esp. items #1,
3752 #2, #3, #5, #8).
3753
3754 ---
3755
3756 ## Plan
3757
3758 ### Phase 0
3759 - [ ] task one
3760 wrapped
3761 "};
3762 let warnings = lint(content);
3763 assert_eq!(
3764 warnings.len(),
3765 1,
3766 "only the task list is missing a blank line, got: {warnings:?}"
3767 );
3768 assert_eq!(warnings[0].line, 9);
3769 assert_eq!(warnings[0].message, "List should be preceded by blank line");
3770
3771 let expected = indoc::indoc! {"
3772 5. **`M.md`** - the deltas (esp. items #1,
3773 #2, #3, #5, #8).
3774
3775 ---
3776
3777 ## Plan
3778
3779 ### Phase 0
3780
3781 - [ ] task one
3782 wrapped
3783 "};
3784 assert_eq!(fix(content), expected);
3785 }
3786
3787 #[test]
3788 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3789 let content = indoc::indoc! {"
3795 1. first
3796
3797 3. item
3798 continuation
3799
3800 1. nested
3801 2. nested
3802
3803 ## Heading
3804 - task
3805 "};
3806 let warnings = lint(content);
3807 assert_eq!(
3808 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3809 vec![10],
3810 "only the list after the heading is missing a blank line, got: {warnings:?}"
3811 );
3812
3813 let expected = indoc::indoc! {"
3814 1. first
3815
3816 3. item
3817 continuation
3818
3819 1. nested
3820 2. nested
3821
3822 ## Heading
3823
3824 - task
3825 "};
3826 assert_eq!(fix(content), expected);
3827 }
3828
3829 #[test]
3830 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
3831 let content = indoc::indoc! {"
3835 - item (esp. #1,
3836 #2, #3).
3837 - next item
3838
3839 ## Heading
3840 - task
3841 "};
3842 let warnings = lint(content);
3843 assert_eq!(
3844 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3845 vec![6],
3846 "only the list after the heading is missing a blank line, got: {warnings:?}"
3847 );
3848
3849 let expected = indoc::indoc! {"
3850 - item (esp. #1,
3851 #2, #3).
3852 - next item
3853
3854 ## Heading
3855
3856 - task
3857 "};
3858 assert_eq!(fix(content), expected);
3859 }
3860
3861 #[test]
3862 fn test_under_indented_continuation_lines_stay_in_their_item() {
3863 for content in [
3867 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
3868 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
3869 "- Item\n lazy continuation\n- another item\n",
3870 "> - Item\n> lazy continuation\n> - another item\n",
3871 ] {
3872 let warnings = lint(content);
3873 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3874 assert_eq!(fix(content), content, "{content:?}");
3875 }
3876 }
3877
3878 #[test]
3879 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
3880 let config = MD032Config {
3883 allow_lazy_continuation: false,
3884 };
3885 for (content, lazy_lines) in [
3886 ("- Item\n lazy continuation\n- another item\n", vec![2]),
3887 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
3888 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
3889 ] {
3890 let warnings = lint_with_config(content, config.clone());
3891 assert!(
3892 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
3893 "{content:?}: got {warnings:?}"
3894 );
3895 assert_eq!(
3896 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3897 lazy_lines,
3898 "{content:?}: got {warnings:?}"
3899 );
3900 }
3901 }
3902
3903 #[test]
3904 fn test_structural_line_at_short_indent_ends_the_list() {
3905 for (content, expected) in [
3909 ("1. item\n ---\n", "1. item\n\n ---\n"),
3910 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
3911 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3912 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3913 ] {
3914 let warnings = lint(content);
3915 assert_eq!(
3916 warnings
3917 .iter()
3918 .map(|w| (w.line, w.message.as_str()))
3919 .collect::<Vec<_>>(),
3920 vec![(1, "List should be followed by blank line")],
3921 "{content:?}: got {warnings:?}"
3922 );
3923 assert_eq!(fix(content), expected, "{content:?}");
3924 }
3925 }
3926
3927 #[test]
3928 fn test_html_block_at_short_indent_ends_the_list() {
3929 for (content, expected) in [
3936 (
3937 "- item\n<script>\nx\n</script>\n- next\n",
3938 "- item\n\n<script>\nx\n</script>\n\n- next\n",
3939 ),
3940 (
3941 "- item\n <script>\n x\n </script>\n- next\n",
3942 "- item\n\n <script>\n x\n </script>\n\n- next\n",
3943 ),
3944 (
3945 "- item\n <pre>\n x\n </pre>\n- next\n",
3946 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
3947 ),
3948 (
3949 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
3950 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
3951 ),
3952 (
3953 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
3954 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
3955 ),
3956 ] {
3957 let warnings = lint(content);
3958 assert_eq!(
3959 warnings
3960 .iter()
3961 .map(|w| (w.line, w.message.as_str()))
3962 .collect::<Vec<_>>(),
3963 vec![
3964 (1, "List should be followed by blank line"),
3965 (5, "List should be preceded by blank line"),
3966 ],
3967 "{content:?}: got {warnings:?}"
3968 );
3969 assert_eq!(fix(content), expected, "{content:?}");
3970 }
3971 }
3972
3973 #[test]
3974 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
3975 for content in [
3987 "100. item\n <div>\n101. next\n",
3988 "> 100. item\n> <div>\n> 101. next\n",
3989 "100. item\n <div>\ntext\n101. next\n",
3990 "- item\n<div.class>\n- next\n",
3991 "> - item\n> <div.class>\n> - next\n",
3992 "100. item\n\t<div>\n101. next\n",
3993 "- item\n \t<div>\n- next\n",
3994 "> - item\n> \t<div>\n> - next\n",
3995 "> - item\n>\t<div>\n> - next\n",
3996 "> 100. item\n> \t<div>\n> 101. next\n",
3997 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
3998 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
3999 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
4000 ] {
4001 let warnings = lint(content);
4002 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4003 assert_eq!(fix(content), content, "{content:?}");
4004 }
4005
4006 for (content, last_item_line) in [
4010 ("100. item\n <div>\n101. next\n", 1),
4011 ("> 100. item\n> <div>\n> 101. next\n", 1),
4012 ("- item\n <div>\n- next\n", 1),
4013 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4014 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4015 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
4016 ] {
4017 let warnings = lint(content);
4018 assert_eq!(
4019 warnings
4020 .iter()
4021 .map(|w| (w.line, w.message.as_str()))
4022 .collect::<Vec<_>>(),
4023 vec![(last_item_line, "List should be followed by blank line")],
4024 "{content:?}: got {warnings:?}"
4025 );
4026 }
4027 }
4028
4029 #[test]
4030 fn test_tab_indented_nested_list_stays_inside_its_item() {
4031 for content in [
4040 "* item text\n\t1. nested\n\t more\n",
4041 "* item text\n\tcontinuation\n\t1. nested\n",
4042 "1. item text\n\t- nested\n",
4043 "> * item text\n>\t1. nested\n",
4044 "> * item text\n> \t1. nested\n",
4045 "* item text\n\tcontinuation\n\t- nested\n",
4046 ] {
4047 let warnings = lint(content);
4048 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4049 assert_eq!(fix(content), content, "{content:?}");
4050 }
4051
4052 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
4055 let warnings = lint(content);
4056 assert_eq!(
4057 warnings
4058 .iter()
4059 .map(|w| (w.line, w.message.as_str()))
4060 .collect::<Vec<_>>(),
4061 vec![
4062 (1, "List should be followed by blank line"),
4063 (2, "List should be preceded by blank line"),
4064 ],
4065 "{content:?}: got {warnings:?}"
4066 );
4067 }
4068 }
4069
4070 #[test]
4071 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4072 for (content, expected) in [
4077 (
4078 "- item\n<div>\nx\n</div>\n- next\n",
4079 "- item\n\n<div>\nx\n</div>\n- next\n",
4080 ),
4081 (
4082 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4083 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4084 ),
4085 ] {
4086 let warnings = lint(content);
4087 assert_eq!(
4088 warnings
4089 .iter()
4090 .map(|w| (w.line, w.message.as_str()))
4091 .collect::<Vec<_>>(),
4092 vec![(1, "List should be followed by blank line")],
4093 "{content:?}: got {warnings:?}"
4094 );
4095 assert_eq!(fix(content), expected, "{content:?}");
4096 }
4097 }
4098
4099 #[test]
4100 fn test_html_block_at_content_column_is_item_content() {
4101 for content in [
4104 "- item\n <script>\n x\n </script>\n- next\n",
4105 "- item\n <div>\n x\n </div>\n- next\n",
4106 "1. item\n <pre>\n x\n </pre>\n2. next\n",
4107 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4108 ] {
4109 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4110 assert_eq!(fix(content), content, "{content:?}");
4111 }
4112 }
4113}