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};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::pandoc;
6use crate::utils::range_utils::calculate_line_range;
7use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17fn is_thematic_break(line: &str) -> bool {
20 if calculate_indentation_width_default(line) > 3 {
22 return false;
23 }
24
25 let trimmed = line.trim();
26 if trimmed.len() < 3 {
27 return false;
28 }
29
30 let chars: Vec<char> = trimmed.chars().collect();
31 let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33 if let Some(&marker) = first_non_space {
34 if marker != '-' && marker != '*' && marker != '_' {
35 return false;
36 }
37 let marker_count = chars.iter().filter(|&&c| c == marker).count();
38 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39 marker_count >= 3 && other_count == 0
40 } else {
41 false
42 }
43}
44
45#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116 config: MD032Config,
117}
118
119impl MD032BlanksAroundLists {
120 pub fn from_config_struct(config: MD032Config) -> Self {
121 Self { config }
122 }
123}
124
125impl MD032BlanksAroundLists {
126 fn should_require_blank_line_before(
128 ctx: &crate::lint_context::LintContext,
129 prev_line_num: usize,
130 current_line_num: usize,
131 ) -> bool {
132 if ctx
134 .line_info(prev_line_num)
135 .is_some_and(|info| info.in_code_block || info.in_front_matter)
136 {
137 return true;
138 }
139
140 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
142 return false;
143 }
144
145 true
147 }
148
149 fn is_nested_list(
151 ctx: &crate::lint_context::LintContext,
152 prev_line_num: usize, current_line_num: usize, ) -> bool {
155 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
157 let current_line = &ctx.lines[current_line_num - 1];
158 if current_line.indent >= 2 {
159 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
161 let prev_line = &ctx.lines[prev_line_num - 1];
162 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
164 return true;
165 }
166 }
167 }
168 }
169 false
170 }
171
172 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
175 ctx.lines
176 .get(line_num.saturating_sub(1))
177 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
178 }
179
180 fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
186 if !ctx.flavor.is_pandoc_compatible() {
187 return false;
188 }
189 let trimmed = info.content(ctx.content).trim();
190 pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
191 }
192
193 fn is_reportable_lazy_line(
196 ctx: &crate::lint_context::LintContext,
197 list_blocks: &[(usize, usize, String)],
198 line_num: usize,
199 ) -> bool {
200 let is_within_block = list_blocks
201 .iter()
202 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
203 if !is_within_block {
204 return false;
205 }
206 ctx.lines
207 .get(line_num.saturating_sub(1))
208 .is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
209 }
210
211 fn calculate_lazy_continuation_fix(
214 ctx: &crate::lint_context::LintContext,
215 line_num: usize,
216 lazy_info: &LazyContLine,
217 ) -> Option<Fix> {
218 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
219 let line_content = line_info.content(ctx.content);
220
221 if lazy_info.blockquote_level == 0 {
222 let start_byte = line_info.byte_offset;
224 let end_byte = start_byte + lazy_info.current_indent;
225 let replacement = " ".repeat(lazy_info.expected_indent);
226
227 Some(Fix::new(start_byte..end_byte, replacement))
228 } else {
229 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
231 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
232 if prefix_byte_len == 0 {
233 return None;
234 }
235
236 let current_indent = after_bq.len() - after_bq.trim_start().len();
237 let start_byte = line_info.byte_offset + prefix_byte_len;
238 let end_byte = start_byte + current_indent;
239 let replacement = " ".repeat(lazy_info.expected_indent);
240
241 Some(Fix::new(start_byte..end_byte, replacement))
242 }
243 }
244
245 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
248 if lazy_info.blockquote_level == 0 {
249 let content = line.trim_start();
251 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
252 } else {
253 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
255 let prefix_len = line.len().saturating_sub(after_bq.len());
256 if prefix_len == 0 {
257 return line.to_string();
258 }
259
260 let prefix = &line[..prefix_len];
261 let rest = after_bq.trim_start();
262 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
263 }
264 }
265
266 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
274 for line_num in (1..before_line).rev() {
275 let idx = line_num - 1;
276 if let Some(info) = ctx.lines.get(idx) {
277 if info.in_html_comment || info.in_mdx_comment {
279 continue;
280 }
281 if Self::is_transparent_div_marker(ctx, info) {
283 continue;
284 }
285 return (line_num, info.is_blank);
286 }
287 }
288 (0, true)
290 }
291
292 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
299 let num_lines = ctx.lines.len();
300 for line_num in (after_line + 1)..=num_lines {
301 let idx = line_num - 1;
302 if let Some(info) = ctx.lines.get(idx) {
303 if info.in_html_comment || info.in_mdx_comment {
305 continue;
306 }
307 if Self::is_transparent_div_marker(ctx, info) {
309 continue;
310 }
311 return (line_num, info.is_blank);
312 }
313 }
314 (0, true)
316 }
317
318 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
320 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
321
322 for block in &ctx.list_blocks {
323 if ctx
325 .line_info(block.start_line)
326 .is_some_and(|info| info.in_footnote_definition)
327 {
328 continue;
329 }
330
331 let mut segments: Vec<(usize, usize)> = Vec::new();
337 let mut current_start = block.start_line;
338 let mut prev_item_line = 0;
339
340 let get_blockquote_level = |line_num: usize| -> usize {
342 if line_num == 0 || line_num > ctx.lines.len() {
343 return 0;
344 }
345 let line_content = ctx.lines[line_num - 1].content(ctx.content);
346 BLOCKQUOTE_PREFIX_RE
347 .find(line_content)
348 .map_or(0, |m| m.as_str().chars().filter(|&c| c == '>').count())
349 };
350
351 let mut prev_bq_level = 0;
352
353 for &item_line in &block.item_lines {
354 let current_bq_level = get_blockquote_level(item_line);
355
356 if prev_item_line > 0 {
357 let blockquote_level_changed = prev_bq_level != current_bq_level;
359
360 let mut has_standalone_code_fence = false;
363
364 let min_indent_for_content = if block.is_ordered {
366 3 } else {
370 2 };
373
374 for check_line in (prev_item_line + 1)..item_line {
375 if check_line - 1 < ctx.lines.len() {
376 let line = &ctx.lines[check_line - 1];
377 let line_content = line.content(ctx.content);
378 if line.in_code_block
379 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
380 {
381 if line.indent < min_indent_for_content {
384 has_standalone_code_fence = true;
385 break;
386 }
387 }
388 }
389 }
390
391 if has_standalone_code_fence || blockquote_level_changed {
392 segments.push((current_start, prev_item_line));
394 current_start = item_line;
395 }
396 }
397 prev_item_line = item_line;
398 prev_bq_level = current_bq_level;
399 }
400
401 if prev_item_line > 0 {
404 segments.push((current_start, prev_item_line));
405 }
406
407 let has_code_fence_splits = segments.len() > 1 && {
409 let mut found_fence = false;
411 for i in 0..segments.len() - 1 {
412 let seg_end = segments[i].1;
413 let next_start = segments[i + 1].0;
414 for check_line in (seg_end + 1)..next_start {
416 if check_line - 1 < ctx.lines.len() {
417 let line = &ctx.lines[check_line - 1];
418 let line_content = line.content(ctx.content);
419 if line.in_code_block
420 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
421 {
422 found_fence = true;
423 break;
424 }
425 }
426 }
427 if found_fence {
428 break;
429 }
430 }
431 found_fence
432 };
433
434 for (start, end) in &segments {
436 let mut actual_end = *end;
438
439 if !has_code_fence_splits && *end < block.end_line {
442 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
444
445 let min_continuation_indent = if block_bq_level > 0 {
448 if block.is_ordered {
450 block.max_marker_width
451 } else {
452 2 }
454 } else {
455 ctx.lines
456 .get(*end - 1)
457 .and_then(|line_info| line_info.list_item.as_ref())
458 .map_or(2, |item| item.content_column)
459 };
460
461 for check_line in (*end + 1)..=block.end_line {
462 if check_line - 1 < ctx.lines.len() {
463 let line = &ctx.lines[check_line - 1];
464 let line_content = line.content(ctx.content);
465 if block.item_lines.contains(&check_line) || line.is_valid_heading() {
470 break;
471 }
472 if line.in_code_block {
474 break;
475 }
476
477 let effective_indent =
479 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
480
481 if effective_indent >= min_continuation_indent {
483 actual_end = check_line;
484 }
485 else if !line.is_blank
490 && !line.is_valid_heading()
491 && !block.item_lines.contains(&check_line)
492 && !is_thematic_break(line_content)
493 {
494 actual_end = check_line;
496 } else if !line.is_blank {
497 break;
499 }
500 }
501 }
502 }
503
504 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
505 }
506 }
507
508 blocks.retain(|(start, end, _)| {
510 let all_in_comment = (*start..=*end).all(|line_num| {
512 ctx.lines
513 .get(line_num - 1)
514 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
515 });
516 !all_in_comment
517 });
518
519 blocks
520 }
521
522 fn perform_checks(
523 &self,
524 ctx: &crate::lint_context::LintContext,
525 lines: &[&str],
526 list_blocks: &[(usize, usize, String)],
527 ) -> Vec<LintWarning> {
528 let mut warnings = Vec::new();
529 let num_lines = lines.len();
530
531 for (line_idx, line) in lines.iter().enumerate() {
534 let line_num = line_idx + 1;
535
536 let is_in_list = list_blocks
538 .iter()
539 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
540 if is_in_list {
541 continue;
542 }
543
544 if ctx.line_info(line_num).is_some_and(|info| {
546 info.in_code_block
547 || info.in_front_matter
548 || info.in_html_comment
549 || info.in_mdx_comment
550 || info.in_html_block
551 || info.in_jsx_block
552 }) {
553 continue;
554 }
555
556 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
558 if line_idx > 0 {
560 let prev_line = lines[line_idx - 1];
561 let prev_is_blank = is_blank_in_context(prev_line);
562 let prev_line_info = ctx.line_info(line_idx);
563 let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
564
565 let prev_in_mkdocs_container =
581 prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
582 let continues_stale_container_list = prev_in_mkdocs_container && {
583 let item_indent = calculate_indentation_width_default(line);
584 let mut found_marker = false;
585 for j in (0..line_idx).rev() {
586 let in_container = ctx
587 .line_info(j + 1)
588 .is_some_and(|info| info.in_admonition || info.in_content_tab);
589 if !in_container {
590 break;
591 }
592 let candidate = lines[j];
593 if is_blank_in_context(candidate) {
594 continue;
595 }
596 let candidate_indent = calculate_indentation_width_default(candidate);
597 if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
598 && candidate_indent == item_indent
599 {
600 found_marker = true;
601 break;
602 }
603 if candidate_indent <= item_indent {
604 break;
605 }
606 }
607 found_marker
608 };
609
610 let prev_trimmed = prev_line.trim();
615 let is_sentence_continuation = continues_stale_container_list
616 || (!prev_is_blank
617 && !prev_trimmed.is_empty()
618 && !prev_trimmed.ends_with('.')
619 && !prev_trimmed.ends_with('!')
620 && !prev_trimmed.ends_with('?')
621 && !prev_trimmed.ends_with(':')
622 && !prev_trimmed.ends_with(';')
623 && !prev_trimmed.ends_with('>')
624 && !prev_trimmed.ends_with('-')
625 && !prev_trimmed.ends_with('*'));
626
627 if prev_is_blank || !is_sentence_continuation {
628 if !prev_is_blank && !prev_excluded {
629 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
631
632 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
633 warnings.push(LintWarning {
634 line: start_line,
635 column: start_col,
636 end_line,
637 end_column: end_col,
638 severity: Severity::Warning,
639 rule_name: Some(self.name().to_string()),
640 message: "Ordered list starting with non-1 should be preceded by blank line"
641 .to_string(),
642 fix: Some(Fix::new(
643 ctx.line_column_byte_range_with_length(line_num, 1, 0),
644 format!("{bq_prefix}\n"),
645 )),
646 });
647 }
648
649 if line_idx + 1 < num_lines {
652 let next_line = lines[line_idx + 1];
653 let next_is_blank = is_blank_in_context(next_line);
654 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
655
656 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
657 let next_trimmed = next_line.trim_start();
661 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
662 || next_line.starts_with("1. ")
663 || (next_line.len() > next_trimmed.len()
664 && !next_trimmed.starts_with("- ")
665 && !next_trimmed.starts_with("* ")
666 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
669 let (start_line, start_col, end_line, end_col) =
670 calculate_line_range(line_num, line);
671 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
672 warnings.push(LintWarning {
673 line: start_line,
674 column: start_col,
675 end_line,
676 end_column: end_col,
677 severity: Severity::Warning,
678 rule_name: Some(self.name().to_string()),
679 message: "List should be followed by blank line".to_string(),
680 fix: Some(Fix::new(
681 ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
682 format!("{bq_prefix}\n"),
683 )),
684 });
685 }
686 }
687 }
688 }
689 }
690 }
691 }
692
693 for &(start_line, end_line, ref prefix) in list_blocks {
694 if ctx
696 .line_info(start_line)
697 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
698 {
699 continue;
700 }
701
702 if start_line > 1 {
703 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
705
706 if !has_blank_separation && content_line > 0 {
708 let prev_line_str = lines[content_line - 1];
709 let is_prev_excluded = ctx
710 .line_info(content_line)
711 .is_some_and(|info| info.in_code_block || info.in_front_matter);
712 let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
713 let prefixes_match = prev_prefix.trim() == prefix.trim();
714
715 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
718 if !is_prev_excluded && prefixes_match && should_require {
719 let (start_line, start_col, end_line, end_col) =
721 calculate_line_range(start_line, lines[start_line - 1]);
722
723 warnings.push(LintWarning {
724 line: start_line,
725 column: start_col,
726 end_line,
727 end_column: end_col,
728 severity: Severity::Warning,
729 rule_name: Some(self.name().to_string()),
730 message: "List should be preceded by blank line".to_string(),
731 fix: Some(Fix::new(
732 ctx.line_column_byte_range_with_length(start_line, 1, 0),
733 format!("{prefix}\n"),
734 )),
735 });
736 }
737 }
738 }
739
740 if end_line < num_lines {
741 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
743
744 if !has_blank_separation && content_line > 0 {
746 let next_line_str = lines[content_line - 1];
747 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
750 || (content_line <= ctx.lines.len()
751 && ctx.lines[content_line - 1].in_code_block
752 && ctx.lines[content_line - 1].indent >= 2);
753 let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
754
755 let end_line_str = lines[end_line - 1];
760 let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
761 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
762 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
763 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
764
765 let prefixes_match = next_prefix.trim() == prefix.trim();
766
767 if !is_next_excluded && prefixes_match && !exits_blockquote {
770 let (start_line_last, start_col_last, end_line_last, end_col_last) =
772 calculate_line_range(end_line, lines[end_line - 1]);
773
774 warnings.push(LintWarning {
775 line: start_line_last,
776 column: start_col_last,
777 end_line: end_line_last,
778 end_column: end_col_last,
779 severity: Severity::Warning,
780 rule_name: Some(self.name().to_string()),
781 message: "List should be followed by blank line".to_string(),
782 fix: Some(Fix::new(
783 ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
784 format!("{prefix}\n"),
785 )),
786 });
787 }
788 }
789 }
790 }
791 warnings
792 }
793}
794
795impl Rule for MD032BlanksAroundLists {
796 fn name(&self) -> &'static str {
797 "MD032"
798 }
799
800 fn description(&self) -> &'static str {
801 "Lists should be surrounded by blank lines"
802 }
803
804 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
805 let lines = ctx.raw_lines();
806 if lines.is_empty() {
808 return Ok(Vec::new());
809 }
810
811 let list_blocks = self.convert_list_blocks(ctx);
812
813 if list_blocks.is_empty() {
814 return Ok(Vec::new());
815 }
816
817 let mut warnings = self.perform_checks(ctx, lines, &list_blocks);
818
819 if !self.config.allow_lazy_continuation {
824 let lazy_cont_lines = ctx.lazy_continuation_lines();
825
826 for lazy_info in lazy_cont_lines.iter() {
827 let line_num = lazy_info.line_num;
828
829 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
833 continue;
834 }
835
836 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
838 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
839
840 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
842 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
843 } else {
844 None
845 };
846
847 warnings.push(LintWarning {
848 line: start_line,
849 column: start_col,
850 end_line,
851 end_column: end_col,
852 severity: Severity::Warning,
853 rule_name: Some(self.name().to_string()),
854 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
855 fix,
856 });
857 }
858 }
859
860 Ok(warnings)
861 }
862
863 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
864 Ok(self.fix_with_structure_impl(ctx))
865 }
866
867 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
868 ctx.content.is_empty() || ctx.list_blocks.is_empty()
871 }
872
873 fn category(&self) -> RuleCategory {
874 RuleCategory::List
875 }
876
877 fn as_any(&self) -> &dyn std::any::Any {
878 self
879 }
880
881 crate::impl_rule_config_methods!(MD032Config);
882}
883
884impl MD032BlanksAroundLists {
885 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
887 let lines = ctx.raw_lines();
888 let num_lines = lines.len();
889 if num_lines == 0 {
890 return String::new();
891 }
892
893 let list_blocks = self.convert_list_blocks(ctx);
894 if list_blocks.is_empty() {
895 return ctx.content.to_string();
896 }
897
898 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
901 if !self.config.allow_lazy_continuation {
902 let lazy_cont_lines = ctx.lazy_continuation_lines();
903 for lazy_info in lazy_cont_lines.iter() {
904 let line_num = lazy_info.line_num;
905 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
907 continue;
908 }
909 if !Self::should_apply_lazy_fix(ctx, line_num) {
911 continue;
912 }
913 lazy_fixes.insert(line_num, lazy_info.clone());
914 }
915 }
916
917 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
918
919 for &(start_line, end_line, ref prefix) in &list_blocks {
921 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
923 continue;
924 }
925
926 if ctx
928 .line_info(start_line)
929 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
930 {
931 continue;
932 }
933
934 if start_line > 1 {
936 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
938
939 if !has_blank_separation && content_line > 0 {
941 let prev_line_str = lines[content_line - 1];
942 let is_prev_excluded = ctx
943 .line_info(content_line)
944 .is_some_and(|info| info.in_code_block || info.in_front_matter);
945 let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
946
947 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
948 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
950 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
952 insertions.insert(start_line, bq_prefix);
953 }
954 }
955 }
956
957 if end_line < num_lines {
959 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
961
962 if !has_blank_separation && content_line > 0 {
964 let next_line_str = lines[content_line - 1];
965 let is_next_excluded = ctx
967 .line_info(content_line)
968 .is_some_and(|info| info.in_code_block || info.in_front_matter)
969 || (content_line <= ctx.lines.len()
970 && ctx.lines[content_line - 1].in_code_block
971 && ctx.lines[content_line - 1].indent >= 2
972 && (ctx.lines[content_line - 1]
973 .content(ctx.content)
974 .trim()
975 .starts_with("```")
976 || ctx.lines[content_line - 1]
977 .content(ctx.content)
978 .trim()
979 .starts_with("~~~")));
980 let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
981
982 let end_line_str = lines[end_line - 1];
984 let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
985 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
986 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
987 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
988
989 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
992 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
994 insertions.insert(end_line + 1, bq_prefix);
995 }
996 }
997 }
998 }
999
1000 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1002 for (i, line) in lines.iter().enumerate() {
1003 let current_line_num = i + 1;
1004 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1005 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1006 {
1007 result_lines.push(prefix_to_insert.clone());
1008 }
1009
1010 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
1012 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
1013 {
1014 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1015 result_lines.push(fixed_line);
1016 } else {
1017 result_lines.push(line.to_string());
1018 }
1019 }
1020
1021 let mut result = result_lines.join("\n");
1023 if ctx.content.ends_with('\n') {
1024 result.push('\n');
1025 }
1026 result
1027 }
1028}
1029
1030fn is_blank_in_context(line: &str) -> bool {
1032 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1035 line[m.end()..].trim().is_empty()
1037 } else {
1038 line.trim().is_empty()
1040 }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046 use crate::lint_context::LintContext;
1047 use crate::rule::Rule;
1048
1049 fn lint(content: &str) -> Vec<LintWarning> {
1050 let rule = MD032BlanksAroundLists::default();
1051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 rule.check(&ctx).expect("Lint check failed")
1053 }
1054
1055 fn fix(content: &str) -> String {
1056 let rule = MD032BlanksAroundLists::default();
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 rule.fix(&ctx).expect("Lint fix failed")
1059 }
1060
1061 #[test]
1062 fn test_fix_does_not_split_item_before_different_list_type() {
1063 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1067 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1068
1069 let warnings = lint(content);
1072 assert_eq!(warnings.len(), 2);
1073 assert_eq!(warnings[0].line, 2);
1074 assert_eq!(warnings[1].line, 3);
1075 }
1076
1077 #[test]
1078 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1079 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1080 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1081 }
1082
1083 #[test]
1084 fn test_fix_keeps_lazy_continuation_with_its_item() {
1085 let content = "- alpha beta\nlazy\n1. ordered item\n";
1089 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1090
1091 let warnings = lint(content);
1092 assert_eq!(warnings.len(), 2);
1093 assert_eq!(warnings[0].line, 2);
1094 assert_eq!(warnings[1].line, 3);
1095 }
1096
1097 #[test]
1098 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1099 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1100 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1101 }
1102
1103 #[test]
1104 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1105 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1108 allow_lazy_continuation: false,
1109 });
1110 let content = "- alpha beta\nlazy\n1. ordered item\n";
1111 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1112 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1113 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1114 }
1115
1116 #[test]
1117 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1118 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1122 allow_lazy_continuation: false,
1123 });
1124 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1125 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1126 let warnings = rule.check(&ctx).expect("Lint check failed");
1127 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1128 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1129 assert_eq!(fixed, content);
1130 }
1131
1132 #[test]
1133 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1134 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1138 allow_lazy_continuation: false,
1139 });
1140 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1141 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1142 let warnings = rule.check(&ctx).expect("Lint check failed");
1143 assert_eq!(
1144 warnings.len(),
1145 1,
1146 "Expected one lazy-continuation warning, got: {warnings:?}"
1147 );
1148 assert_eq!(warnings[0].line, 3);
1149 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1150 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1151 }
1152
1153 #[test]
1154 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1155 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1158 allow_lazy_continuation: false,
1159 });
1160 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1163 assert_eq!(fixed, "Intro\n\n- List item 1\n- List item 2\n :::\n");
1164 }
1165
1166 fn check_warnings_have_fixes(content: &str) {
1168 let warnings = lint(content);
1169 for warning in &warnings {
1170 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1171 }
1172 }
1173
1174 #[test]
1175 fn test_list_at_start() {
1176 let content = "- Item 1\n- Item 2\nText";
1179 let warnings = lint(content);
1180 assert_eq!(
1181 warnings.len(),
1182 0,
1183 "Trailing text is lazy continuation per CommonMark - no warning expected"
1184 );
1185 }
1186
1187 #[test]
1188 fn test_list_at_end() {
1189 let content = "Text\n- Item 1\n- Item 2";
1190 let warnings = lint(content);
1191 assert_eq!(
1192 warnings.len(),
1193 1,
1194 "Expected 1 warning for list at end without preceding blank line"
1195 );
1196 assert_eq!(
1197 warnings[0].line, 2,
1198 "Warning should be on the first line of the list (line 2)"
1199 );
1200 assert!(warnings[0].message.contains("preceded by blank line"));
1201
1202 check_warnings_have_fixes(content);
1204
1205 let fixed_content = fix(content);
1206 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1207
1208 let warnings_after_fix = lint(&fixed_content);
1210 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1211 }
1212
1213 #[test]
1214 fn test_list_in_middle() {
1215 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1218 let warnings = lint(content);
1219 assert_eq!(
1220 warnings.len(),
1221 1,
1222 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1223 );
1224 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1225 assert!(warnings[0].message.contains("preceded by blank line"));
1226
1227 check_warnings_have_fixes(content);
1229
1230 let fixed_content = fix(content);
1231 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1232
1233 let warnings_after_fix = lint(&fixed_content);
1235 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1236 }
1237
1238 #[test]
1239 fn test_correct_spacing() {
1240 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1241 let warnings = lint(content);
1242 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1243
1244 let fixed_content = fix(content);
1245 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1246 }
1247
1248 #[test]
1249 fn test_list_with_content() {
1250 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1253 let warnings = lint(content);
1254 assert_eq!(
1255 warnings.len(),
1256 1,
1257 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1258 );
1259 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1260 assert!(warnings[0].message.contains("preceded by blank line"));
1261
1262 check_warnings_have_fixes(content);
1264
1265 let fixed_content = fix(content);
1266 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1267 assert_eq!(
1268 fixed_content, expected_fixed,
1269 "Fix did not produce the expected output. Got:\n{fixed_content}"
1270 );
1271
1272 let warnings_after_fix = lint(&fixed_content);
1274 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1275 }
1276
1277 #[test]
1278 fn test_nested_list() {
1279 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1281 let warnings = lint(content);
1282 assert_eq!(
1283 warnings.len(),
1284 1,
1285 "Nested list block needs preceding blank only. Got: {warnings:?}"
1286 );
1287 assert_eq!(warnings[0].line, 2);
1288 assert!(warnings[0].message.contains("preceded by blank line"));
1289
1290 check_warnings_have_fixes(content);
1292
1293 let fixed_content = fix(content);
1294 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1295
1296 let warnings_after_fix = lint(&fixed_content);
1298 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1299 }
1300
1301 #[test]
1302 fn test_list_with_internal_blanks() {
1303 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1305 let warnings = lint(content);
1306 assert_eq!(
1307 warnings.len(),
1308 1,
1309 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1310 );
1311 assert_eq!(warnings[0].line, 2);
1312 assert!(warnings[0].message.contains("preceded by blank line"));
1313
1314 check_warnings_have_fixes(content);
1316
1317 let fixed_content = fix(content);
1318 assert_eq!(
1319 fixed_content,
1320 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1321 );
1322
1323 let warnings_after_fix = lint(&fixed_content);
1325 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1326 }
1327
1328 #[test]
1329 fn test_ignore_code_blocks() {
1330 let content = "```\n- Not a list item\n```\nText";
1331 let warnings = lint(content);
1332 assert_eq!(warnings.len(), 0);
1333 let fixed_content = fix(content);
1334 assert_eq!(fixed_content, content);
1335 }
1336
1337 #[test]
1338 fn test_ignore_front_matter() {
1339 let content = "---\ntitle: Test\n---\n- List Item\nText";
1341 let warnings = lint(content);
1342 assert_eq!(
1343 warnings.len(),
1344 0,
1345 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1346 );
1347
1348 let fixed_content = fix(content);
1350 assert_eq!(fixed_content, content, "No changes when no warnings");
1351 }
1352
1353 #[test]
1354 fn test_multiple_lists() {
1355 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1360 let warnings = lint(content);
1361 assert!(
1363 !warnings.is_empty(),
1364 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1365 );
1366
1367 check_warnings_have_fixes(content);
1369
1370 let fixed_content = fix(content);
1371 let warnings_after_fix = lint(&fixed_content);
1373 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1374 }
1375
1376 #[test]
1377 fn test_adjacent_lists() {
1378 let content = "- List 1\n\n* List 2";
1379 let warnings = lint(content);
1380 assert_eq!(warnings.len(), 0);
1381 let fixed_content = fix(content);
1382 assert_eq!(fixed_content, content);
1383 }
1384
1385 #[test]
1386 fn test_list_in_blockquote() {
1387 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1389 let warnings = lint(content);
1390 assert_eq!(
1391 warnings.len(),
1392 1,
1393 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1394 );
1395 assert_eq!(warnings[0].line, 2);
1396
1397 check_warnings_have_fixes(content);
1399
1400 let fixed_content = fix(content);
1401 assert_eq!(
1403 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1404 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1405 );
1406
1407 let warnings_after_fix = lint(&fixed_content);
1409 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1410 }
1411
1412 #[test]
1413 fn test_ordered_list() {
1414 let content = "Text\n1. Item 1\n2. Item 2\nText";
1416 let warnings = lint(content);
1417 assert_eq!(warnings.len(), 1);
1418
1419 check_warnings_have_fixes(content);
1421
1422 let fixed_content = fix(content);
1423 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1424
1425 let warnings_after_fix = lint(&fixed_content);
1427 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1428 }
1429
1430 #[test]
1431 fn test_no_double_blank_fix() {
1432 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1435 assert_eq!(
1436 warnings.len(),
1437 0,
1438 "Should have no warnings - properly preceded, trailing is lazy"
1439 );
1440
1441 let fixed_content = fix(content);
1442 assert_eq!(
1443 fixed_content, content,
1444 "No fix needed when no warnings. Got:\n{fixed_content}"
1445 );
1446
1447 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1449 assert_eq!(warnings2.len(), 1);
1450 if !warnings2.is_empty() {
1451 assert_eq!(
1452 warnings2[0].line, 2,
1453 "Warning line for missing blank before should be the first line of the block"
1454 );
1455 }
1456
1457 check_warnings_have_fixes(content2);
1459
1460 let fixed_content2 = fix(content2);
1461 assert_eq!(
1462 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1463 "Fix added extra blank before. Got:\n{fixed_content2}"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_empty_input() {
1469 let content = "";
1470 let warnings = lint(content);
1471 assert_eq!(warnings.len(), 0);
1472 let fixed_content = fix(content);
1473 assert_eq!(fixed_content, "");
1474 }
1475
1476 #[test]
1477 fn test_only_list() {
1478 let content = "- Item 1\n- Item 2";
1479 let warnings = lint(content);
1480 assert_eq!(warnings.len(), 0);
1481 let fixed_content = fix(content);
1482 assert_eq!(fixed_content, content);
1483 }
1484
1485 #[test]
1488 fn test_fix_complex_nested_blockquote() {
1489 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1491 let warnings = lint(content);
1492 assert_eq!(
1493 warnings.len(),
1494 1,
1495 "Should warn for missing preceding blank only. Got: {warnings:?}"
1496 );
1497
1498 check_warnings_have_fixes(content);
1500
1501 let fixed_content = fix(content);
1502 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1504 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1505
1506 let warnings_after_fix = lint(&fixed_content);
1507 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1508 }
1509
1510 #[test]
1511 fn test_fix_mixed_list_markers() {
1512 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1515 let warnings = lint(content);
1516 assert!(
1518 !warnings.is_empty(),
1519 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1520 );
1521
1522 check_warnings_have_fixes(content);
1524
1525 let fixed_content = fix(content);
1526 assert!(
1528 fixed_content.contains("Text\n\n-"),
1529 "Fix should add blank line before first list item"
1530 );
1531
1532 let warnings_after_fix = lint(&fixed_content);
1534 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1535 }
1536
1537 #[test]
1538 fn test_fix_ordered_list_with_different_numbers() {
1539 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1541 let warnings = lint(content);
1542 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1543
1544 check_warnings_have_fixes(content);
1546
1547 let fixed_content = fix(content);
1548 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1549 assert_eq!(
1550 fixed_content, expected,
1551 "Fix should handle ordered lists with non-sequential numbers"
1552 );
1553
1554 let warnings_after_fix = lint(&fixed_content);
1556 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1557 }
1558
1559 #[test]
1560 fn test_fix_list_with_code_blocks_inside() {
1561 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1563 let warnings = lint(content);
1564 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1565
1566 check_warnings_have_fixes(content);
1568
1569 let fixed_content = fix(content);
1570 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1571 assert_eq!(
1572 fixed_content, expected,
1573 "Fix should handle lists with internal code blocks"
1574 );
1575
1576 let warnings_after_fix = lint(&fixed_content);
1578 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1579 }
1580
1581 #[test]
1582 fn test_fix_deeply_nested_lists() {
1583 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1585 let warnings = lint(content);
1586 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1587
1588 check_warnings_have_fixes(content);
1590
1591 let fixed_content = fix(content);
1592 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1593 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1594
1595 let warnings_after_fix = lint(&fixed_content);
1597 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1598 }
1599
1600 #[test]
1601 fn test_fix_list_with_multiline_items() {
1602 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1605 let warnings = lint(content);
1606 assert_eq!(
1607 warnings.len(),
1608 1,
1609 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1610 );
1611
1612 check_warnings_have_fixes(content);
1614
1615 let fixed_content = fix(content);
1616 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1617 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1618
1619 let warnings_after_fix = lint(&fixed_content);
1621 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1622 }
1623
1624 #[test]
1625 fn test_fix_list_at_document_boundaries() {
1626 let content1 = "- Item 1\n- Item 2";
1628 let warnings1 = lint(content1);
1629 assert_eq!(
1630 warnings1.len(),
1631 0,
1632 "List at document start should not need blank before"
1633 );
1634 let fixed1 = fix(content1);
1635 assert_eq!(fixed1, content1, "No fix needed for list at start");
1636
1637 let content2 = "Text\n- Item 1\n- Item 2";
1639 let warnings2 = lint(content2);
1640 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1641 check_warnings_have_fixes(content2);
1642 let fixed2 = fix(content2);
1643 assert_eq!(
1644 fixed2, "Text\n\n- Item 1\n- Item 2",
1645 "Should add blank before list at end"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_fix_preserves_existing_blank_lines() {
1651 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1652 let warnings = lint(content);
1653 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1654 let fixed_content = fix(content);
1655 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1656 }
1657
1658 #[test]
1659 fn test_fix_handles_tabs_and_spaces() {
1660 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1663 let warnings = lint(content);
1664 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1666
1667 check_warnings_have_fixes(content);
1669
1670 let fixed_content = fix(content);
1671 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1674 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1675
1676 let warnings_after_fix = lint(&fixed_content);
1678 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1679 }
1680
1681 #[test]
1682 fn test_fix_warning_objects_have_correct_ranges() {
1683 let content = "Text\n- Item 1\n- Item 2\nText";
1685 let warnings = lint(content);
1686 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1687
1688 for warning in &warnings {
1690 assert!(warning.fix.is_some(), "Warning should have fix");
1691 let fix = warning.fix.as_ref().unwrap();
1692 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1693 assert!(
1694 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1695 "Fix should have replacement or be insertion"
1696 );
1697 }
1698 }
1699
1700 #[test]
1701 fn test_fix_idempotent() {
1702 let content = "Text\n- Item 1\n- Item 2\nText";
1704
1705 let fixed_once = fix(content);
1707 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1708
1709 let fixed_twice = fix(&fixed_once);
1711 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1712
1713 let warnings_after_fix = lint(&fixed_once);
1715 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1716 }
1717
1718 #[test]
1719 fn test_fix_with_normalized_line_endings() {
1720 let content = "Text\n- Item 1\n- Item 2\nText";
1724 let warnings = lint(content);
1725 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1726
1727 check_warnings_have_fixes(content);
1729
1730 let fixed_content = fix(content);
1731 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1733 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1734 }
1735
1736 #[test]
1737 fn test_fix_preserves_final_newline() {
1738 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1741 let fixed_with_newline = fix(content_with_newline);
1742 assert!(
1743 fixed_with_newline.ends_with('\n'),
1744 "Fix should preserve final newline when present"
1745 );
1746 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1748
1749 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1751 let fixed_without_newline = fix(content_without_newline);
1752 assert!(
1753 !fixed_without_newline.ends_with('\n'),
1754 "Fix should not add final newline when not present"
1755 );
1756 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1758 }
1759
1760 #[test]
1761 fn test_fix_multiline_list_items_no_indent() {
1762 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";
1763
1764 let warnings = lint(content);
1765 assert_eq!(
1767 warnings.len(),
1768 0,
1769 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1770 );
1771
1772 let fixed_content = fix(content);
1773 assert_eq!(
1775 fixed_content, content,
1776 "Should not modify correctly formatted multi-line list items"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_nested_list_with_lazy_continuation() {
1782 let content = r#"# Test
1788
1789- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1790 1. Switch/case dispatcher statements (original Phase 3.2)
1791 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1792`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1793 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1794 references"#;
1795
1796 let warnings = lint(content);
1797 let md032_warnings: Vec<_> = warnings
1800 .iter()
1801 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1802 .collect();
1803 assert_eq!(
1804 md032_warnings.len(),
1805 0,
1806 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_pipes_in_code_spans_not_detected_as_table() {
1812 let content = r#"# Test
1814
1815- Item with `a | b` inline code
1816 - Nested item should work
1817
1818"#;
1819
1820 let warnings = lint(content);
1821 let md032_warnings: Vec<_> = warnings
1822 .iter()
1823 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1824 .collect();
1825 assert_eq!(
1826 md032_warnings.len(),
1827 0,
1828 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1829 );
1830 }
1831
1832 #[test]
1833 fn test_multiple_code_spans_with_pipes() {
1834 let content = r#"# Test
1836
1837- Item with `a | b` and `c || d` operators
1838 - Nested item should work
1839
1840"#;
1841
1842 let warnings = lint(content);
1843 let md032_warnings: Vec<_> = warnings
1844 .iter()
1845 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1846 .collect();
1847 assert_eq!(
1848 md032_warnings.len(),
1849 0,
1850 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1851 );
1852 }
1853
1854 #[test]
1855 fn test_actual_table_breaks_list() {
1856 let content = r#"# Test
1858
1859- Item before table
1860
1861| Col1 | Col2 |
1862|------|------|
1863| A | B |
1864
1865- Item after table
1866
1867"#;
1868
1869 let warnings = lint(content);
1870 let md032_warnings: Vec<_> = warnings
1872 .iter()
1873 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1874 .collect();
1875 assert_eq!(
1876 md032_warnings.len(),
1877 0,
1878 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1879 );
1880 }
1881
1882 #[test]
1883 fn test_thematic_break_not_lazy_continuation() {
1884 let content = r#"- Item 1
1887- Item 2
1888***
1889
1890More text.
1891"#;
1892
1893 let warnings = lint(content);
1894 let md032_warnings: Vec<_> = warnings
1895 .iter()
1896 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1897 .collect();
1898 assert_eq!(
1899 md032_warnings.len(),
1900 1,
1901 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1902 );
1903 assert!(
1904 md032_warnings[0].message.contains("followed by blank line"),
1905 "Warning should be about missing blank after list"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_thematic_break_with_blank_line() {
1911 let content = r#"- Item 1
1913- Item 2
1914
1915***
1916
1917More text.
1918"#;
1919
1920 let warnings = lint(content);
1921 let md032_warnings: Vec<_> = warnings
1922 .iter()
1923 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1924 .collect();
1925 assert_eq!(
1926 md032_warnings.len(),
1927 0,
1928 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_various_thematic_break_styles() {
1934 for hr in ["---", "***", "___"] {
1939 let content = format!(
1940 r#"- Item 1
1941- Item 2
1942{hr}
1943
1944More text.
1945"#
1946 );
1947
1948 let warnings = lint(&content);
1949 let md032_warnings: Vec<_> = warnings
1950 .iter()
1951 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1952 .collect();
1953 assert_eq!(
1954 md032_warnings.len(),
1955 1,
1956 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1957 );
1958 }
1959 }
1960
1961 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1964 let rule = MD032BlanksAroundLists::from_config_struct(config);
1965 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1966 rule.check(&ctx).expect("Lint check failed")
1967 }
1968
1969 fn fix_with_config(content: &str, config: MD032Config) -> String {
1970 let rule = MD032BlanksAroundLists::from_config_struct(config);
1971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 rule.fix(&ctx).expect("Lint fix failed")
1973 }
1974
1975 #[test]
1976 fn test_lazy_continuation_allowed_by_default() {
1977 let content = "# Heading\n\n1. List\nSome text.";
1979 let warnings = lint(content);
1980 assert_eq!(
1981 warnings.len(),
1982 0,
1983 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1984 );
1985 }
1986
1987 #[test]
1988 fn test_lazy_continuation_disallowed() {
1989 let content = "# Heading\n\n1. List\nSome text.";
1991 let config = MD032Config {
1992 allow_lazy_continuation: false,
1993 };
1994 let warnings = lint_with_config(content, config);
1995 assert_eq!(
1996 warnings.len(),
1997 1,
1998 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1999 );
2000 assert!(
2001 warnings[0].message.contains("Lazy continuation"),
2002 "Warning message should mention lazy continuation"
2003 );
2004 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2005 }
2006
2007 #[test]
2008 fn test_lazy_continuation_fix() {
2009 let content = "# Heading\n\n1. List\nSome text.";
2011 let config = MD032Config {
2012 allow_lazy_continuation: false,
2013 };
2014 let fixed = fix_with_config(content, config.clone());
2015 assert_eq!(
2017 fixed, "# Heading\n\n1. List\n Some text.",
2018 "Fix should add proper indentation to lazy continuation"
2019 );
2020
2021 let warnings_after = lint_with_config(&fixed, config);
2023 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2024 }
2025
2026 #[test]
2027 fn test_lazy_continuation_multiple_lines() {
2028 let content = "- Item 1\nLine 2\nLine 3";
2030 let config = MD032Config {
2031 allow_lazy_continuation: false,
2032 };
2033 let warnings = lint_with_config(content, config.clone());
2034 assert_eq!(
2036 warnings.len(),
2037 2,
2038 "Should warn for each lazy continuation line. Got: {warnings:?}"
2039 );
2040
2041 let fixed = fix_with_config(content, config.clone());
2042 assert_eq!(
2044 fixed, "- Item 1\n Line 2\n Line 3",
2045 "Fix should add proper indentation to lazy continuation lines"
2046 );
2047
2048 let warnings_after = lint_with_config(&fixed, config);
2050 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2051 }
2052
2053 #[test]
2054 fn test_lazy_continuation_with_indented_content() {
2055 let content = "- Item 1\n Indented content\nLazy text";
2057 let config = MD032Config {
2058 allow_lazy_continuation: false,
2059 };
2060 let warnings = lint_with_config(content, config);
2061 assert_eq!(
2062 warnings.len(),
2063 1,
2064 "Should warn for lazy text after indented content. Got: {warnings:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_lazy_continuation_properly_separated() {
2070 let content = "- Item 1\n\nSome text.";
2072 let config = MD032Config {
2073 allow_lazy_continuation: false,
2074 };
2075 let warnings = lint_with_config(content, config);
2076 assert_eq!(
2077 warnings.len(),
2078 0,
2079 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2080 );
2081 }
2082
2083 #[test]
2086 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2087 let content = "1) First item\nLazy continuation";
2089 let config = MD032Config {
2090 allow_lazy_continuation: false,
2091 };
2092 let warnings = lint_with_config(content, config.clone());
2093 assert_eq!(
2094 warnings.len(),
2095 1,
2096 "Should warn for lazy continuation with parenthesis marker"
2097 );
2098
2099 let fixed = fix_with_config(content, config);
2100 assert_eq!(fixed, "1) First item\n Lazy continuation");
2102 }
2103
2104 #[test]
2105 fn test_lazy_continuation_followed_by_another_list() {
2106 let content = "- Item 1\nSome text\n- Item 2";
2112 let config = MD032Config {
2113 allow_lazy_continuation: false,
2114 };
2115 let warnings = lint_with_config(content, config);
2116 assert_eq!(
2118 warnings.len(),
2119 1,
2120 "Should warn about lazy continuation within list. Got: {warnings:?}"
2121 );
2122 assert!(
2123 warnings[0].message.contains("Lazy continuation"),
2124 "Warning should be about lazy continuation"
2125 );
2126 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2127 }
2128
2129 #[test]
2130 fn test_lazy_continuation_multiple_in_document() {
2131 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2136 let config = MD032Config {
2137 allow_lazy_continuation: false,
2138 };
2139 let warnings = lint_with_config(content, config.clone());
2140 assert_eq!(
2142 warnings.len(),
2143 2,
2144 "Should warn for both lazy continuations. Got: {warnings:?}"
2145 );
2146
2147 let fixed = fix_with_config(content, config.clone());
2148 assert!(
2150 fixed.contains(" Lazy 1"),
2151 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2152 );
2153 assert!(
2154 fixed.contains(" Lazy 2"),
2155 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2156 );
2157
2158 let warnings_after = lint_with_config(&fixed, config);
2159 assert_eq!(
2161 warnings_after.len(),
2162 0,
2163 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2164 );
2165 }
2166
2167 #[test]
2168 fn test_lazy_continuation_end_of_document_no_newline() {
2169 let content = "- Item\nNo trailing newline";
2171 let config = MD032Config {
2172 allow_lazy_continuation: false,
2173 };
2174 let warnings = lint_with_config(content, config.clone());
2175 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2176
2177 let fixed = fix_with_config(content, config);
2178 assert_eq!(fixed, "- Item\n No trailing newline");
2180 }
2181
2182 #[test]
2183 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2184 let content = "- Item 1\n---";
2187 let config = MD032Config {
2188 allow_lazy_continuation: false,
2189 };
2190 let warnings = lint_with_config(content, config.clone());
2191 assert_eq!(
2193 warnings.len(),
2194 1,
2195 "List should need blank line before thematic break. Got: {warnings:?}"
2196 );
2197
2198 let fixed = fix_with_config(content, config);
2200 assert_eq!(fixed, "- Item 1\n\n---");
2201 }
2202
2203 #[test]
2204 fn test_lazy_continuation_heading_not_flagged() {
2205 let content = "- Item 1\n# Heading";
2208 let config = MD032Config {
2209 allow_lazy_continuation: false,
2210 };
2211 let warnings = lint_with_config(content, config);
2212 assert!(
2215 warnings.iter().all(|w| !w.message.contains("lazy")),
2216 "Heading should not trigger lazy continuation warning"
2217 );
2218 }
2219
2220 #[test]
2221 fn test_lazy_continuation_mixed_list_types() {
2222 let content = "- Unordered\n1. Ordered\nLazy text";
2224 let config = MD032Config {
2225 allow_lazy_continuation: false,
2226 };
2227 let warnings = lint_with_config(content, config.clone());
2228 assert!(!warnings.is_empty(), "Should warn about structure issues");
2229 }
2230
2231 #[test]
2232 fn test_lazy_continuation_deep_nesting() {
2233 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2235 let config = MD032Config {
2236 allow_lazy_continuation: false,
2237 };
2238 let warnings = lint_with_config(content, config.clone());
2239 assert!(
2240 !warnings.is_empty(),
2241 "Should warn about lazy continuation after nested list"
2242 );
2243
2244 let fixed = fix_with_config(content, config.clone());
2245 let warnings_after = lint_with_config(&fixed, config);
2246 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2247 }
2248
2249 #[test]
2250 fn test_lazy_continuation_with_emphasis_in_text() {
2251 let content = "- Item\n*emphasized* continuation";
2253 let config = MD032Config {
2254 allow_lazy_continuation: false,
2255 };
2256 let warnings = lint_with_config(content, config.clone());
2257 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2258
2259 let fixed = fix_with_config(content, config);
2260 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2262 }
2263
2264 #[test]
2265 fn test_lazy_continuation_with_code_span() {
2266 let content = "- Item\n`code` continuation";
2268 let config = MD032Config {
2269 allow_lazy_continuation: false,
2270 };
2271 let warnings = lint_with_config(content, config.clone());
2272 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2273
2274 let fixed = fix_with_config(content, config);
2275 assert_eq!(fixed, "- Item\n `code` continuation");
2277 }
2278
2279 #[test]
2286 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2287 let content = r#"1. Create a new Chat conversation:
2290 - On the sidebar, select **New Chat**.
2291 - In the box, type `/new`.
2292 A new Chat conversation replaces the previous one.
22931. Under the Chat text box, turn off the toggle."#;
2294 let config = MD032Config {
2295 allow_lazy_continuation: false,
2296 };
2297 let warnings = lint_with_config(content, config);
2298 let lazy_warnings: Vec<_> = warnings
2300 .iter()
2301 .filter(|w| w.message.contains("Lazy continuation"))
2302 .collect();
2303 assert!(
2304 !lazy_warnings.is_empty(),
2305 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2306 );
2307 assert!(
2308 lazy_warnings.iter().any(|w| w.line == 4),
2309 "Should warn on line 4. Got: {lazy_warnings:?}"
2310 );
2311 }
2312
2313 #[test]
2314 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2315 let content = r#"- `field`: Is the specific key:
2318 - `password`: Accesses the password.
2319 - `api_key`: Accesses the api_key.
2320 `token`: Specifies which ID token to use.
2321- `version_id`: Is the unique identifier."#;
2322 let config = MD032Config {
2323 allow_lazy_continuation: false,
2324 };
2325 let warnings = lint_with_config(content, config);
2326 let lazy_warnings: Vec<_> = warnings
2328 .iter()
2329 .filter(|w| w.message.contains("Lazy continuation"))
2330 .collect();
2331 assert!(
2332 !lazy_warnings.is_empty(),
2333 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2334 );
2335 assert!(
2336 lazy_warnings.iter().any(|w| w.line == 4),
2337 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2343 let content = r#"- Check out the branch, and test locally.
2345 - If the MR requires significant modifications:
2346 - **Skip local testing** and review instead.
2347 - **Request verification** from the author.
2348 - **Identify the minimal change** needed.
2349 Your testing might result in opportunities.
2350- If you don't understand, _say so_."#;
2351 let config = MD032Config {
2352 allow_lazy_continuation: false,
2353 };
2354 let warnings = lint_with_config(content, config);
2355 let lazy_warnings: Vec<_> = warnings
2357 .iter()
2358 .filter(|w| w.message.contains("Lazy continuation"))
2359 .collect();
2360 assert!(
2361 !lazy_warnings.is_empty(),
2362 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2363 );
2364 assert!(
2365 lazy_warnings.iter().any(|w| w.line == 6),
2366 "Should warn on line 6. Got: {lazy_warnings:?}"
2367 );
2368 }
2369
2370 #[test]
2371 fn test_issue295_ordered_list_nested_bullets_continuation() {
2372 let content = r#"# Test
2375
23761. First item.
2377 - Nested A.
2378 - Nested B.
2379 Continuation at outer level.
23801. Second item."#;
2381 let config = MD032Config {
2382 allow_lazy_continuation: false,
2383 };
2384 let warnings = lint_with_config(content, config);
2385 let lazy_warnings: Vec<_> = warnings
2387 .iter()
2388 .filter(|w| w.message.contains("Lazy continuation"))
2389 .collect();
2390 assert!(
2391 !lazy_warnings.is_empty(),
2392 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2393 );
2394 assert!(
2396 lazy_warnings.iter().any(|w| w.line == 6),
2397 "Should warn on line 6. Got: {lazy_warnings:?}"
2398 );
2399 }
2400
2401 #[test]
2402 fn test_issue295_multiple_lazy_lines_after_nested() {
2403 let content = r#"1. The device client receives a response.
2405 - Those defined by OAuth Framework.
2406 - Those specific to device authorization.
2407 Those error responses are described below.
2408 For more information on each response,
2409 see the documentation.
24101. Next step in the process."#;
2411 let config = MD032Config {
2412 allow_lazy_continuation: false,
2413 };
2414 let warnings = lint_with_config(content, config);
2415 let lazy_warnings: Vec<_> = warnings
2417 .iter()
2418 .filter(|w| w.message.contains("Lazy continuation"))
2419 .collect();
2420 assert!(
2421 lazy_warnings.len() >= 3,
2422 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2423 lazy_warnings.len()
2424 );
2425 }
2426
2427 #[test]
2428 fn test_issue295_properly_indented_not_lazy() {
2429 let content = r#"1. First item.
2431 - Nested A.
2432 - Nested B.
2433
2434 Properly indented continuation.
24351. Second item."#;
2436 let config = MD032Config {
2437 allow_lazy_continuation: false,
2438 };
2439 let warnings = lint_with_config(content, config);
2440 let lazy_warnings: Vec<_> = warnings
2442 .iter()
2443 .filter(|w| w.message.contains("Lazy continuation"))
2444 .collect();
2445 assert_eq!(
2446 lazy_warnings.len(),
2447 0,
2448 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2449 );
2450 }
2451
2452 #[test]
2459 fn test_html_comment_before_list_with_preceding_blank() {
2460 let content = "Some text.\n\n<!-- comment -->\n- List item";
2463 let warnings = lint(content);
2464 assert_eq!(
2465 warnings.len(),
2466 0,
2467 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2468 );
2469 }
2470
2471 #[test]
2472 fn test_html_comment_after_list_with_following_blank() {
2473 let content = "- List item\n<!-- comment -->\n\nSome text.";
2475 let warnings = lint(content);
2476 assert_eq!(
2477 warnings.len(),
2478 0,
2479 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2480 );
2481 }
2482
2483 #[test]
2484 fn test_list_inside_html_comment_ignored() {
2485 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2487 let warnings = lint(content);
2488 assert_eq!(
2489 warnings.len(),
2490 0,
2491 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_multiline_html_comment_before_list() {
2497 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2499 let warnings = lint(content);
2500 assert_eq!(
2501 warnings.len(),
2502 0,
2503 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_no_blank_before_html_comment_still_warns() {
2509 let content = "Some text.\n<!-- comment -->\n- List item";
2511 let warnings = lint(content);
2512 assert_eq!(
2513 warnings.len(),
2514 1,
2515 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2516 );
2517 assert!(
2518 warnings[0].message.contains("preceded by blank line"),
2519 "Should be 'preceded by blank line' warning"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2525 let content = "- List item\n<!-- comment -->\nSome text.";
2528 let warnings = lint(content);
2529 assert_eq!(
2530 warnings.len(),
2531 0,
2532 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_list_followed_by_heading_through_comment_should_warn() {
2538 let content = "- List item\n<!-- comment -->\n# Heading";
2540 let warnings = lint(content);
2541 assert!(
2544 warnings.len() <= 1,
2545 "Should handle heading after comment gracefully. Got: {warnings:?}"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_html_comment_between_list_and_text_both_directions() {
2551 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2553 let warnings = lint(content);
2554 assert_eq!(
2555 warnings.len(),
2556 0,
2557 "Should not warn with proper separation through comments. Got: {warnings:?}"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2563 let content = "Text.\n\n<!-- comment -->\n- Item";
2565 let fixed = fix(content);
2566 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2567 }
2568
2569 #[test]
2570 fn test_html_comment_fix_adds_blank_when_needed() {
2571 let content = "Text.\n<!-- comment -->\n- Item";
2574 let fixed = fix(content);
2575 assert!(
2576 fixed.contains("<!-- comment -->\n\n- Item"),
2577 "Fix should add blank line before list. Got: {fixed}"
2578 );
2579 }
2580
2581 #[test]
2582 fn test_ordered_list_inside_html_comment() {
2583 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2585 let warnings = lint(content);
2586 assert_eq!(
2587 warnings.len(),
2588 0,
2589 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2590 );
2591 }
2592
2593 #[test]
2600 fn test_blockquote_list_exit_no_warning() {
2601 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2603 let warnings = lint(content);
2604 assert_eq!(
2605 warnings.len(),
2606 0,
2607 "Should not warn when exiting blockquote. Got: {warnings:?}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_nested_blockquote_list_exit() {
2613 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2615 let warnings = lint(content);
2616 assert_eq!(
2617 warnings.len(),
2618 0,
2619 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2620 );
2621 }
2622
2623 #[test]
2624 fn test_blockquote_same_level_no_warning() {
2625 let content = "> - item 1\n> - item 2\n> Text after";
2628 let warnings = lint(content);
2629 assert_eq!(
2630 warnings.len(),
2631 0,
2632 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2633 );
2634 }
2635
2636 #[test]
2637 fn test_blockquote_list_with_special_chars() {
2638 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2640 let warnings = lint(content);
2641 assert_eq!(
2642 warnings.len(),
2643 0,
2644 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2645 );
2646 }
2647
2648 #[test]
2649 fn test_lazy_continuation_whitespace_only_line() {
2650 let content = "- Item\n \nText after whitespace-only line";
2653 let config = MD032Config {
2654 allow_lazy_continuation: false,
2655 };
2656 let warnings = lint_with_config(content, config);
2657 assert_eq!(
2659 warnings.len(),
2660 0,
2661 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2662 );
2663 }
2664
2665 #[test]
2666 fn test_lazy_continuation_blockquote_context() {
2667 let content = "> - Item\n> Lazy in quote";
2669 let config = MD032Config {
2670 allow_lazy_continuation: false,
2671 };
2672 let warnings = lint_with_config(content, config);
2673 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2676 }
2677
2678 #[test]
2679 fn test_lazy_continuation_fix_preserves_content() {
2680 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2682 let config = MD032Config {
2683 allow_lazy_continuation: false,
2684 };
2685 let fixed = fix_with_config(content, config);
2686 assert!(fixed.contains("<>&"), "Should preserve special chars");
2687 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2688 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2690 }
2691
2692 #[test]
2693 fn test_lazy_continuation_fix_idempotent() {
2694 let content = "- Item\nLazy";
2696 let config = MD032Config {
2697 allow_lazy_continuation: false,
2698 };
2699 let fixed_once = fix_with_config(content, config.clone());
2700 let fixed_twice = fix_with_config(&fixed_once, config);
2701 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2702 }
2703
2704 #[test]
2705 fn test_lazy_continuation_config_default_allows() {
2706 let content = "- Item\nLazy text that continues";
2708 let default_config = MD032Config::default();
2709 assert!(
2710 default_config.allow_lazy_continuation,
2711 "Default should allow lazy continuation"
2712 );
2713 let warnings = lint_with_config(content, default_config);
2714 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2715 }
2716
2717 #[test]
2718 fn test_lazy_continuation_after_multi_line_item() {
2719 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2721 let config = MD032Config {
2722 allow_lazy_continuation: false,
2723 };
2724 let warnings = lint_with_config(content, config.clone());
2725 assert_eq!(
2726 warnings.len(),
2727 1,
2728 "Should warn only for the lazy line, not the indented line"
2729 );
2730 }
2731
2732 #[test]
2734 fn test_blockquote_list_with_continuation_and_nested() {
2735 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2738 let warnings = lint(content);
2739 assert_eq!(
2740 warnings.len(),
2741 0,
2742 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2743 );
2744 }
2745
2746 #[test]
2747 fn test_blockquote_list_simple() {
2748 let content = "> - item 1\n> - item 2";
2750 let warnings = lint(content);
2751 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2752 }
2753
2754 #[test]
2755 fn test_blockquote_list_with_continuation_only() {
2756 let content = "> - item 1\n> continuation\n> - item 2";
2758 let warnings = lint(content);
2759 assert_eq!(
2760 warnings.len(),
2761 0,
2762 "Blockquoted list with continuation should have no warnings"
2763 );
2764 }
2765
2766 #[test]
2767 fn test_blockquote_list_with_lazy_continuation() {
2768 let content = "> - item 1\n> lazy continuation\n> - item 2";
2770 let warnings = lint(content);
2771 assert_eq!(
2772 warnings.len(),
2773 0,
2774 "Blockquoted list with lazy continuation should have no warnings"
2775 );
2776 }
2777
2778 #[test]
2779 fn test_nested_blockquote_list() {
2780 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2782 let warnings = lint(content);
2783 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2784 }
2785
2786 #[test]
2787 fn test_blockquote_list_needs_preceding_blank() {
2788 let content = "> Text before\n> - item 1\n> - item 2";
2790 let warnings = lint(content);
2791 assert_eq!(
2792 warnings.len(),
2793 1,
2794 "Should warn for missing blank before blockquoted list"
2795 );
2796 }
2797
2798 #[test]
2799 fn test_blockquote_list_properly_separated() {
2800 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2802 let warnings = lint(content);
2803 assert_eq!(
2804 warnings.len(),
2805 0,
2806 "Properly separated blockquoted list should have no warnings"
2807 );
2808 }
2809
2810 #[test]
2811 fn test_blockquote_ordered_list() {
2812 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2814 let warnings = lint(content);
2815 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2816 }
2817
2818 #[test]
2819 fn test_blockquote_list_with_empty_blockquote_line() {
2820 let content = "> - item 1\n>\n> - item 2";
2822 let warnings = lint(content);
2823 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2824 }
2825
2826 #[test]
2828 fn test_blockquote_list_multi_paragraph_items() {
2829 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2832 let warnings = lint(content);
2833 assert_eq!(
2834 warnings.len(),
2835 0,
2836 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2837 );
2838 }
2839
2840 #[test]
2842 fn test_blockquote_ordered_list_multi_paragraph_items() {
2843 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2844 let warnings = lint(content);
2845 assert_eq!(
2846 warnings.len(),
2847 0,
2848 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2849 );
2850 }
2851
2852 #[test]
2854 fn test_blockquote_list_multiple_continuations() {
2855 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2856 let warnings = lint(content);
2857 assert_eq!(
2858 warnings.len(),
2859 0,
2860 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2861 );
2862 }
2863
2864 #[test]
2866 fn test_nested_blockquote_multi_paragraph_list() {
2867 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2868 let warnings = lint(content);
2869 assert_eq!(
2870 warnings.len(),
2871 0,
2872 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2873 );
2874 }
2875
2876 #[test]
2878 fn test_triple_nested_blockquote_multi_paragraph_list() {
2879 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2880 let warnings = lint(content);
2881 assert_eq!(
2882 warnings.len(),
2883 0,
2884 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2885 );
2886 }
2887
2888 #[test]
2890 fn test_blockquote_list_last_item_continuation() {
2891 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2892 let warnings = lint(content);
2893 assert_eq!(
2894 warnings.len(),
2895 0,
2896 "Last item with continuation should have no warnings. Got: {warnings:?}"
2897 );
2898 }
2899
2900 #[test]
2902 fn test_blockquote_list_first_item_only_continuation() {
2903 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2904 let warnings = lint(content);
2905 assert_eq!(
2906 warnings.len(),
2907 0,
2908 "Single item with continuation should have no warnings. Got: {warnings:?}"
2909 );
2910 }
2911
2912 #[test]
2916 fn test_blockquote_level_change_breaks_list() {
2917 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2919 let warnings = lint(content);
2920 assert!(
2924 warnings.len() <= 2,
2925 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2926 );
2927 }
2928
2929 #[test]
2931 fn test_exit_blockquote_needs_blank_before_list() {
2932 let content = "> Blockquote text\n\n- List outside blockquote\n";
2934 let warnings = lint(content);
2935 assert_eq!(
2936 warnings.len(),
2937 0,
2938 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2939 );
2940
2941 let content2 = "> Blockquote text\n- List outside blockquote\n";
2945 let warnings2 = lint(content2);
2946 assert!(
2948 warnings2.len() <= 1,
2949 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2950 );
2951 }
2952
2953 #[test]
2955 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2956 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2958 let warnings = lint(content_dash);
2959 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2960
2961 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2963 let warnings = lint(content_asterisk);
2964 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2965
2966 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2968 let warnings = lint(content_plus);
2969 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2970 }
2971
2972 #[test]
2974 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2975 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2976 let warnings = lint(content);
2977 assert_eq!(
2978 warnings.len(),
2979 0,
2980 "Parenthesis ordered markers should work. Got: {warnings:?}"
2981 );
2982 }
2983
2984 #[test]
2986 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2987 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2989 let warnings = lint(content);
2990 assert_eq!(
2991 warnings.len(),
2992 0,
2993 "Multi-digit ordered list should work. Got: {warnings:?}"
2994 );
2995 }
2996
2997 #[test]
2999 fn test_blockquote_multi_paragraph_with_formatting() {
3000 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3001 let warnings = lint(content);
3002 assert_eq!(
3003 warnings.len(),
3004 0,
3005 "Continuation with inline formatting should work. Got: {warnings:?}"
3006 );
3007 }
3008
3009 #[test]
3011 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3012 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3013 let warnings = lint(content);
3014 assert_eq!(
3015 warnings.len(),
3016 0,
3017 "All items with continuations should work. Got: {warnings:?}"
3018 );
3019 }
3020
3021 #[test]
3023 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3024 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3025 let warnings = lint(content);
3026 assert_eq!(
3027 warnings.len(),
3028 0,
3029 "Lowercase continuation should work. Got: {warnings:?}"
3030 );
3031 }
3032
3033 #[test]
3035 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3036 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3037 let warnings = lint(content);
3038 assert_eq!(
3039 warnings.len(),
3040 0,
3041 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3042 );
3043 }
3044
3045 #[test]
3047 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3048 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3050 let warnings = lint(content);
3051 assert!(
3053 warnings.len() <= 1,
3054 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3055 );
3056 }
3057
3058 #[test]
3060 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3061 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3063 let warnings = lint(content);
3064 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3065 }
3066
3067 #[test]
3068 fn test_blockquote_list_varying_spaces_after_marker() {
3069 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3071 let warnings = lint(content);
3072 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3073 }
3074
3075 #[test]
3076 fn test_deeply_nested_blockquote_list() {
3077 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3079 let warnings = lint(content);
3080 assert_eq!(
3081 warnings.len(),
3082 0,
3083 "Deeply nested blockquote list should have no warnings"
3084 );
3085 }
3086
3087 #[test]
3088 fn test_blockquote_level_change_in_list() {
3089 let content = "> - item 1\n>> - deeper item\n> - item 2";
3091 let warnings = lint(content);
3094 assert!(
3095 !warnings.is_empty(),
3096 "Blockquote level change should break list and trigger warnings"
3097 );
3098 }
3099
3100 #[test]
3101 fn test_blockquote_list_with_code_span() {
3102 let content = "> - item with `code`\n> continuation\n> - item 2";
3104 let warnings = lint(content);
3105 assert_eq!(
3106 warnings.len(),
3107 0,
3108 "Blockquote list with code span should have no warnings"
3109 );
3110 }
3111
3112 #[test]
3113 fn test_code_span_html_comment_delimiters_no_false_positive() {
3114 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";
3120 let warnings = lint(content);
3121 assert_eq!(
3122 warnings.len(),
3123 0,
3124 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3125 );
3126 }
3127
3128 #[test]
3129 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3130 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";
3135 let fixed = fix(content);
3136 assert_eq!(
3137 fixed, content,
3138 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3139 );
3140 }
3141
3142 #[test]
3143 fn test_blockquote_list_at_document_end() {
3144 let content = "> Some text\n>\n> - item 1\n> - item 2";
3146 let warnings = lint(content);
3147 assert_eq!(
3148 warnings.len(),
3149 0,
3150 "Blockquote list at document end should have no warnings"
3151 );
3152 }
3153
3154 #[test]
3155 fn test_fix_preserves_blockquote_prefix_before_list() {
3156 let content = "> Text before
3158> - Item 1
3159> - Item 2";
3160 let fixed = fix(content);
3161
3162 let expected = "> Text before
3164>
3165> - Item 1
3166> - Item 2";
3167 assert_eq!(
3168 fixed, expected,
3169 "Fix should insert '>' blank line, not plain blank line"
3170 );
3171 }
3172
3173 #[test]
3174 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3175 let content = ">>> Triple nested
3178>>> - Item 1
3179>>> - Item 2
3180>>> More text";
3181 let fixed = fix(content);
3182
3183 let expected = ">>> Triple nested
3185>>>
3186>>> - Item 1
3187>>> - Item 2
3188>>> More text";
3189 assert_eq!(
3190 fixed, expected,
3191 "Fix should preserve triple-nested blockquote prefix '>>>'"
3192 );
3193 }
3194
3195 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3198 let rule = MD032BlanksAroundLists::default();
3199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3200 rule.check(&ctx).unwrap()
3201 }
3202
3203 #[test]
3204 fn test_quarto_list_after_div_open() {
3205 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3207 let warnings = lint_quarto(content);
3208 assert!(
3210 warnings.is_empty(),
3211 "Quarto div marker should be transparent before list: {warnings:?}"
3212 );
3213 }
3214
3215 #[test]
3216 fn test_quarto_list_before_div_close() {
3217 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3219 let warnings = lint_quarto(content);
3220 assert!(
3222 warnings.is_empty(),
3223 "Quarto div marker should be transparent after list: {warnings:?}"
3224 );
3225 }
3226
3227 #[test]
3228 fn test_quarto_list_needs_blank_without_div() {
3229 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3231 let warnings = lint_quarto(content);
3232 assert!(
3235 !warnings.is_empty(),
3236 "Should still require blank when not present: {warnings:?}"
3237 );
3238 }
3239
3240 #[test]
3241 fn test_quarto_list_in_callout_with_content() {
3242 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3244 let warnings = lint_quarto(content);
3245 assert!(
3246 warnings.is_empty(),
3247 "List with proper blanks inside callout should pass: {warnings:?}"
3248 );
3249 }
3250
3251 #[test]
3252 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3253 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3255 let warnings = lint(content); assert!(
3258 !warnings.is_empty(),
3259 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3260 );
3261 }
3262
3263 #[test]
3264 fn test_quarto_nested_divs_with_list() {
3265 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3267 let warnings = lint_quarto(content);
3268 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3269 }
3270
3271 #[test]
3272 fn test_issue512_complex_nested_list_with_continuation() {
3273 let content = "\
3276- First level of indentation.
3277 - Second level of indentation.
3278 - Third level of indentation.
3279 - Third level of indentation.
3280
3281 Second level list continuation.
3282
3283 First level list continuation.
3284- First level of indentation.
3285";
3286 let warnings = lint(content);
3287 assert!(
3288 warnings.is_empty(),
3289 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3290 );
3291 }
3292
3293 #[test]
3294 fn test_issue512_continuation_at_root_level() {
3295 let content = "\
3299- First level.
3300 - Second level.
3301
3302 First level continuation.
3303
3304Root level lazy continuation.
3305- Another first level item.
3306";
3307 let warnings = lint(content);
3308 assert_eq!(
3309 warnings.len(),
3310 1,
3311 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3312 );
3313 assert_eq!(warnings[0].line, 7);
3314 }
3315
3316 #[test]
3317 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3318 let content = "\
3320- Level 1 item.
3321 - Level 2 item.
3322 - Level 3 item.
3323
3324 Level 3 continuation.
3325
3326 Level 2 continuation.
3327
3328 Level 1 continuation (indented under marker).
3329- Another level 1 item.
3330";
3331 let warnings = lint(content);
3332 assert!(
3333 warnings.is_empty(),
3334 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3335 );
3336 }
3337
3338 #[test]
3339 fn test_pandoc_list_after_div_open() {
3340 let rule = MD032BlanksAroundLists::default();
3343 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3345 let warnings = rule.check(&ctx).unwrap();
3346 assert!(
3347 warnings.is_empty(),
3348 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3349 );
3350 }
3351
3352 #[test]
3353 fn test_md032_html_comment() {
3354 let rule = MD032BlanksAroundLists::default();
3355 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3357 let warnings = rule.check(&ctx).unwrap();
3358 assert!(
3359 warnings.is_empty(),
3360 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3366 let rule = MD032BlanksAroundLists::default();
3372 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";
3373 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3374 let warnings = rule.check(&ctx).unwrap();
3375 assert!(
3376 warnings.is_empty(),
3377 "admonition-nested ordered list should not be flagged: {warnings:?}"
3378 );
3379 }
3380
3381 #[test]
3382 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3383 let rule = MD032BlanksAroundLists::default();
3387 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";
3388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3389 let warnings = rule.check(&ctx).unwrap();
3390 assert!(
3391 warnings.is_empty(),
3392 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3393 );
3394 }
3395
3396 #[test]
3397 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3398 let rule = MD032BlanksAroundLists::default();
3400 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";
3401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3402 let warnings = rule.check(&ctx).unwrap();
3403 assert!(
3404 warnings.is_empty(),
3405 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3406 );
3407 }
3408
3409 #[test]
3410 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3411 let rule = MD032BlanksAroundLists::default();
3417 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3419 let warnings = rule.check(&ctx).unwrap();
3420 assert_eq!(
3421 warnings.len(),
3422 1,
3423 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3424 );
3425 }
3426
3427 #[test]
3428 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3429 let rule = MD032BlanksAroundLists::default();
3434 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
3435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3436 let warnings = rule.check(&ctx).unwrap();
3437 assert_eq!(
3438 warnings.len(),
3439 1,
3440 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3441 );
3442 }
3443
3444 #[test]
3445 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3446 let rule = MD032BlanksAroundLists::default();
3451 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";
3452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3453 let warnings = rule.check(&ctx).unwrap();
3454 assert!(
3455 warnings.is_empty(),
3456 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3457 );
3458 }
3459
3460 #[test]
3461 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3462 let rule = MD032BlanksAroundLists::default();
3468 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3470 let warnings = rule.check(&ctx).unwrap();
3471 assert_eq!(
3472 warnings.len(),
3473 1,
3474 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3475 );
3476 assert_eq!(warnings[0].line, 4);
3477 assert!(warnings[0].message.contains("non-1"));
3478 }
3479
3480 #[test]
3481 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3482 let rule = MD032BlanksAroundLists::default();
3488 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";
3489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3490 let warnings = rule.check(&ctx).unwrap();
3491 assert!(
3492 warnings.is_empty(),
3493 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3494 );
3495 }
3496
3497 #[test]
3498 fn test_standard_flavor_admonition_indented_list_unchanged() {
3499 let rule = MD032BlanksAroundLists::default();
3504 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";
3505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3506 let warnings = rule.check(&ctx).unwrap();
3507 assert!(
3508 warnings.is_empty(),
3509 "indented code block under standard flavor should not be flagged: {warnings:?}"
3510 );
3511 }
3512
3513 #[test]
3514 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3515 let rule = MD032BlanksAroundLists::default();
3520 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";
3521 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3522 let warnings = rule.check(&ctx).unwrap();
3523 assert_eq!(
3524 warnings.len(),
3525 1,
3526 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3527 );
3528 assert_eq!(warnings[0].line, 6);
3529 }
3530
3531 #[test]
3532 fn test_pseudo_list_marker_after_list() {
3533 let content = indoc::indoc! {"
3534 - Item 1
3535 Item 1 content.
3536
3537 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3538 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3539 are not used except when those semantics are desired.
3540 "};
3541 let warnings = lint(content);
3542 assert!(
3543 warnings.is_empty(),
3544 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3545 );
3546 }
3547
3548 #[test]
3549 fn test_pseudo_list_marker_without_preceding_list() {
3550 let content = indoc::indoc! {"
3551 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3552 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3553 are not used except when those semantics are desired.
3554 "};
3555 let warnings = lint(content);
3556 assert!(
3557 warnings.is_empty(),
3558 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3559 );
3560 }
3561
3562 #[test]
3563 fn test_no_space_hash_continuation_line_stays_in_its_item() {
3564 let content = indoc::indoc! {"
3569 5. **`M.md`** - the deltas (esp. items #1,
3570 #2, #3, #5, #8).
3571
3572 ---
3573
3574 ## Plan
3575
3576 ### Phase 0
3577 - [ ] task one
3578 wrapped
3579 "};
3580 let warnings = lint(content);
3581 assert_eq!(
3582 warnings.len(),
3583 1,
3584 "only the task list is missing a blank line, got: {warnings:?}"
3585 );
3586 assert_eq!(warnings[0].line, 9);
3587 assert_eq!(warnings[0].message, "List should be preceded by blank line");
3588
3589 let expected = indoc::indoc! {"
3590 5. **`M.md`** - the deltas (esp. items #1,
3591 #2, #3, #5, #8).
3592
3593 ---
3594
3595 ## Plan
3596
3597 ### Phase 0
3598
3599 - [ ] task one
3600 wrapped
3601 "};
3602 assert_eq!(fix(content), expected);
3603 }
3604
3605 #[test]
3606 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3607 let content = indoc::indoc! {"
3613 1. first
3614
3615 3. item
3616 continuation
3617
3618 1. nested
3619 2. nested
3620
3621 ## Heading
3622 - task
3623 "};
3624 let warnings = lint(content);
3625 assert_eq!(
3626 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3627 vec![10],
3628 "only the list after the heading is missing a blank line, got: {warnings:?}"
3629 );
3630
3631 let expected = indoc::indoc! {"
3632 1. first
3633
3634 3. item
3635 continuation
3636
3637 1. nested
3638 2. nested
3639
3640 ## Heading
3641
3642 - task
3643 "};
3644 assert_eq!(fix(content), expected);
3645 }
3646
3647 #[test]
3648 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
3649 let content = indoc::indoc! {"
3653 - item (esp. #1,
3654 #2, #3).
3655 - next item
3656
3657 ## Heading
3658 - task
3659 "};
3660 let warnings = lint(content);
3661 assert_eq!(
3662 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3663 vec![6],
3664 "only the list after the heading is missing a blank line, got: {warnings:?}"
3665 );
3666
3667 let expected = indoc::indoc! {"
3668 - item (esp. #1,
3669 #2, #3).
3670 - next item
3671
3672 ## Heading
3673
3674 - task
3675 "};
3676 assert_eq!(fix(content), expected);
3677 }
3678
3679 #[test]
3680 fn test_under_indented_continuation_lines_stay_in_their_item() {
3681 for content in [
3685 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
3686 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
3687 "- Item\n lazy continuation\n- another item\n",
3688 "> - Item\n> lazy continuation\n> - another item\n",
3689 ] {
3690 let warnings = lint(content);
3691 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3692 assert_eq!(fix(content), content, "{content:?}");
3693 }
3694 }
3695
3696 #[test]
3697 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
3698 let config = MD032Config {
3701 allow_lazy_continuation: false,
3702 };
3703 for (content, lazy_lines) in [
3704 ("- Item\n lazy continuation\n- another item\n", vec![2]),
3705 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
3706 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
3707 ] {
3708 let warnings = lint_with_config(content, config.clone());
3709 assert!(
3710 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
3711 "{content:?}: got {warnings:?}"
3712 );
3713 assert_eq!(
3714 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3715 lazy_lines,
3716 "{content:?}: got {warnings:?}"
3717 );
3718 }
3719 }
3720
3721 #[test]
3722 fn test_structural_line_at_short_indent_ends_the_list() {
3723 for (content, expected) in [
3727 ("1. item\n ---\n", "1. item\n\n ---\n"),
3728 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
3729 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3730 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3731 ] {
3732 let warnings = lint(content);
3733 assert_eq!(
3734 warnings
3735 .iter()
3736 .map(|w| (w.line, w.message.as_str()))
3737 .collect::<Vec<_>>(),
3738 vec![(1, "List should be followed by blank line")],
3739 "{content:?}: got {warnings:?}"
3740 );
3741 assert_eq!(fix(content), expected, "{content:?}");
3742 }
3743 }
3744
3745 #[test]
3746 fn test_html_block_at_short_indent_ends_the_list() {
3747 for (content, expected) in [
3754 (
3755 "- item\n<script>\nx\n</script>\n- next\n",
3756 "- item\n\n<script>\nx\n</script>\n\n- next\n",
3757 ),
3758 (
3759 "- item\n <script>\n x\n </script>\n- next\n",
3760 "- item\n\n <script>\n x\n </script>\n\n- next\n",
3761 ),
3762 (
3763 "- item\n <pre>\n x\n </pre>\n- next\n",
3764 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
3765 ),
3766 (
3767 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
3768 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
3769 ),
3770 (
3771 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
3772 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
3773 ),
3774 ] {
3775 let warnings = lint(content);
3776 assert_eq!(
3777 warnings
3778 .iter()
3779 .map(|w| (w.line, w.message.as_str()))
3780 .collect::<Vec<_>>(),
3781 vec![
3782 (1, "List should be followed by blank line"),
3783 (5, "List should be preceded by blank line"),
3784 ],
3785 "{content:?}: got {warnings:?}"
3786 );
3787 assert_eq!(fix(content), expected, "{content:?}");
3788 }
3789 }
3790
3791 #[test]
3792 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
3793 for content in [
3805 "100. item\n <div>\n101. next\n",
3806 "> 100. item\n> <div>\n> 101. next\n",
3807 "100. item\n <div>\ntext\n101. next\n",
3808 "- item\n<div.class>\n- next\n",
3809 "> - item\n> <div.class>\n> - next\n",
3810 "100. item\n\t<div>\n101. next\n",
3811 "- item\n \t<div>\n- next\n",
3812 "> - item\n> \t<div>\n> - next\n",
3813 "> - item\n>\t<div>\n> - next\n",
3814 "> 100. item\n> \t<div>\n> 101. next\n",
3815 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
3816 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
3817 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
3818 ] {
3819 let warnings = lint(content);
3820 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3821 assert_eq!(fix(content), content, "{content:?}");
3822 }
3823
3824 for (content, last_item_line) in [
3828 ("100. item\n <div>\n101. next\n", 1),
3829 ("> 100. item\n> <div>\n> 101. next\n", 1),
3830 ("- item\n <div>\n- next\n", 1),
3831 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
3832 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
3833 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
3834 ] {
3835 let warnings = lint(content);
3836 assert_eq!(
3837 warnings
3838 .iter()
3839 .map(|w| (w.line, w.message.as_str()))
3840 .collect::<Vec<_>>(),
3841 vec![(last_item_line, "List should be followed by blank line")],
3842 "{content:?}: got {warnings:?}"
3843 );
3844 }
3845 }
3846
3847 #[test]
3848 fn test_tab_indented_nested_list_stays_inside_its_item() {
3849 for content in [
3858 "* item text\n\t1. nested\n\t more\n",
3859 "* item text\n\tcontinuation\n\t1. nested\n",
3860 "1. item text\n\t- nested\n",
3861 "> * item text\n>\t1. nested\n",
3862 "> * item text\n> \t1. nested\n",
3863 "* item text\n\tcontinuation\n\t- nested\n",
3864 ] {
3865 let warnings = lint(content);
3866 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3867 assert_eq!(fix(content), content, "{content:?}");
3868 }
3869
3870 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
3873 let warnings = lint(content);
3874 assert_eq!(
3875 warnings
3876 .iter()
3877 .map(|w| (w.line, w.message.as_str()))
3878 .collect::<Vec<_>>(),
3879 vec![
3880 (1, "List should be followed by blank line"),
3881 (2, "List should be preceded by blank line"),
3882 ],
3883 "{content:?}: got {warnings:?}"
3884 );
3885 }
3886 }
3887
3888 #[test]
3889 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
3890 for (content, expected) in [
3895 (
3896 "- item\n<div>\nx\n</div>\n- next\n",
3897 "- item\n\n<div>\nx\n</div>\n- next\n",
3898 ),
3899 (
3900 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
3901 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
3902 ),
3903 ] {
3904 let warnings = lint(content);
3905 assert_eq!(
3906 warnings
3907 .iter()
3908 .map(|w| (w.line, w.message.as_str()))
3909 .collect::<Vec<_>>(),
3910 vec![(1, "List should be followed by blank line")],
3911 "{content:?}: got {warnings:?}"
3912 );
3913 assert_eq!(fix(content), expected, "{content:?}");
3914 }
3915 }
3916
3917 #[test]
3918 fn test_html_block_at_content_column_is_item_content() {
3919 for content in [
3922 "- item\n <script>\n x\n </script>\n- next\n",
3923 "- item\n <div>\n x\n </div>\n- next\n",
3924 "1. item\n <pre>\n x\n </pre>\n2. next\n",
3925 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
3926 ] {
3927 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
3928 assert_eq!(fix(content), content, "{content:?}");
3929 }
3930 }
3931}