1use crate::lint_context::LazyContLine;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote, parse_blockquote_prefix};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::pandoc;
6use crate::utils::range_utils::calculate_line_range;
7use regex::Regex;
8use std::sync::LazyLock;
9
10mod md032_config;
11pub(super) use md032_config::MD032Config;
12
13static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
15
16fn is_thematic_break(line: &str) -> bool {
19 if calculate_indentation_width_default(line) > 3 {
21 return false;
22 }
23
24 let trimmed = line.trim();
25 if trimmed.len() < 3 {
26 return false;
27 }
28
29 let chars: Vec<char> = trimmed.chars().collect();
30 let first_non_space = chars.iter().find(|&&c| c != ' ');
31
32 if let Some(&marker) = first_non_space {
33 if marker != '-' && marker != '*' && marker != '_' {
34 return false;
35 }
36 let marker_count = chars.iter().filter(|&&c| c == marker).count();
37 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
38 marker_count >= 3 && other_count == 0
39 } else {
40 false
41 }
42}
43
44#[derive(Debug, Clone, Default)]
114pub struct MD032BlanksAroundLists {
115 config: MD032Config,
116}
117
118impl MD032BlanksAroundLists {
119 pub fn from_config_struct(config: MD032Config) -> Self {
120 Self { config }
121 }
122}
123
124impl MD032BlanksAroundLists {
125 fn should_require_blank_line_before(
127 ctx: &crate::lint_context::LintContext,
128 prev_line_num: usize,
129 current_line_num: usize,
130 ) -> bool {
131 if ctx
133 .line_info(prev_line_num)
134 .is_some_and(|info| info.in_code_block || info.in_front_matter)
135 {
136 return true;
137 }
138
139 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
141 return false;
142 }
143
144 true
146 }
147
148 fn is_nested_list(
150 ctx: &crate::lint_context::LintContext,
151 prev_line_num: usize, current_line_num: usize, ) -> bool {
154 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
156 let current_line = &ctx.lines[current_line_num - 1];
157 if current_line.indent >= 2 {
158 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
160 let prev_line = &ctx.lines[prev_line_num - 1];
161 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
163 return true;
164 }
165 }
166 }
167 }
168 false
169 }
170
171 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
174 ctx.lines
175 .get(line_num.saturating_sub(1))
176 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
177 }
178
179 fn is_transparent_div_marker(ctx: &crate::lint_context::LintContext, info: &crate::lint_context::LineInfo) -> bool {
185 if !ctx.flavor.is_pandoc_compatible() {
186 return false;
187 }
188 let trimmed = info.content(ctx.content).trim();
189 pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed)
190 }
191
192 fn is_reportable_lazy_line(
195 ctx: &crate::lint_context::LintContext,
196 list_blocks: &[(usize, usize, String)],
197 line_num: usize,
198 ) -> bool {
199 let is_within_block = list_blocks
200 .iter()
201 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
202 if !is_within_block {
203 return false;
204 }
205 ctx.lines
206 .get(line_num.saturating_sub(1))
207 .is_some_and(|info| !Self::is_transparent_div_marker(ctx, info))
208 }
209
210 fn calculate_lazy_continuation_fix(
213 ctx: &crate::lint_context::LintContext,
214 line_num: usize,
215 lazy_info: &LazyContLine,
216 ) -> Option<Fix> {
217 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
218 let line_content = line_info.content(ctx.content);
219
220 if lazy_info.blockquote_level == 0 {
221 let start_byte = line_info.byte_offset;
223 let end_byte = start_byte + lazy_info.current_indent;
224 let replacement = " ".repeat(lazy_info.expected_indent);
225
226 Some(Fix::new(start_byte..end_byte, replacement))
227 } else {
228 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
230 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
231 if prefix_byte_len == 0 {
232 return None;
233 }
234
235 let current_indent = after_bq.len() - after_bq.trim_start().len();
236 let start_byte = line_info.byte_offset + prefix_byte_len;
237 let end_byte = start_byte + current_indent;
238 let replacement = " ".repeat(lazy_info.expected_indent);
239
240 Some(Fix::new(start_byte..end_byte, replacement))
241 }
242 }
243
244 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
247 if lazy_info.blockquote_level == 0 {
248 let content = line.trim_start();
250 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
251 } else {
252 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
254 let prefix_len = line.len().saturating_sub(after_bq.len());
255 if prefix_len == 0 {
256 return line.to_string();
257 }
258
259 let prefix = &line[..prefix_len];
260 let rest = after_bq.trim_start();
261 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
262 }
263 }
264
265 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
273 for line_num in (1..before_line).rev() {
274 let idx = line_num - 1;
275 if let Some(info) = ctx.lines.get(idx) {
276 if is_blank_in_context(info.content(ctx.content)) {
279 return (line_num, true);
280 }
281 if info.in_html_comment || info.in_mdx_comment {
283 continue;
284 }
285 if Self::is_transparent_div_marker(ctx, info) {
287 continue;
288 }
289 return (line_num, is_blank_in_context(info.content(ctx.content)));
290 }
291 }
292 (0, true)
294 }
295
296 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
303 let num_lines = ctx.lines.len();
304 for line_num in (after_line + 1)..=num_lines {
305 let idx = line_num - 1;
306 if let Some(info) = ctx.lines.get(idx) {
307 if is_blank_in_context(info.content(ctx.content)) {
310 return (line_num, true);
311 }
312 if info.in_html_comment || info.in_mdx_comment {
314 continue;
315 }
316 if Self::is_transparent_div_marker(ctx, info) {
318 continue;
319 }
320 return (line_num, is_blank_in_context(info.content(ctx.content)));
321 }
322 }
323 (0, true)
325 }
326
327 fn block_ends_in_comment_line(lines: &[&str], end_line: usize) -> bool {
334 lines.get(end_line - 1).is_some_and(|line| is_blank_in_context(line))
335 }
336
337 fn is_following_content_excluded(ctx: &crate::lint_context::LintContext, line_num: usize, prefix: &str) -> bool {
340 ctx.line_info(line_num).is_some_and(|info| {
341 info.in_front_matter
342 || (info.in_code_block
343 && effective_indent_in_blockquote(
344 info.content(ctx.content),
345 prefix.chars().filter(|&c| c == '>').count(),
346 info.indent,
347 ) >= 2)
348 })
349 }
350
351 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
353 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
354
355 for block in &ctx.list_blocks {
356 if ctx
358 .line_info(block.start_line)
359 .is_some_and(|info| info.in_footnote_definition)
360 {
361 continue;
362 }
363
364 let mut segments: Vec<(usize, usize)> = Vec::new();
370 let mut current_start = block.start_line;
371 let mut prev_item_line = 0;
372
373 let get_blockquote_level = |line_num: usize| -> usize {
375 if line_num == 0 || line_num > ctx.lines.len() {
376 return 0;
377 }
378 let line_content = ctx.lines[line_num - 1].content(ctx.content);
379 parse_blockquote_prefix(line_content).map_or(0, |bq| bq.nesting_level)
380 };
381
382 let mut prev_bq_level = 0;
383
384 for &item_line in &block.item_lines {
385 let current_bq_level = get_blockquote_level(item_line);
386
387 if prev_item_line > 0 {
388 let blockquote_level_changed = prev_bq_level != current_bq_level;
390
391 let mut has_standalone_code_fence = false;
394
395 let min_indent_for_content = if block.is_ordered {
397 3 } else {
401 2 };
404
405 for check_line in (prev_item_line + 1)..item_line {
406 if check_line - 1 < ctx.lines.len() {
407 let line = &ctx.lines[check_line - 1];
408 let line_content = line.content(ctx.content);
409 if line.in_code_block
410 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
411 {
412 if line.indent < min_indent_for_content {
415 has_standalone_code_fence = true;
416 break;
417 }
418 }
419 }
420 }
421
422 if has_standalone_code_fence || blockquote_level_changed {
423 segments.push((current_start, prev_item_line));
425 current_start = item_line;
426 }
427 }
428 prev_item_line = item_line;
429 prev_bq_level = current_bq_level;
430 }
431
432 if prev_item_line > 0 {
435 segments.push((current_start, prev_item_line));
436 }
437
438 let has_code_fence_splits = segments.len() > 1 && {
440 let mut found_fence = false;
442 for i in 0..segments.len() - 1 {
443 let seg_end = segments[i].1;
444 let next_start = segments[i + 1].0;
445 for check_line in (seg_end + 1)..next_start {
447 if check_line - 1 < ctx.lines.len() {
448 let line = &ctx.lines[check_line - 1];
449 let line_content = line.content(ctx.content);
450 if line.in_code_block
451 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
452 {
453 found_fence = true;
454 break;
455 }
456 }
457 }
458 if found_fence {
459 break;
460 }
461 }
462 found_fence
463 };
464
465 for (start, end) in &segments {
467 let mut actual_end = *end;
469
470 if !has_code_fence_splits && *end < block.end_line {
473 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
475
476 let min_continuation_indent = if block_bq_level > 0 {
479 if block.is_ordered {
481 block.max_marker_width
482 } else {
483 2 }
485 } else {
486 ctx.lines
487 .get(*end - 1)
488 .and_then(|line_info| line_info.list_item.as_ref())
489 .map_or(2, |item| item.content_column)
490 };
491
492 for check_line in (*end + 1)..=block.end_line {
493 if check_line - 1 < ctx.lines.len() {
494 let line = &ctx.lines[check_line - 1];
495 let line_content = line.content(ctx.content);
496 if block.item_lines.contains(&check_line) || line.heading.is_some() {
500 break;
501 }
502 if line.in_code_block {
504 break;
505 }
506
507 let effective_indent =
509 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
510
511 if effective_indent >= min_continuation_indent {
513 actual_end = check_line;
514 }
515 else if !line.is_blank
520 && line.heading.is_none()
521 && !block.item_lines.contains(&check_line)
522 && !is_thematic_break(line_content)
523 {
524 actual_end = check_line;
526 } else if !line.is_blank {
527 break;
529 }
530 }
531 }
532 }
533
534 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
535 }
536 }
537
538 blocks.retain(|(start, end, _)| {
540 let all_in_comment = (*start..=*end).all(|line_num| {
542 ctx.lines
543 .get(line_num - 1)
544 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
545 });
546 !all_in_comment
547 });
548
549 blocks
550 }
551
552 fn perform_checks(
553 &self,
554 ctx: &crate::lint_context::LintContext,
555 lines: &[&str],
556 list_blocks: &[(usize, usize, String)],
557 ) -> Vec<LintWarning> {
558 let mut warnings = Vec::new();
559 let num_lines = lines.len();
560
561 for (line_idx, line) in lines.iter().enumerate() {
564 let line_num = line_idx + 1;
565
566 let is_in_list = list_blocks
568 .iter()
569 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
570 if is_in_list {
571 continue;
572 }
573
574 if ctx.line_info(line_num).is_some_and(|info| {
576 info.in_code_block
577 || info.in_front_matter
578 || info.in_html_comment
579 || info.in_mdx_comment
580 || info.in_html_block
581 || info.in_jsx_block
582 }) {
583 continue;
584 }
585
586 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
588 if line_idx > 0 {
590 let prev_line = lines[line_idx - 1];
591 let prev_is_blank = is_blank_in_context(prev_line);
592 let prev_line_info = ctx.line_info(line_idx);
593 let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
594
595 let prev_in_mkdocs_container =
611 prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
612 let continues_stale_container_list = prev_in_mkdocs_container && {
613 let item_indent = calculate_indentation_width_default(line);
614 let mut found_marker = false;
615 for j in (0..line_idx).rev() {
616 let in_container = ctx
617 .line_info(j + 1)
618 .is_some_and(|info| info.in_admonition || info.in_content_tab);
619 if !in_container {
620 break;
621 }
622 let candidate = lines[j];
623 if is_blank_in_context(candidate) {
624 continue;
625 }
626 let candidate_indent = calculate_indentation_width_default(candidate);
627 if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
628 && candidate_indent == item_indent
629 {
630 found_marker = true;
631 break;
632 }
633 if candidate_indent <= item_indent {
634 break;
635 }
636 }
637 found_marker
638 };
639
640 let prev_trimmed = prev_line.trim();
645 let is_sentence_continuation = continues_stale_container_list
646 || (!prev_is_blank
647 && !prev_trimmed.is_empty()
648 && !prev_trimmed.ends_with('.')
649 && !prev_trimmed.ends_with('!')
650 && !prev_trimmed.ends_with('?')
651 && !prev_trimmed.ends_with(':')
652 && !prev_trimmed.ends_with(';')
653 && !prev_trimmed.ends_with('>')
654 && !prev_trimmed.ends_with('-')
655 && !prev_trimmed.ends_with('*'));
656
657 if prev_is_blank || !is_sentence_continuation {
658 if !prev_is_blank && !prev_excluded {
659 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
661
662 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
663 warnings.push(LintWarning {
664 line: start_line,
665 column: start_col,
666 end_line,
667 end_column: end_col,
668 severity: Severity::Warning,
669 rule_name: Some(self.name().to_string()),
670 message: "Ordered list starting with non-1 should be preceded by blank line"
671 .to_string(),
672 fix: Some(Fix::new(
673 ctx.line_column_byte_range_with_length(line_num, 1, 0),
674 format!("{bq_prefix}\n"),
675 )),
676 });
677 }
678
679 if line_idx + 1 < num_lines {
682 let next_line = lines[line_idx + 1];
683 let next_is_blank = is_blank_in_context(next_line);
684 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
685
686 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
687 let next_trimmed = next_line.trim_start();
691 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
692 || next_line.starts_with("1. ")
693 || (next_line.len() > next_trimmed.len()
694 && !next_trimmed.starts_with("- ")
695 && !next_trimmed.starts_with("* ")
696 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
699 let (start_line, start_col, end_line, end_col) =
700 calculate_line_range(line_num, line);
701 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
702 warnings.push(LintWarning {
703 line: start_line,
704 column: start_col,
705 end_line,
706 end_column: end_col,
707 severity: Severity::Warning,
708 rule_name: Some(self.name().to_string()),
709 message: "List should be followed by blank line".to_string(),
710 fix: Some(Fix::new(
711 ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
712 format!("{bq_prefix}\n"),
713 )),
714 });
715 }
716 }
717 }
718 }
719 }
720 }
721 }
722
723 for &(start_line, end_line, ref prefix) in list_blocks {
724 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
725 if ctx
727 .line_info(start_line)
728 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
729 {
730 continue;
731 }
732
733 if start_line > 1 {
734 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
736
737 if !has_blank_separation && content_line > 0 {
739 let prev_line_str = lines[content_line - 1];
740 let is_prev_excluded = ctx
741 .line_info(content_line)
742 .is_some_and(|info| info.in_code_block || info.in_front_matter);
743 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
744 let prefixes_match = prev_bq_level == block_bq_level;
745
746 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
749 if !is_prev_excluded && prefixes_match && should_require {
750 let (start_line, start_col, end_line, end_col) =
752 calculate_line_range(start_line, lines[start_line - 1]);
753
754 warnings.push(LintWarning {
755 line: start_line,
756 column: start_col,
757 end_line,
758 end_column: end_col,
759 severity: Severity::Warning,
760 rule_name: Some(self.name().to_string()),
761 message: "List should be preceded by blank line".to_string(),
762 fix: Some(Fix::new(
763 ctx.line_column_byte_range_with_length(start_line, 1, 0),
764 format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
765 )),
766 });
767 }
768 }
769 }
770
771 if end_line < num_lines && !Self::block_ends_in_comment_line(lines, end_line) {
772 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
774
775 if !has_blank_separation && content_line > 0 {
777 let next_line_str = lines[content_line - 1];
778 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
781 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
782
783 let end_line_str = lines[end_line - 1];
788 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
789 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
790
791 let prefixes_match = next_line_bq_level == block_bq_level;
792
793 if !is_next_excluded && prefixes_match && !exits_blockquote {
796 let (start_line_last, start_col_last, end_line_last, end_col_last) =
798 calculate_line_range(end_line, lines[end_line - 1]);
799
800 warnings.push(LintWarning {
801 line: start_line_last,
802 column: start_col_last,
803 end_line: end_line_last,
804 end_column: end_col_last,
805 severity: Severity::Warning,
806 rule_name: Some(self.name().to_string()),
807 message: "List should be followed by blank line".to_string(),
808 fix: Some(Fix::new(
809 ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
810 format!("{}\n", ctx.blockquote_prefix_for_blank_line(end_line - 1)),
811 )),
812 });
813 }
814 }
815 }
816 }
817 warnings
818 }
819}
820
821impl Rule for MD032BlanksAroundLists {
822 fn name(&self) -> &'static str {
823 "MD032"
824 }
825
826 fn description(&self) -> &'static str {
827 "Lists should be surrounded by blank lines"
828 }
829
830 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
831 let lines = ctx.raw_lines();
832 if lines.is_empty() {
834 return Ok(Vec::new());
835 }
836
837 let list_blocks = self.convert_list_blocks(ctx);
838
839 if list_blocks.is_empty() {
840 return Ok(Vec::new());
841 }
842
843 let mut warnings = self.perform_checks(ctx, lines, &list_blocks);
844
845 if !self.config.allow_lazy_continuation {
850 let lazy_cont_lines = ctx.lazy_continuation_lines();
851
852 for lazy_info in lazy_cont_lines.iter() {
853 let line_num = lazy_info.line_num;
854
855 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
859 continue;
860 }
861
862 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
864 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
865
866 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
868 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
869 } else {
870 None
871 };
872
873 warnings.push(LintWarning {
874 line: start_line,
875 column: start_col,
876 end_line,
877 end_column: end_col,
878 severity: Severity::Warning,
879 rule_name: Some(self.name().to_string()),
880 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
881 fix,
882 });
883 }
884 }
885
886 Ok(warnings)
887 }
888
889 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
890 Ok(self.fix_with_structure_impl(ctx))
891 }
892
893 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
894 ctx.content.is_empty() || ctx.list_blocks.is_empty()
897 }
898
899 fn category(&self) -> RuleCategory {
900 RuleCategory::List
901 }
902
903 fn as_any(&self) -> &dyn std::any::Any {
904 self
905 }
906
907 crate::impl_rule_config_methods!(MD032Config);
908}
909
910impl MD032BlanksAroundLists {
911 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
913 let lines = ctx.raw_lines();
914 let num_lines = lines.len();
915 if num_lines == 0 {
916 return String::new();
917 }
918
919 let list_blocks = self.convert_list_blocks(ctx);
920 if list_blocks.is_empty() {
921 return ctx.content.to_string();
922 }
923
924 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
927 if !self.config.allow_lazy_continuation {
928 let lazy_cont_lines = ctx.lazy_continuation_lines();
929 for lazy_info in lazy_cont_lines.iter() {
930 let line_num = lazy_info.line_num;
931 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
933 continue;
934 }
935 if !Self::should_apply_lazy_fix(ctx, line_num)
937 || ctx.inline_config().is_rule_disabled(self.name(), line_num)
938 {
939 continue;
940 }
941 lazy_fixes.insert(line_num, lazy_info.clone());
942 }
943 }
944
945 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
946
947 for &(start_line, end_line, ref prefix) in &list_blocks {
949 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
950 if ctx
952 .line_info(start_line)
953 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
954 {
955 continue;
956 }
957
958 if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
960 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
962
963 if !has_blank_separation && content_line > 0 {
965 let prev_line_str = lines[content_line - 1];
966 let is_prev_excluded = ctx
967 .line_info(content_line)
968 .is_some_and(|info| info.in_code_block || info.in_front_matter);
969 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
970
971 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
972 if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
974 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
976 insertions.insert(start_line, bq_prefix);
977 }
978 }
979 }
980
981 if end_line < num_lines
983 && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
984 && !Self::block_ends_in_comment_line(lines, end_line)
985 {
986 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
988
989 if !has_blank_separation && content_line > 0 {
991 let next_line_str = lines[content_line - 1];
992 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
995 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
996
997 let end_line_str = lines[end_line - 1];
999 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1000 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1001
1002 if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1005 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
1007 insertions.insert(end_line + 1, bq_prefix);
1008 }
1009 }
1010 }
1011 }
1012
1013 if insertions.is_empty() && lazy_fixes.is_empty() {
1014 return ctx.content.to_string();
1015 }
1016
1017 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1019 for (i, line) in lines.iter().enumerate() {
1020 let current_line_num = i + 1;
1021 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1022 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1023 {
1024 result_lines.push(prefix_to_insert.clone());
1025 }
1026
1027 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1029 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1030 result_lines.push(fixed_line);
1031 } else {
1032 result_lines.push(line.to_string());
1033 }
1034 }
1035
1036 let line_ending = crate::utils::detect_line_ending(ctx.content);
1038 let mut result = result_lines.join(line_ending);
1039 if ctx.content.ends_with('\n') {
1040 result.push_str(line_ending);
1041 }
1042 result
1043 }
1044}
1045
1046fn is_blank_in_context(line: &str) -> bool {
1049 parse_blockquote_prefix(line)
1050 .map_or(line, |bq| bq.content)
1051 .trim()
1052 .is_empty()
1053 || crate::utils::blank_lines::is_blank_or_comment_only(line)
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use crate::lint_context::LintContext;
1060 use crate::rule::Rule;
1061
1062 fn lint(content: &str) -> Vec<LintWarning> {
1063 let rule = MD032BlanksAroundLists::default();
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 rule.check(&ctx).expect("Lint check failed")
1066 }
1067
1068 fn fix(content: &str) -> String {
1069 let rule = MD032BlanksAroundLists::default();
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 rule.fix(&ctx).expect("Lint fix failed")
1072 }
1073
1074 #[test]
1075 fn test_spaced_nested_blockquotes_list_separation() {
1076 for (list_prefix, surrounding_prefix) in [
1077 ("> >", "> >"),
1078 ("> >", "> >"),
1079 ("> > >", "> > >"),
1080 ("> >", ">>"),
1081 (">>", "> >"),
1082 ] {
1083 let content = format!(
1084 "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1085 );
1086 let expected = format!(
1087 "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1088 );
1089 let warnings = lint(&content);
1090 assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1091 assert!(warnings.iter().all(|warning| warning.line == 2));
1092 let mut edited = content.clone();
1093 for warning in warnings.iter().rev() {
1094 let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1095 edited.replace_range(edit.range.clone(), &edit.replacement);
1096 }
1097 assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1098 assert_eq!(fix(&content), expected);
1099 assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1100 assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1101 }
1102 }
1103
1104 #[test]
1105 fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1106 for content in [
1107 "> > - item\n> > ```\n> > code\n> > ```\n",
1108 "> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
1109 "> > - item\n> ~~~\n> code\n> ~~~\n",
1110 "> > - item\n~~~\ncode\n~~~\n",
1111 "> > - item\n>> - next item\n",
1112 ] {
1113 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1114 assert_eq!(fix(content), content);
1115 }
1116 }
1117
1118 #[test]
1119 fn test_fix_separates_list_from_standalone_code_fence() {
1120 for (content, expected) in [
1121 (
1122 "# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
1123 "# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
1124 ),
1125 ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1126 ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1127 (
1128 ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1129 ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1130 ),
1131 ] {
1132 let warnings = lint(content);
1133 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1134 assert_eq!(warnings[0].message, "List should be followed by blank line");
1135 let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1136 let mut edited = content.to_string();
1137 edited.replace_range(edit.range.clone(), &edit.replacement);
1138 assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1139 assert_eq!(fix(content), expected, "{content:?}");
1140 assert!(lint(expected).is_empty(), "{expected:?}");
1141 assert_eq!(fix(expected), expected, "Fix must be idempotent");
1142 }
1143 }
1144
1145 #[test]
1146 fn test_fix_preserves_code_fence_inside_list_item() {
1147 for content in [
1148 "- item\n ```\n code\n ```\n",
1149 "1. item\n ~~~\n code\n ~~~\n",
1150 "> - item\n> ```\n> code\n> ```\n",
1151 ] {
1152 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1153 assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1154 }
1155 }
1156
1157 #[test]
1158 fn test_fix_does_not_split_item_before_different_list_type() {
1159 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1163 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1164
1165 let warnings = lint(content);
1168 assert_eq!(warnings.len(), 2);
1169 assert_eq!(warnings[0].line, 2);
1170 assert_eq!(warnings[1].line, 3);
1171 }
1172
1173 #[test]
1174 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1175 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1176 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1177 }
1178
1179 #[test]
1180 fn test_fix_keeps_lazy_continuation_with_its_item() {
1181 let content = "- alpha beta\nlazy\n1. ordered item\n";
1185 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1186
1187 let warnings = lint(content);
1188 assert_eq!(warnings.len(), 2);
1189 assert_eq!(warnings[0].line, 2);
1190 assert_eq!(warnings[1].line, 3);
1191 }
1192
1193 #[test]
1194 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1195 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1196 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1197 }
1198
1199 #[test]
1200 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1201 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1204 allow_lazy_continuation: false,
1205 });
1206 let content = "- alpha beta\nlazy\n1. ordered item\n";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1209 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1210 }
1211
1212 #[test]
1213 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1214 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1218 allow_lazy_continuation: false,
1219 });
1220 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1222 let warnings = rule.check(&ctx).expect("Lint check failed");
1223 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1224 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1225 assert_eq!(fixed, content);
1226 }
1227
1228 #[test]
1229 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1230 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1234 allow_lazy_continuation: false,
1235 });
1236 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1238 let warnings = rule.check(&ctx).expect("Lint check failed");
1239 assert_eq!(
1240 warnings.len(),
1241 1,
1242 "Expected one lazy-continuation warning, got: {warnings:?}"
1243 );
1244 assert_eq!(warnings[0].line, 3);
1245 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1246 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1247 }
1248
1249 #[test]
1250 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1251 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1254 allow_lazy_continuation: false,
1255 });
1256 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1259 assert_eq!(fixed, "Intro\n\n- List item 1\n- List item 2\n :::\n");
1260 }
1261
1262 fn check_warnings_have_fixes(content: &str) {
1264 let warnings = lint(content);
1265 for warning in &warnings {
1266 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1267 }
1268 }
1269
1270 #[test]
1271 fn test_list_at_start() {
1272 let content = "- Item 1\n- Item 2\nText";
1275 let warnings = lint(content);
1276 assert_eq!(
1277 warnings.len(),
1278 0,
1279 "Trailing text is lazy continuation per CommonMark - no warning expected"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_list_at_end() {
1285 let content = "Text\n- Item 1\n- Item 2";
1286 let warnings = lint(content);
1287 assert_eq!(
1288 warnings.len(),
1289 1,
1290 "Expected 1 warning for list at end without preceding blank line"
1291 );
1292 assert_eq!(
1293 warnings[0].line, 2,
1294 "Warning should be on the first line of the list (line 2)"
1295 );
1296 assert!(warnings[0].message.contains("preceded by blank line"));
1297
1298 check_warnings_have_fixes(content);
1300
1301 let fixed_content = fix(content);
1302 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1303
1304 let warnings_after_fix = lint(&fixed_content);
1306 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1307 }
1308
1309 #[test]
1310 fn test_list_in_middle() {
1311 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1314 let warnings = lint(content);
1315 assert_eq!(
1316 warnings.len(),
1317 1,
1318 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1319 );
1320 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1321 assert!(warnings[0].message.contains("preceded by blank line"));
1322
1323 check_warnings_have_fixes(content);
1325
1326 let fixed_content = fix(content);
1327 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1328
1329 let warnings_after_fix = lint(&fixed_content);
1331 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1332 }
1333
1334 #[test]
1335 fn test_correct_spacing() {
1336 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1337 let warnings = lint(content);
1338 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1339
1340 let fixed_content = fix(content);
1341 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1342 }
1343
1344 #[test]
1345 fn test_list_with_content() {
1346 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1349 let warnings = lint(content);
1350 assert_eq!(
1351 warnings.len(),
1352 1,
1353 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1354 );
1355 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1356 assert!(warnings[0].message.contains("preceded by blank line"));
1357
1358 check_warnings_have_fixes(content);
1360
1361 let fixed_content = fix(content);
1362 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1363 assert_eq!(
1364 fixed_content, expected_fixed,
1365 "Fix did not produce the expected output. Got:\n{fixed_content}"
1366 );
1367
1368 let warnings_after_fix = lint(&fixed_content);
1370 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1371 }
1372
1373 #[test]
1374 fn test_nested_list() {
1375 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1377 let warnings = lint(content);
1378 assert_eq!(
1379 warnings.len(),
1380 1,
1381 "Nested list block needs preceding blank only. Got: {warnings:?}"
1382 );
1383 assert_eq!(warnings[0].line, 2);
1384 assert!(warnings[0].message.contains("preceded by blank line"));
1385
1386 check_warnings_have_fixes(content);
1388
1389 let fixed_content = fix(content);
1390 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1391
1392 let warnings_after_fix = lint(&fixed_content);
1394 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1395 }
1396
1397 #[test]
1398 fn test_list_with_internal_blanks() {
1399 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1401 let warnings = lint(content);
1402 assert_eq!(
1403 warnings.len(),
1404 1,
1405 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1406 );
1407 assert_eq!(warnings[0].line, 2);
1408 assert!(warnings[0].message.contains("preceded by blank line"));
1409
1410 check_warnings_have_fixes(content);
1412
1413 let fixed_content = fix(content);
1414 assert_eq!(
1415 fixed_content,
1416 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1417 );
1418
1419 let warnings_after_fix = lint(&fixed_content);
1421 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1422 }
1423
1424 #[test]
1425 fn test_ignore_code_blocks() {
1426 let content = "```\n- Not a list item\n```\nText";
1427 let warnings = lint(content);
1428 assert_eq!(warnings.len(), 0);
1429 let fixed_content = fix(content);
1430 assert_eq!(fixed_content, content);
1431 }
1432
1433 #[test]
1434 fn test_ignore_front_matter() {
1435 let content = "---\ntitle: Test\n---\n- List Item\nText";
1437 let warnings = lint(content);
1438 assert_eq!(
1439 warnings.len(),
1440 0,
1441 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1442 );
1443
1444 let fixed_content = fix(content);
1446 assert_eq!(fixed_content, content, "No changes when no warnings");
1447 }
1448
1449 #[test]
1450 fn test_multiple_lists() {
1451 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1456 let warnings = lint(content);
1457 assert!(
1459 !warnings.is_empty(),
1460 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1461 );
1462
1463 check_warnings_have_fixes(content);
1465
1466 let fixed_content = fix(content);
1467 let warnings_after_fix = lint(&fixed_content);
1469 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1470 }
1471
1472 #[test]
1473 fn test_adjacent_lists() {
1474 let content = "- List 1\n\n* List 2";
1475 let warnings = lint(content);
1476 assert_eq!(warnings.len(), 0);
1477 let fixed_content = fix(content);
1478 assert_eq!(fixed_content, content);
1479 }
1480
1481 #[test]
1482 fn test_list_in_blockquote() {
1483 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1485 let warnings = lint(content);
1486 assert_eq!(
1487 warnings.len(),
1488 1,
1489 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1490 );
1491 assert_eq!(warnings[0].line, 2);
1492
1493 check_warnings_have_fixes(content);
1495
1496 let fixed_content = fix(content);
1497 assert_eq!(
1499 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1500 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1501 );
1502
1503 let warnings_after_fix = lint(&fixed_content);
1505 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1506 }
1507
1508 #[test]
1509 fn test_ordered_list() {
1510 let content = "Text\n1. Item 1\n2. Item 2\nText";
1512 let warnings = lint(content);
1513 assert_eq!(warnings.len(), 1);
1514
1515 check_warnings_have_fixes(content);
1517
1518 let fixed_content = fix(content);
1519 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1520
1521 let warnings_after_fix = lint(&fixed_content);
1523 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1524 }
1525
1526 #[test]
1527 fn test_no_double_blank_fix() {
1528 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1531 assert_eq!(
1532 warnings.len(),
1533 0,
1534 "Should have no warnings - properly preceded, trailing is lazy"
1535 );
1536
1537 let fixed_content = fix(content);
1538 assert_eq!(
1539 fixed_content, content,
1540 "No fix needed when no warnings. Got:\n{fixed_content}"
1541 );
1542
1543 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1545 assert_eq!(warnings2.len(), 1);
1546 if !warnings2.is_empty() {
1547 assert_eq!(
1548 warnings2[0].line, 2,
1549 "Warning line for missing blank before should be the first line of the block"
1550 );
1551 }
1552
1553 check_warnings_have_fixes(content2);
1555
1556 let fixed_content2 = fix(content2);
1557 assert_eq!(
1558 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1559 "Fix added extra blank before. Got:\n{fixed_content2}"
1560 );
1561 }
1562
1563 #[test]
1564 fn test_empty_input() {
1565 let content = "";
1566 let warnings = lint(content);
1567 assert_eq!(warnings.len(), 0);
1568 let fixed_content = fix(content);
1569 assert_eq!(fixed_content, "");
1570 }
1571
1572 #[test]
1573 fn test_only_list() {
1574 let content = "- Item 1\n- Item 2";
1575 let warnings = lint(content);
1576 assert_eq!(warnings.len(), 0);
1577 let fixed_content = fix(content);
1578 assert_eq!(fixed_content, content);
1579 }
1580
1581 #[test]
1584 fn test_fix_complex_nested_blockquote() {
1585 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1587 let warnings = lint(content);
1588 assert_eq!(
1589 warnings.len(),
1590 1,
1591 "Should warn for missing preceding blank only. Got: {warnings:?}"
1592 );
1593
1594 check_warnings_have_fixes(content);
1596
1597 let fixed_content = fix(content);
1598 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1600 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1601
1602 let warnings_after_fix = lint(&fixed_content);
1603 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1604 }
1605
1606 #[test]
1607 fn test_fix_mixed_list_markers() {
1608 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1611 let warnings = lint(content);
1612 assert!(
1614 !warnings.is_empty(),
1615 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1616 );
1617
1618 check_warnings_have_fixes(content);
1620
1621 let fixed_content = fix(content);
1622 assert!(
1624 fixed_content.contains("Text\n\n-"),
1625 "Fix should add blank line before first list item"
1626 );
1627
1628 let warnings_after_fix = lint(&fixed_content);
1630 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1631 }
1632
1633 #[test]
1634 fn test_fix_ordered_list_with_different_numbers() {
1635 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1637 let warnings = lint(content);
1638 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1639
1640 check_warnings_have_fixes(content);
1642
1643 let fixed_content = fix(content);
1644 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1645 assert_eq!(
1646 fixed_content, expected,
1647 "Fix should handle ordered lists with non-sequential numbers"
1648 );
1649
1650 let warnings_after_fix = lint(&fixed_content);
1652 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1653 }
1654
1655 #[test]
1656 fn test_fix_list_with_code_blocks_inside() {
1657 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1659 let warnings = lint(content);
1660 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1661
1662 check_warnings_have_fixes(content);
1664
1665 let fixed_content = fix(content);
1666 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1667 assert_eq!(
1668 fixed_content, expected,
1669 "Fix should handle lists with internal code blocks"
1670 );
1671
1672 let warnings_after_fix = lint(&fixed_content);
1674 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1675 }
1676
1677 #[test]
1678 fn test_fix_deeply_nested_lists() {
1679 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1681 let warnings = lint(content);
1682 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1683
1684 check_warnings_have_fixes(content);
1686
1687 let fixed_content = fix(content);
1688 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1689 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1690
1691 let warnings_after_fix = lint(&fixed_content);
1693 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1694 }
1695
1696 #[test]
1697 fn test_fix_list_with_multiline_items() {
1698 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1701 let warnings = lint(content);
1702 assert_eq!(
1703 warnings.len(),
1704 1,
1705 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1706 );
1707
1708 check_warnings_have_fixes(content);
1710
1711 let fixed_content = fix(content);
1712 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1713 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1714
1715 let warnings_after_fix = lint(&fixed_content);
1717 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1718 }
1719
1720 #[test]
1721 fn test_fix_list_at_document_boundaries() {
1722 let content1 = "- Item 1\n- Item 2";
1724 let warnings1 = lint(content1);
1725 assert_eq!(
1726 warnings1.len(),
1727 0,
1728 "List at document start should not need blank before"
1729 );
1730 let fixed1 = fix(content1);
1731 assert_eq!(fixed1, content1, "No fix needed for list at start");
1732
1733 let content2 = "Text\n- Item 1\n- Item 2";
1735 let warnings2 = lint(content2);
1736 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1737 check_warnings_have_fixes(content2);
1738 let fixed2 = fix(content2);
1739 assert_eq!(
1740 fixed2, "Text\n\n- Item 1\n- Item 2",
1741 "Should add blank before list at end"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_fix_preserves_existing_blank_lines() {
1747 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1748 let warnings = lint(content);
1749 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1750 let fixed_content = fix(content);
1751 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1752 }
1753
1754 #[test]
1755 fn test_fix_handles_tabs_and_spaces() {
1756 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1759 let warnings = lint(content);
1760 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1762
1763 check_warnings_have_fixes(content);
1765
1766 let fixed_content = fix(content);
1767 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1770 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1771
1772 let warnings_after_fix = lint(&fixed_content);
1774 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1775 }
1776
1777 #[test]
1778 fn test_fix_warning_objects_have_correct_ranges() {
1779 let content = "Text\n- Item 1\n- Item 2\nText";
1781 let warnings = lint(content);
1782 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1783
1784 for warning in &warnings {
1786 assert!(warning.fix.is_some(), "Warning should have fix");
1787 let fix = warning.fix.as_ref().unwrap();
1788 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1789 assert!(
1790 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1791 "Fix should have replacement or be insertion"
1792 );
1793 }
1794 }
1795
1796 #[test]
1797 fn test_fix_idempotent() {
1798 let content = "Text\n- Item 1\n- Item 2\nText";
1800
1801 let fixed_once = fix(content);
1803 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1804
1805 let fixed_twice = fix(&fixed_once);
1807 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1808
1809 let warnings_after_fix = lint(&fixed_once);
1811 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1812 }
1813
1814 #[test]
1815 fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
1816 let rule = MD032BlanksAroundLists::default();
1817 for (content, expected) in [
1818 ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1819 (
1820 "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1821 "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1822 ),
1823 ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1824 ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
1825 ] {
1826 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1827 let warnings = rule.check(&ctx).unwrap();
1828 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1829 assert_eq!(edited, expected);
1830 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1831 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1832 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1833 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1834 }
1835 }
1836
1837 #[test]
1838 fn test_fix_respects_inline_config_at_each_list_boundary() {
1839 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1840
1841 let rule = MD032BlanksAroundLists::default();
1842 for (content, expected, warning_lines) in [
1845 (
1846 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
1847 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
1848 vec![4],
1849 ),
1850 (
1851 "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1852 "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1853 vec![2],
1854 ),
1855 (
1856 "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
1857 "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
1858 vec![2, 8],
1859 ),
1860 (
1861 "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
1862 "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
1863 vec![2, 8],
1864 ),
1865 (
1866 "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
1871 "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
1872 vec![3],
1873 ),
1874 ] {
1875 for ending in ["\n", "\r\n"] {
1876 for final_newline in [true, false] {
1877 let content = if final_newline {
1878 content
1879 } else {
1880 content.trim_end_matches('\n')
1881 };
1882 let expected = if final_newline {
1883 expected
1884 } else {
1885 expected.trim_end_matches('\n')
1886 };
1887 let content = content.replace('\n', ending);
1888 let expected = expected.replace('\n', ending);
1889 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1890 let warnings =
1891 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
1892 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
1893 assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
1894 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1895 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1896 assert!(
1897 filter_warnings_by_inline_config(
1898 rule.check(&fixed_ctx).unwrap(),
1899 fixed_ctx.inline_config(),
1900 rule.name()
1901 )
1902 .is_empty()
1903 );
1904 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1905 }
1906 }
1907 }
1908 }
1909
1910 #[test]
1911 fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
1912 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1913
1914 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1915 allow_lazy_continuation: false,
1916 });
1917 let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
1918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919 let raw = rule.check(&ctx).unwrap();
1920 assert_eq!(raw.len(), 1);
1921 assert!(raw[0].fix.is_some());
1922 let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
1923 assert!(warnings.is_empty());
1924 assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
1925 assert_eq!(rule.fix(&ctx).unwrap(), content);
1926 }
1927
1928 #[test]
1929 fn test_fix_with_normalized_line_endings() {
1930 let content = "Text\n- Item 1\n- Item 2\nText";
1934 let warnings = lint(content);
1935 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1936
1937 check_warnings_have_fixes(content);
1939
1940 let fixed_content = fix(content);
1941 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1943 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1944 }
1945
1946 #[test]
1947 fn test_fix_preserves_final_newline() {
1948 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1951 let fixed_with_newline = fix(content_with_newline);
1952 assert!(
1953 fixed_with_newline.ends_with('\n'),
1954 "Fix should preserve final newline when present"
1955 );
1956 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1958
1959 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1961 let fixed_without_newline = fix(content_without_newline);
1962 assert!(
1963 !fixed_without_newline.ends_with('\n'),
1964 "Fix should not add final newline when not present"
1965 );
1966 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1968 }
1969
1970 #[test]
1971 fn test_fix_multiline_list_items_no_indent() {
1972 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";
1973
1974 let warnings = lint(content);
1975 assert_eq!(
1977 warnings.len(),
1978 0,
1979 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1980 );
1981
1982 let fixed_content = fix(content);
1983 assert_eq!(
1985 fixed_content, content,
1986 "Should not modify correctly formatted multi-line list items"
1987 );
1988 }
1989
1990 #[test]
1991 fn test_nested_list_with_lazy_continuation() {
1992 let content = r#"# Test
1998
1999- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2000 1. Switch/case dispatcher statements (original Phase 3.2)
2001 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2002`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2003 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2004 references"#;
2005
2006 let warnings = lint(content);
2007 let md032_warnings: Vec<_> = warnings
2010 .iter()
2011 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2012 .collect();
2013 assert_eq!(
2014 md032_warnings.len(),
2015 0,
2016 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_pipes_in_code_spans_not_detected_as_table() {
2022 let content = r#"# Test
2024
2025- Item with `a | b` inline code
2026 - Nested item should work
2027
2028"#;
2029
2030 let warnings = lint(content);
2031 let md032_warnings: Vec<_> = warnings
2032 .iter()
2033 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2034 .collect();
2035 assert_eq!(
2036 md032_warnings.len(),
2037 0,
2038 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2039 );
2040 }
2041
2042 #[test]
2043 fn test_multiple_code_spans_with_pipes() {
2044 let content = r#"# Test
2046
2047- Item with `a | b` and `c || d` operators
2048 - Nested item should work
2049
2050"#;
2051
2052 let warnings = lint(content);
2053 let md032_warnings: Vec<_> = warnings
2054 .iter()
2055 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2056 .collect();
2057 assert_eq!(
2058 md032_warnings.len(),
2059 0,
2060 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn test_actual_table_breaks_list() {
2066 let content = r#"# Test
2068
2069- Item before table
2070
2071| Col1 | Col2 |
2072|------|------|
2073| A | B |
2074
2075- Item after table
2076
2077"#;
2078
2079 let warnings = lint(content);
2080 let md032_warnings: Vec<_> = warnings
2082 .iter()
2083 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2084 .collect();
2085 assert_eq!(
2086 md032_warnings.len(),
2087 0,
2088 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2089 );
2090 }
2091
2092 #[test]
2093 fn test_thematic_break_not_lazy_continuation() {
2094 let content = r#"- Item 1
2097- Item 2
2098***
2099
2100More text.
2101"#;
2102
2103 let warnings = lint(content);
2104 let md032_warnings: Vec<_> = warnings
2105 .iter()
2106 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2107 .collect();
2108 assert_eq!(
2109 md032_warnings.len(),
2110 1,
2111 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2112 );
2113 assert!(
2114 md032_warnings[0].message.contains("followed by blank line"),
2115 "Warning should be about missing blank after list"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_thematic_break_with_blank_line() {
2121 let content = r#"- Item 1
2123- Item 2
2124
2125***
2126
2127More text.
2128"#;
2129
2130 let warnings = lint(content);
2131 let md032_warnings: Vec<_> = warnings
2132 .iter()
2133 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2134 .collect();
2135 assert_eq!(
2136 md032_warnings.len(),
2137 0,
2138 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_various_thematic_break_styles() {
2144 for hr in ["---", "***", "___"] {
2149 let content = format!(
2150 r#"- Item 1
2151- Item 2
2152{hr}
2153
2154More text.
2155"#
2156 );
2157
2158 let warnings = lint(&content);
2159 let md032_warnings: Vec<_> = warnings
2160 .iter()
2161 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2162 .collect();
2163 assert_eq!(
2164 md032_warnings.len(),
2165 1,
2166 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2167 );
2168 }
2169 }
2170
2171 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2174 let rule = MD032BlanksAroundLists::from_config_struct(config);
2175 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2176 rule.check(&ctx).expect("Lint check failed")
2177 }
2178
2179 fn fix_with_config(content: &str, config: MD032Config) -> String {
2180 let rule = MD032BlanksAroundLists::from_config_struct(config);
2181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2182 rule.fix(&ctx).expect("Lint fix failed")
2183 }
2184
2185 #[test]
2186 fn test_lazy_continuation_allowed_by_default() {
2187 let content = "# Heading\n\n1. List\nSome text.";
2189 let warnings = lint(content);
2190 assert_eq!(
2191 warnings.len(),
2192 0,
2193 "Default behavior should allow lazy continuation. Got: {warnings:?}"
2194 );
2195 }
2196
2197 #[test]
2198 fn test_lazy_continuation_disallowed() {
2199 let content = "# Heading\n\n1. List\nSome text.";
2201 let config = MD032Config {
2202 allow_lazy_continuation: false,
2203 };
2204 let warnings = lint_with_config(content, config);
2205 assert_eq!(
2206 warnings.len(),
2207 1,
2208 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2209 );
2210 assert!(
2211 warnings[0].message.contains("Lazy continuation"),
2212 "Warning message should mention lazy continuation"
2213 );
2214 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2215 }
2216
2217 #[test]
2218 fn test_lazy_continuation_fix() {
2219 let content = "# Heading\n\n1. List\nSome text.";
2221 let config = MD032Config {
2222 allow_lazy_continuation: false,
2223 };
2224 let fixed = fix_with_config(content, config.clone());
2225 assert_eq!(
2227 fixed, "# Heading\n\n1. List\n Some text.",
2228 "Fix should add proper indentation to lazy continuation"
2229 );
2230
2231 let warnings_after = lint_with_config(&fixed, config);
2233 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2234 }
2235
2236 #[test]
2237 fn test_lazy_continuation_multiple_lines() {
2238 let content = "- Item 1\nLine 2\nLine 3";
2240 let config = MD032Config {
2241 allow_lazy_continuation: false,
2242 };
2243 let warnings = lint_with_config(content, config.clone());
2244 assert_eq!(
2246 warnings.len(),
2247 2,
2248 "Should warn for each lazy continuation line. Got: {warnings:?}"
2249 );
2250
2251 let fixed = fix_with_config(content, config.clone());
2252 assert_eq!(
2254 fixed, "- Item 1\n Line 2\n Line 3",
2255 "Fix should add proper indentation to lazy continuation lines"
2256 );
2257
2258 let warnings_after = lint_with_config(&fixed, config);
2260 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2261 }
2262
2263 #[test]
2264 fn test_lazy_continuation_with_indented_content() {
2265 let content = "- Item 1\n Indented content\nLazy text";
2267 let config = MD032Config {
2268 allow_lazy_continuation: false,
2269 };
2270 let warnings = lint_with_config(content, config);
2271 assert_eq!(
2272 warnings.len(),
2273 1,
2274 "Should warn for lazy text after indented content. Got: {warnings:?}"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_lazy_continuation_properly_separated() {
2280 let content = "- Item 1\n\nSome text.";
2282 let config = MD032Config {
2283 allow_lazy_continuation: false,
2284 };
2285 let warnings = lint_with_config(content, config);
2286 assert_eq!(
2287 warnings.len(),
2288 0,
2289 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2290 );
2291 }
2292
2293 #[test]
2296 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2297 let content = "1) First item\nLazy continuation";
2299 let config = MD032Config {
2300 allow_lazy_continuation: false,
2301 };
2302 let warnings = lint_with_config(content, config.clone());
2303 assert_eq!(
2304 warnings.len(),
2305 1,
2306 "Should warn for lazy continuation with parenthesis marker"
2307 );
2308
2309 let fixed = fix_with_config(content, config);
2310 assert_eq!(fixed, "1) First item\n Lazy continuation");
2312 }
2313
2314 #[test]
2315 fn test_lazy_continuation_followed_by_another_list() {
2316 let content = "- Item 1\nSome text\n- Item 2";
2322 let config = MD032Config {
2323 allow_lazy_continuation: false,
2324 };
2325 let warnings = lint_with_config(content, config);
2326 assert_eq!(
2328 warnings.len(),
2329 1,
2330 "Should warn about lazy continuation within list. Got: {warnings:?}"
2331 );
2332 assert!(
2333 warnings[0].message.contains("Lazy continuation"),
2334 "Warning should be about lazy continuation"
2335 );
2336 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2337 }
2338
2339 #[test]
2340 fn test_lazy_continuation_multiple_in_document() {
2341 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2346 let config = MD032Config {
2347 allow_lazy_continuation: false,
2348 };
2349 let warnings = lint_with_config(content, config.clone());
2350 assert_eq!(
2352 warnings.len(),
2353 2,
2354 "Should warn for both lazy continuations. Got: {warnings:?}"
2355 );
2356
2357 let fixed = fix_with_config(content, config.clone());
2358 assert!(
2360 fixed.contains(" Lazy 1"),
2361 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2362 );
2363 assert!(
2364 fixed.contains(" Lazy 2"),
2365 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2366 );
2367
2368 let warnings_after = lint_with_config(&fixed, config);
2369 assert_eq!(
2371 warnings_after.len(),
2372 0,
2373 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_lazy_continuation_end_of_document_no_newline() {
2379 let content = "- Item\nNo trailing newline";
2381 let config = MD032Config {
2382 allow_lazy_continuation: false,
2383 };
2384 let warnings = lint_with_config(content, config.clone());
2385 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2386
2387 let fixed = fix_with_config(content, config);
2388 assert_eq!(fixed, "- Item\n No trailing newline");
2390 }
2391
2392 #[test]
2393 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2394 let content = "- Item 1\n---";
2397 let config = MD032Config {
2398 allow_lazy_continuation: false,
2399 };
2400 let warnings = lint_with_config(content, config.clone());
2401 assert_eq!(
2403 warnings.len(),
2404 1,
2405 "List should need blank line before thematic break. Got: {warnings:?}"
2406 );
2407
2408 let fixed = fix_with_config(content, config);
2410 assert_eq!(fixed, "- Item 1\n\n---");
2411 }
2412
2413 #[test]
2414 fn test_lazy_continuation_heading_not_flagged() {
2415 let content = "- Item 1\n# Heading";
2418 let config = MD032Config {
2419 allow_lazy_continuation: false,
2420 };
2421 let warnings = lint_with_config(content, config);
2422 assert!(
2425 warnings.iter().all(|w| !w.message.contains("lazy")),
2426 "Heading should not trigger lazy continuation warning"
2427 );
2428 }
2429
2430 #[test]
2431 fn test_lazy_continuation_mixed_list_types() {
2432 let content = "- Unordered\n1. Ordered\nLazy text";
2434 let config = MD032Config {
2435 allow_lazy_continuation: false,
2436 };
2437 let warnings = lint_with_config(content, config.clone());
2438 assert!(!warnings.is_empty(), "Should warn about structure issues");
2439 }
2440
2441 #[test]
2442 fn test_lazy_continuation_deep_nesting() {
2443 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2445 let config = MD032Config {
2446 allow_lazy_continuation: false,
2447 };
2448 let warnings = lint_with_config(content, config.clone());
2449 assert!(
2450 !warnings.is_empty(),
2451 "Should warn about lazy continuation after nested list"
2452 );
2453
2454 let fixed = fix_with_config(content, config.clone());
2455 let warnings_after = lint_with_config(&fixed, config);
2456 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2457 }
2458
2459 #[test]
2460 fn test_lazy_continuation_with_emphasis_in_text() {
2461 let content = "- Item\n*emphasized* continuation";
2463 let config = MD032Config {
2464 allow_lazy_continuation: false,
2465 };
2466 let warnings = lint_with_config(content, config.clone());
2467 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2468
2469 let fixed = fix_with_config(content, config);
2470 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2472 }
2473
2474 #[test]
2475 fn test_lazy_continuation_with_code_span() {
2476 let content = "- Item\n`code` continuation";
2478 let config = MD032Config {
2479 allow_lazy_continuation: false,
2480 };
2481 let warnings = lint_with_config(content, config.clone());
2482 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2483
2484 let fixed = fix_with_config(content, config);
2485 assert_eq!(fixed, "- Item\n `code` continuation");
2487 }
2488
2489 #[test]
2496 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2497 let content = r#"1. Create a new Chat conversation:
2500 - On the sidebar, select **New Chat**.
2501 - In the box, type `/new`.
2502 A new Chat conversation replaces the previous one.
25031. Under the Chat text box, turn off the toggle."#;
2504 let config = MD032Config {
2505 allow_lazy_continuation: false,
2506 };
2507 let warnings = lint_with_config(content, config);
2508 let lazy_warnings: Vec<_> = warnings
2510 .iter()
2511 .filter(|w| w.message.contains("Lazy continuation"))
2512 .collect();
2513 assert!(
2514 !lazy_warnings.is_empty(),
2515 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2516 );
2517 assert!(
2518 lazy_warnings.iter().any(|w| w.line == 4),
2519 "Should warn on line 4. Got: {lazy_warnings:?}"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2525 let content = r#"- `field`: Is the specific key:
2528 - `password`: Accesses the password.
2529 - `api_key`: Accesses the api_key.
2530 `token`: Specifies which ID token to use.
2531- `version_id`: Is the unique identifier."#;
2532 let config = MD032Config {
2533 allow_lazy_continuation: false,
2534 };
2535 let warnings = lint_with_config(content, config);
2536 let lazy_warnings: Vec<_> = warnings
2538 .iter()
2539 .filter(|w| w.message.contains("Lazy continuation"))
2540 .collect();
2541 assert!(
2542 !lazy_warnings.is_empty(),
2543 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2544 );
2545 assert!(
2546 lazy_warnings.iter().any(|w| w.line == 4),
2547 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2548 );
2549 }
2550
2551 #[test]
2552 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2553 let content = r#"- Check out the branch, and test locally.
2555 - If the MR requires significant modifications:
2556 - **Skip local testing** and review instead.
2557 - **Request verification** from the author.
2558 - **Identify the minimal change** needed.
2559 Your testing might result in opportunities.
2560- If you don't understand, _say so_."#;
2561 let config = MD032Config {
2562 allow_lazy_continuation: false,
2563 };
2564 let warnings = lint_with_config(content, config);
2565 let lazy_warnings: Vec<_> = warnings
2567 .iter()
2568 .filter(|w| w.message.contains("Lazy continuation"))
2569 .collect();
2570 assert!(
2571 !lazy_warnings.is_empty(),
2572 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2573 );
2574 assert!(
2575 lazy_warnings.iter().any(|w| w.line == 6),
2576 "Should warn on line 6. Got: {lazy_warnings:?}"
2577 );
2578 }
2579
2580 #[test]
2581 fn test_issue295_ordered_list_nested_bullets_continuation() {
2582 let content = r#"# Test
2585
25861. First item.
2587 - Nested A.
2588 - Nested B.
2589 Continuation at outer level.
25901. Second item."#;
2591 let config = MD032Config {
2592 allow_lazy_continuation: false,
2593 };
2594 let warnings = lint_with_config(content, config);
2595 let lazy_warnings: Vec<_> = warnings
2597 .iter()
2598 .filter(|w| w.message.contains("Lazy continuation"))
2599 .collect();
2600 assert!(
2601 !lazy_warnings.is_empty(),
2602 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2603 );
2604 assert!(
2606 lazy_warnings.iter().any(|w| w.line == 6),
2607 "Should warn on line 6. Got: {lazy_warnings:?}"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_issue295_multiple_lazy_lines_after_nested() {
2613 let content = r#"1. The device client receives a response.
2615 - Those defined by OAuth Framework.
2616 - Those specific to device authorization.
2617 Those error responses are described below.
2618 For more information on each response,
2619 see the documentation.
26201. Next step in the process."#;
2621 let config = MD032Config {
2622 allow_lazy_continuation: false,
2623 };
2624 let warnings = lint_with_config(content, config);
2625 let lazy_warnings: Vec<_> = warnings
2627 .iter()
2628 .filter(|w| w.message.contains("Lazy continuation"))
2629 .collect();
2630 assert!(
2631 lazy_warnings.len() >= 3,
2632 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2633 lazy_warnings.len()
2634 );
2635 }
2636
2637 #[test]
2638 fn test_issue295_properly_indented_not_lazy() {
2639 let content = r#"1. First item.
2641 - Nested A.
2642 - Nested B.
2643
2644 Properly indented continuation.
26451. Second item."#;
2646 let config = MD032Config {
2647 allow_lazy_continuation: false,
2648 };
2649 let warnings = lint_with_config(content, config);
2650 let lazy_warnings: Vec<_> = warnings
2652 .iter()
2653 .filter(|w| w.message.contains("Lazy continuation"))
2654 .collect();
2655 assert_eq!(
2656 lazy_warnings.len(),
2657 0,
2658 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2659 );
2660 }
2661
2662 #[test]
2669 fn test_html_comment_before_list_with_preceding_blank() {
2670 let content = "Some text.\n\n<!-- comment -->\n- List item";
2673 let warnings = lint(content);
2674 assert_eq!(
2675 warnings.len(),
2676 0,
2677 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_html_comment_after_list_with_following_blank() {
2683 let content = "- List item\n<!-- comment -->\n\nSome text.";
2685 let warnings = lint(content);
2686 assert_eq!(
2687 warnings.len(),
2688 0,
2689 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2690 );
2691 }
2692
2693 #[test]
2694 fn test_list_inside_html_comment_ignored() {
2695 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2697 let warnings = lint(content);
2698 assert_eq!(
2699 warnings.len(),
2700 0,
2701 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_multiline_html_comment_before_list() {
2707 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2709 let warnings = lint(content);
2710 assert_eq!(
2711 warnings.len(),
2712 0,
2713 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2714 );
2715 }
2716
2717 #[test]
2718 fn test_a_comment_line_separates_the_paragraph_from_the_list() {
2719 let content = "Some text.\n<!-- comment -->\n- List item";
2722 let warnings = lint(content);
2723 assert_eq!(
2724 warnings.len(),
2725 0,
2726 "A comment-only line separates the blocks around it. Got: {warnings:?}"
2727 );
2728 }
2729
2730 #[test]
2731 fn test_a_line_carrying_text_beside_a_comment_still_warns() {
2732 let content = "Some text. <!-- comment -->\n- List item";
2735 let warnings = lint(content);
2736 assert_eq!(
2737 warnings.len(),
2738 1,
2739 "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
2740 );
2741 assert!(
2742 warnings[0].message.contains("preceded by blank line"),
2743 "Should be 'preceded by blank line' warning"
2744 );
2745 }
2746
2747 #[test]
2748 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2749 let content = "- List item\n<!-- comment -->\nSome text.";
2752 let warnings = lint(content);
2753 assert_eq!(
2754 warnings.len(),
2755 0,
2756 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2757 );
2758 }
2759
2760 #[test]
2761 fn test_list_followed_by_heading_through_comment_should_warn() {
2762 let content = "- List item\n<!-- comment -->\n# Heading";
2764 let warnings = lint(content);
2765 assert!(
2768 warnings.len() <= 1,
2769 "Should handle heading after comment gracefully. Got: {warnings:?}"
2770 );
2771 }
2772
2773 #[test]
2774 fn test_html_comment_between_list_and_text_both_directions() {
2775 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2777 let warnings = lint(content);
2778 assert_eq!(
2779 warnings.len(),
2780 0,
2781 "Should not warn with proper separation through comments. Got: {warnings:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2787 let content = "Text.\n\n<!-- comment -->\n- Item";
2789 let fixed = fix(content);
2790 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2791 }
2792
2793 #[test]
2794 fn test_html_comment_fix_adds_blank_when_needed() {
2795 let separated = "Text.\n<!-- comment -->\n- Item";
2798 assert_eq!(
2799 fix(separated),
2800 separated,
2801 "A comment-only line needs no blank line inserted around it"
2802 );
2803
2804 let content = "Text. <!-- comment -->\n- Item";
2805 let fixed = fix(content);
2806 assert!(
2807 fixed.contains("Text. <!-- comment -->\n\n- Item"),
2808 "Fix should add blank line before list. Got: {fixed}"
2809 );
2810 }
2811
2812 #[test]
2813 fn test_ordered_list_inside_html_comment() {
2814 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2816 let warnings = lint(content);
2817 assert_eq!(
2818 warnings.len(),
2819 0,
2820 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2821 );
2822 }
2823
2824 #[test]
2831 fn test_blockquote_list_exit_no_warning() {
2832 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2834 let warnings = lint(content);
2835 assert_eq!(
2836 warnings.len(),
2837 0,
2838 "Should not warn when exiting blockquote. Got: {warnings:?}"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_nested_blockquote_list_exit() {
2844 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2846 let warnings = lint(content);
2847 assert_eq!(
2848 warnings.len(),
2849 0,
2850 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2851 );
2852 }
2853
2854 #[test]
2855 fn test_blockquote_same_level_no_warning() {
2856 let content = "> - item 1\n> - item 2\n> Text after";
2859 let warnings = lint(content);
2860 assert_eq!(
2861 warnings.len(),
2862 0,
2863 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2864 );
2865 }
2866
2867 #[test]
2868 fn test_blockquote_list_with_special_chars() {
2869 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2871 let warnings = lint(content);
2872 assert_eq!(
2873 warnings.len(),
2874 0,
2875 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2876 );
2877 }
2878
2879 #[test]
2880 fn test_lazy_continuation_whitespace_only_line() {
2881 let content = "- Item\n \nText after whitespace-only line";
2884 let config = MD032Config {
2885 allow_lazy_continuation: false,
2886 };
2887 let warnings = lint_with_config(content, config);
2888 assert_eq!(
2890 warnings.len(),
2891 0,
2892 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2893 );
2894 }
2895
2896 #[test]
2897 fn test_lazy_continuation_blockquote_context() {
2898 let content = "> - Item\n> Lazy in quote";
2900 let config = MD032Config {
2901 allow_lazy_continuation: false,
2902 };
2903 let warnings = lint_with_config(content, config);
2904 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2907 }
2908
2909 #[test]
2910 fn test_lazy_continuation_fix_preserves_content() {
2911 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2913 let config = MD032Config {
2914 allow_lazy_continuation: false,
2915 };
2916 let fixed = fix_with_config(content, config);
2917 assert!(fixed.contains("<>&"), "Should preserve special chars");
2918 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2919 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2921 }
2922
2923 #[test]
2924 fn test_lazy_continuation_fix_idempotent() {
2925 let content = "- Item\nLazy";
2927 let config = MD032Config {
2928 allow_lazy_continuation: false,
2929 };
2930 let fixed_once = fix_with_config(content, config.clone());
2931 let fixed_twice = fix_with_config(&fixed_once, config);
2932 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2933 }
2934
2935 #[test]
2936 fn test_lazy_continuation_config_default_allows() {
2937 let content = "- Item\nLazy text that continues";
2939 let default_config = MD032Config::default();
2940 assert!(
2941 default_config.allow_lazy_continuation,
2942 "Default should allow lazy continuation"
2943 );
2944 let warnings = lint_with_config(content, default_config);
2945 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2946 }
2947
2948 #[test]
2949 fn test_lazy_continuation_after_multi_line_item() {
2950 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2952 let config = MD032Config {
2953 allow_lazy_continuation: false,
2954 };
2955 let warnings = lint_with_config(content, config.clone());
2956 assert_eq!(
2957 warnings.len(),
2958 1,
2959 "Should warn only for the lazy line, not the indented line"
2960 );
2961 }
2962
2963 #[test]
2965 fn test_blockquote_list_with_continuation_and_nested() {
2966 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2969 let warnings = lint(content);
2970 assert_eq!(
2971 warnings.len(),
2972 0,
2973 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2974 );
2975 }
2976
2977 #[test]
2978 fn test_blockquote_list_simple() {
2979 let content = "> - item 1\n> - item 2";
2981 let warnings = lint(content);
2982 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2983 }
2984
2985 #[test]
2986 fn test_blockquote_list_with_continuation_only() {
2987 let content = "> - item 1\n> continuation\n> - item 2";
2989 let warnings = lint(content);
2990 assert_eq!(
2991 warnings.len(),
2992 0,
2993 "Blockquoted list with continuation should have no warnings"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_blockquote_list_with_lazy_continuation() {
2999 let content = "> - item 1\n> lazy continuation\n> - item 2";
3001 let warnings = lint(content);
3002 assert_eq!(
3003 warnings.len(),
3004 0,
3005 "Blockquoted list with lazy continuation should have no warnings"
3006 );
3007 }
3008
3009 #[test]
3010 fn test_nested_blockquote_list() {
3011 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
3013 let warnings = lint(content);
3014 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3015 }
3016
3017 #[test]
3018 fn test_blockquote_list_needs_preceding_blank() {
3019 let content = "> Text before\n> - item 1\n> - item 2";
3021 let warnings = lint(content);
3022 assert_eq!(
3023 warnings.len(),
3024 1,
3025 "Should warn for missing blank before blockquoted list"
3026 );
3027 }
3028
3029 #[test]
3030 fn test_blockquote_list_properly_separated() {
3031 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3033 let warnings = lint(content);
3034 assert_eq!(
3035 warnings.len(),
3036 0,
3037 "Properly separated blockquoted list should have no warnings"
3038 );
3039 }
3040
3041 #[test]
3042 fn test_blockquote_ordered_list() {
3043 let content = "> 1. item 1\n> continuation\n> 2. item 2";
3045 let warnings = lint(content);
3046 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3047 }
3048
3049 #[test]
3050 fn test_blockquote_list_with_empty_blockquote_line() {
3051 let content = "> - item 1\n>\n> - item 2";
3053 let warnings = lint(content);
3054 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3055 }
3056
3057 #[test]
3059 fn test_blockquote_list_multi_paragraph_items() {
3060 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
3063 let warnings = lint(content);
3064 assert_eq!(
3065 warnings.len(),
3066 0,
3067 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3068 );
3069 }
3070
3071 #[test]
3073 fn test_blockquote_ordered_list_multi_paragraph_items() {
3074 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
3075 let warnings = lint(content);
3076 assert_eq!(
3077 warnings.len(),
3078 0,
3079 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3080 );
3081 }
3082
3083 #[test]
3085 fn test_blockquote_list_multiple_continuations() {
3086 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
3087 let warnings = lint(content);
3088 assert_eq!(
3089 warnings.len(),
3090 0,
3091 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3092 );
3093 }
3094
3095 #[test]
3097 fn test_nested_blockquote_multi_paragraph_list() {
3098 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
3099 let warnings = lint(content);
3100 assert_eq!(
3101 warnings.len(),
3102 0,
3103 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3104 );
3105 }
3106
3107 #[test]
3109 fn test_triple_nested_blockquote_multi_paragraph_list() {
3110 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
3111 let warnings = lint(content);
3112 assert_eq!(
3113 warnings.len(),
3114 0,
3115 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3116 );
3117 }
3118
3119 #[test]
3121 fn test_blockquote_list_last_item_continuation() {
3122 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
3123 let warnings = lint(content);
3124 assert_eq!(
3125 warnings.len(),
3126 0,
3127 "Last item with continuation should have no warnings. Got: {warnings:?}"
3128 );
3129 }
3130
3131 #[test]
3133 fn test_blockquote_list_first_item_only_continuation() {
3134 let content = "> - Item 1\n> \n> Continuation of item 1\n";
3135 let warnings = lint(content);
3136 assert_eq!(
3137 warnings.len(),
3138 0,
3139 "Single item with continuation should have no warnings. Got: {warnings:?}"
3140 );
3141 }
3142
3143 #[test]
3147 fn test_blockquote_level_change_breaks_list() {
3148 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3150 let warnings = lint(content);
3151 assert!(
3155 warnings.len() <= 2,
3156 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3157 );
3158 }
3159
3160 #[test]
3162 fn test_exit_blockquote_needs_blank_before_list() {
3163 let content = "> Blockquote text\n\n- List outside blockquote\n";
3165 let warnings = lint(content);
3166 assert_eq!(
3167 warnings.len(),
3168 0,
3169 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3170 );
3171
3172 let content2 = "> Blockquote text\n- List outside blockquote\n";
3176 let warnings2 = lint(content2);
3177 assert!(
3179 warnings2.len() <= 1,
3180 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3181 );
3182 }
3183
3184 #[test]
3186 fn test_blockquote_multi_paragraph_all_unordered_markers() {
3187 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
3189 let warnings = lint(content_dash);
3190 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3191
3192 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
3194 let warnings = lint(content_asterisk);
3195 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3196
3197 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
3199 let warnings = lint(content_plus);
3200 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3201 }
3202
3203 #[test]
3205 fn test_blockquote_multi_paragraph_parenthesis_marker() {
3206 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
3207 let warnings = lint(content);
3208 assert_eq!(
3209 warnings.len(),
3210 0,
3211 "Parenthesis ordered markers should work. Got: {warnings:?}"
3212 );
3213 }
3214
3215 #[test]
3217 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3218 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
3220 let warnings = lint(content);
3221 assert_eq!(
3222 warnings.len(),
3223 0,
3224 "Multi-digit ordered list should work. Got: {warnings:?}"
3225 );
3226 }
3227
3228 #[test]
3230 fn test_blockquote_multi_paragraph_with_formatting() {
3231 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3232 let warnings = lint(content);
3233 assert_eq!(
3234 warnings.len(),
3235 0,
3236 "Continuation with inline formatting should work. Got: {warnings:?}"
3237 );
3238 }
3239
3240 #[test]
3242 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3243 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3244 let warnings = lint(content);
3245 assert_eq!(
3246 warnings.len(),
3247 0,
3248 "All items with continuations should work. Got: {warnings:?}"
3249 );
3250 }
3251
3252 #[test]
3254 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3255 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3256 let warnings = lint(content);
3257 assert_eq!(
3258 warnings.len(),
3259 0,
3260 "Lowercase continuation should work. Got: {warnings:?}"
3261 );
3262 }
3263
3264 #[test]
3266 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3267 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3268 let warnings = lint(content);
3269 assert_eq!(
3270 warnings.len(),
3271 0,
3272 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3273 );
3274 }
3275
3276 #[test]
3278 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3279 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3281 let warnings = lint(content);
3282 assert!(
3284 warnings.len() <= 1,
3285 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3286 );
3287 }
3288
3289 #[test]
3291 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3292 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3294 let warnings = lint(content);
3295 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3296 }
3297
3298 #[test]
3299 fn test_blockquote_list_varying_spaces_after_marker() {
3300 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3302 let warnings = lint(content);
3303 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3304 }
3305
3306 #[test]
3307 fn test_deeply_nested_blockquote_list() {
3308 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3310 let warnings = lint(content);
3311 assert_eq!(
3312 warnings.len(),
3313 0,
3314 "Deeply nested blockquote list should have no warnings"
3315 );
3316 }
3317
3318 #[test]
3319 fn test_blockquote_level_change_in_list() {
3320 let content = "> - item 1\n>> - deeper item\n> - item 2";
3322 let warnings = lint(content);
3325 assert!(
3326 !warnings.is_empty(),
3327 "Blockquote level change should break list and trigger warnings"
3328 );
3329 }
3330
3331 #[test]
3332 fn test_blockquote_list_with_code_span() {
3333 let content = "> - item with `code`\n> continuation\n> - item 2";
3335 let warnings = lint(content);
3336 assert_eq!(
3337 warnings.len(),
3338 0,
3339 "Blockquote list with code span should have no warnings"
3340 );
3341 }
3342
3343 #[test]
3344 fn test_code_span_html_comment_delimiters_no_false_positive() {
3345 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";
3351 let warnings = lint(content);
3352 assert_eq!(
3353 warnings.len(),
3354 0,
3355 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3356 );
3357 }
3358
3359 #[test]
3360 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3361 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";
3366 let fixed = fix(content);
3367 assert_eq!(
3368 fixed, content,
3369 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_blockquote_list_at_document_end() {
3375 let content = "> Some text\n>\n> - item 1\n> - item 2";
3377 let warnings = lint(content);
3378 assert_eq!(
3379 warnings.len(),
3380 0,
3381 "Blockquote list at document end should have no warnings"
3382 );
3383 }
3384
3385 #[test]
3386 fn test_fix_preserves_blockquote_prefix_before_list() {
3387 let content = "> Text before
3389> - Item 1
3390> - Item 2";
3391 let fixed = fix(content);
3392
3393 let expected = "> Text before
3395>
3396> - Item 1
3397> - Item 2";
3398 assert_eq!(
3399 fixed, expected,
3400 "Fix should insert '>' blank line, not plain blank line"
3401 );
3402 }
3403
3404 #[test]
3405 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3406 let content = ">>> Triple nested
3409>>> - Item 1
3410>>> - Item 2
3411>>> More text";
3412 let fixed = fix(content);
3413
3414 let expected = ">>> Triple nested
3416>>>
3417>>> - Item 1
3418>>> - Item 2
3419>>> More text";
3420 assert_eq!(
3421 fixed, expected,
3422 "Fix should preserve triple-nested blockquote prefix '>>>'"
3423 );
3424 }
3425
3426 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3429 let rule = MD032BlanksAroundLists::default();
3430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3431 rule.check(&ctx).unwrap()
3432 }
3433
3434 #[test]
3435 fn test_quarto_list_after_div_open() {
3436 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3438 let warnings = lint_quarto(content);
3439 assert!(
3441 warnings.is_empty(),
3442 "Quarto div marker should be transparent before list: {warnings:?}"
3443 );
3444 }
3445
3446 #[test]
3447 fn test_quarto_list_before_div_close() {
3448 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3450 let warnings = lint_quarto(content);
3451 assert!(
3453 warnings.is_empty(),
3454 "Quarto div marker should be transparent after list: {warnings:?}"
3455 );
3456 }
3457
3458 #[test]
3459 fn test_quarto_list_needs_blank_without_div() {
3460 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3462 let warnings = lint_quarto(content);
3463 assert!(
3466 !warnings.is_empty(),
3467 "Should still require blank when not present: {warnings:?}"
3468 );
3469 }
3470
3471 #[test]
3472 fn test_quarto_list_in_callout_with_content() {
3473 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3475 let warnings = lint_quarto(content);
3476 assert!(
3477 warnings.is_empty(),
3478 "List with proper blanks inside callout should pass: {warnings:?}"
3479 );
3480 }
3481
3482 #[test]
3483 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3484 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3486 let warnings = lint(content); assert!(
3489 !warnings.is_empty(),
3490 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3491 );
3492 }
3493
3494 #[test]
3495 fn test_quarto_nested_divs_with_list() {
3496 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3498 let warnings = lint_quarto(content);
3499 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3500 }
3501
3502 #[test]
3503 fn test_issue512_complex_nested_list_with_continuation() {
3504 let content = "\
3507- First level of indentation.
3508 - Second level of indentation.
3509 - Third level of indentation.
3510 - Third level of indentation.
3511
3512 Second level list continuation.
3513
3514 First level list continuation.
3515- First level of indentation.
3516";
3517 let warnings = lint(content);
3518 assert!(
3519 warnings.is_empty(),
3520 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3521 );
3522 }
3523
3524 #[test]
3525 fn test_issue512_continuation_at_root_level() {
3526 let content = "\
3530- First level.
3531 - Second level.
3532
3533 First level continuation.
3534
3535Root level lazy continuation.
3536- Another first level item.
3537";
3538 let warnings = lint(content);
3539 assert_eq!(
3540 warnings.len(),
3541 1,
3542 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3543 );
3544 assert_eq!(warnings[0].line, 7);
3545 }
3546
3547 #[test]
3548 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3549 let content = "\
3551- Level 1 item.
3552 - Level 2 item.
3553 - Level 3 item.
3554
3555 Level 3 continuation.
3556
3557 Level 2 continuation.
3558
3559 Level 1 continuation (indented under marker).
3560- Another level 1 item.
3561";
3562 let warnings = lint(content);
3563 assert!(
3564 warnings.is_empty(),
3565 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3566 );
3567 }
3568
3569 #[test]
3570 fn test_pandoc_list_after_div_open() {
3571 let rule = MD032BlanksAroundLists::default();
3574 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3576 let warnings = rule.check(&ctx).unwrap();
3577 assert!(
3578 warnings.is_empty(),
3579 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3580 );
3581 }
3582
3583 #[test]
3584 fn test_md032_html_comment() {
3585 let rule = MD032BlanksAroundLists::default();
3586 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3588 let warnings = rule.check(&ctx).unwrap();
3589 assert!(
3590 warnings.is_empty(),
3591 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3592 );
3593 }
3594
3595 #[test]
3596 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3597 let rule = MD032BlanksAroundLists::default();
3603 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";
3604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3605 let warnings = rule.check(&ctx).unwrap();
3606 assert!(
3607 warnings.is_empty(),
3608 "admonition-nested ordered list should not be flagged: {warnings:?}"
3609 );
3610 }
3611
3612 #[test]
3613 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3614 let rule = MD032BlanksAroundLists::default();
3618 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";
3619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3620 let warnings = rule.check(&ctx).unwrap();
3621 assert!(
3622 warnings.is_empty(),
3623 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3624 );
3625 }
3626
3627 #[test]
3628 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3629 let rule = MD032BlanksAroundLists::default();
3631 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";
3632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3633 let warnings = rule.check(&ctx).unwrap();
3634 assert!(
3635 warnings.is_empty(),
3636 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3637 );
3638 }
3639
3640 #[test]
3641 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3642 let rule = MD032BlanksAroundLists::default();
3648 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3650 let warnings = rule.check(&ctx).unwrap();
3651 assert_eq!(
3652 warnings.len(),
3653 1,
3654 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3655 );
3656 }
3657
3658 #[test]
3659 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3660 let rule = MD032BlanksAroundLists::default();
3665 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
3666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3667 let warnings = rule.check(&ctx).unwrap();
3668 assert_eq!(
3669 warnings.len(),
3670 1,
3671 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3672 );
3673 }
3674
3675 #[test]
3676 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3677 let rule = MD032BlanksAroundLists::default();
3682 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";
3683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3684 let warnings = rule.check(&ctx).unwrap();
3685 assert!(
3686 warnings.is_empty(),
3687 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3688 );
3689 }
3690
3691 #[test]
3692 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3693 let rule = MD032BlanksAroundLists::default();
3699 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3701 let warnings = rule.check(&ctx).unwrap();
3702 assert_eq!(
3703 warnings.len(),
3704 1,
3705 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3706 );
3707 assert_eq!(warnings[0].line, 4);
3708 assert!(warnings[0].message.contains("non-1"));
3709 }
3710
3711 #[test]
3712 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3713 let rule = MD032BlanksAroundLists::default();
3719 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";
3720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3721 let warnings = rule.check(&ctx).unwrap();
3722 assert!(
3723 warnings.is_empty(),
3724 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3725 );
3726 }
3727
3728 #[test]
3729 fn test_standard_flavor_admonition_indented_list_unchanged() {
3730 let rule = MD032BlanksAroundLists::default();
3735 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";
3736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3737 let warnings = rule.check(&ctx).unwrap();
3738 assert!(
3739 warnings.is_empty(),
3740 "indented code block under standard flavor should not be flagged: {warnings:?}"
3741 );
3742 }
3743
3744 #[test]
3745 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3746 let rule = MD032BlanksAroundLists::default();
3751 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";
3752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3753 let warnings = rule.check(&ctx).unwrap();
3754 assert_eq!(
3755 warnings.len(),
3756 1,
3757 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3758 );
3759 assert_eq!(warnings[0].line, 6);
3760 }
3761
3762 #[test]
3763 fn test_pseudo_list_marker_after_list() {
3764 let content = indoc::indoc! {"
3765 - Item 1
3766 Item 1 content.
3767
3768 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3769 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3770 are not used except when those semantics are desired.
3771 "};
3772 let warnings = lint(content);
3773 assert!(
3774 warnings.is_empty(),
3775 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3776 );
3777 }
3778
3779 #[test]
3780 fn test_pseudo_list_marker_without_preceding_list() {
3781 let content = indoc::indoc! {"
3782 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3783 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3784 are not used except when those semantics are desired.
3785 "};
3786 let warnings = lint(content);
3787 assert!(
3788 warnings.is_empty(),
3789 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3790 );
3791 }
3792
3793 #[test]
3794 fn test_no_space_hash_continuation_line_stays_in_its_item() {
3795 let content = indoc::indoc! {"
3800 5. **`M.md`** - the deltas (esp. items #1,
3801 #2, #3, #5, #8).
3802
3803 ---
3804
3805 ## Plan
3806
3807 ### Phase 0
3808 - [ ] task one
3809 wrapped
3810 "};
3811 let warnings = lint(content);
3812 assert_eq!(
3813 warnings.len(),
3814 1,
3815 "only the task list is missing a blank line, got: {warnings:?}"
3816 );
3817 assert_eq!(warnings[0].line, 9);
3818 assert_eq!(warnings[0].message, "List should be preceded by blank line");
3819
3820 let expected = indoc::indoc! {"
3821 5. **`M.md`** - the deltas (esp. items #1,
3822 #2, #3, #5, #8).
3823
3824 ---
3825
3826 ## Plan
3827
3828 ### Phase 0
3829
3830 - [ ] task one
3831 wrapped
3832 "};
3833 assert_eq!(fix(content), expected);
3834 }
3835
3836 #[test]
3837 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3838 let content = indoc::indoc! {"
3844 1. first
3845
3846 3. item
3847 continuation
3848
3849 1. nested
3850 2. nested
3851
3852 ## Heading
3853 - task
3854 "};
3855 let warnings = lint(content);
3856 assert_eq!(
3857 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3858 vec![10],
3859 "only the list after the heading is missing a blank line, got: {warnings:?}"
3860 );
3861
3862 let expected = indoc::indoc! {"
3863 1. first
3864
3865 3. item
3866 continuation
3867
3868 1. nested
3869 2. nested
3870
3871 ## Heading
3872
3873 - task
3874 "};
3875 assert_eq!(fix(content), expected);
3876 }
3877
3878 #[test]
3879 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
3880 let content = indoc::indoc! {"
3884 - item (esp. #1,
3885 #2, #3).
3886 - next item
3887
3888 ## Heading
3889 - task
3890 "};
3891 let warnings = lint(content);
3892 assert_eq!(
3893 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3894 vec![6],
3895 "only the list after the heading is missing a blank line, got: {warnings:?}"
3896 );
3897
3898 let expected = indoc::indoc! {"
3899 - item (esp. #1,
3900 #2, #3).
3901 - next item
3902
3903 ## Heading
3904
3905 - task
3906 "};
3907 assert_eq!(fix(content), expected);
3908 }
3909
3910 #[test]
3911 fn test_under_indented_continuation_lines_stay_in_their_item() {
3912 for content in [
3916 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
3917 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
3918 "- Item\n lazy continuation\n- another item\n",
3919 "> - Item\n> lazy continuation\n> - another item\n",
3920 ] {
3921 let warnings = lint(content);
3922 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3923 assert_eq!(fix(content), content, "{content:?}");
3924 }
3925 }
3926
3927 #[test]
3928 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
3929 let config = MD032Config {
3932 allow_lazy_continuation: false,
3933 };
3934 for (content, lazy_lines) in [
3935 ("- Item\n lazy continuation\n- another item\n", vec![2]),
3936 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
3937 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
3938 ] {
3939 let warnings = lint_with_config(content, config.clone());
3940 assert!(
3941 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
3942 "{content:?}: got {warnings:?}"
3943 );
3944 assert_eq!(
3945 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3946 lazy_lines,
3947 "{content:?}: got {warnings:?}"
3948 );
3949 }
3950 }
3951
3952 #[test]
3953 fn test_structural_line_at_short_indent_ends_the_list() {
3954 for (content, expected) in [
3958 ("1. item\n ---\n", "1. item\n\n ---\n"),
3959 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
3960 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3961 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3962 ] {
3963 let warnings = lint(content);
3964 assert_eq!(
3965 warnings
3966 .iter()
3967 .map(|w| (w.line, w.message.as_str()))
3968 .collect::<Vec<_>>(),
3969 vec![(1, "List should be followed by blank line")],
3970 "{content:?}: got {warnings:?}"
3971 );
3972 assert_eq!(fix(content), expected, "{content:?}");
3973 }
3974 }
3975
3976 #[test]
3977 fn test_html_block_at_short_indent_ends_the_list() {
3978 for (content, expected) in [
3985 (
3986 "- item\n<script>\nx\n</script>\n- next\n",
3987 "- item\n\n<script>\nx\n</script>\n\n- next\n",
3988 ),
3989 (
3990 "- item\n <script>\n x\n </script>\n- next\n",
3991 "- item\n\n <script>\n x\n </script>\n\n- next\n",
3992 ),
3993 (
3994 "- item\n <pre>\n x\n </pre>\n- next\n",
3995 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
3996 ),
3997 (
3998 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
3999 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4000 ),
4001 (
4002 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4003 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4004 ),
4005 ] {
4006 let warnings = lint(content);
4007 assert_eq!(
4008 warnings
4009 .iter()
4010 .map(|w| (w.line, w.message.as_str()))
4011 .collect::<Vec<_>>(),
4012 vec![
4013 (1, "List should be followed by blank line"),
4014 (5, "List should be preceded by blank line"),
4015 ],
4016 "{content:?}: got {warnings:?}"
4017 );
4018 assert_eq!(fix(content), expected, "{content:?}");
4019 }
4020 }
4021
4022 #[test]
4023 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4024 for content in [
4036 "100. item\n <div>\n101. next\n",
4037 "> 100. item\n> <div>\n> 101. next\n",
4038 "100. item\n <div>\ntext\n101. next\n",
4039 "- item\n<div.class>\n- next\n",
4040 "> - item\n> <div.class>\n> - next\n",
4041 "100. item\n\t<div>\n101. next\n",
4042 "- item\n \t<div>\n- next\n",
4043 "> - item\n> \t<div>\n> - next\n",
4044 "> - item\n>\t<div>\n> - next\n",
4045 "> 100. item\n> \t<div>\n> 101. next\n",
4046 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4047 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4048 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
4049 ] {
4050 let warnings = lint(content);
4051 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4052 assert_eq!(fix(content), content, "{content:?}");
4053 }
4054
4055 for (content, last_item_line) in [
4059 ("100. item\n <div>\n101. next\n", 1),
4060 ("> 100. item\n> <div>\n> 101. next\n", 1),
4061 ("- item\n <div>\n- next\n", 1),
4062 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4063 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4064 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
4065 ] {
4066 let warnings = lint(content);
4067 assert_eq!(
4068 warnings
4069 .iter()
4070 .map(|w| (w.line, w.message.as_str()))
4071 .collect::<Vec<_>>(),
4072 vec![(last_item_line, "List should be followed by blank line")],
4073 "{content:?}: got {warnings:?}"
4074 );
4075 }
4076 }
4077
4078 #[test]
4079 fn test_tab_indented_nested_list_stays_inside_its_item() {
4080 for content in [
4089 "* item text\n\t1. nested\n\t more\n",
4090 "* item text\n\tcontinuation\n\t1. nested\n",
4091 "1. item text\n\t- nested\n",
4092 "> * item text\n>\t1. nested\n",
4093 "> * item text\n> \t1. nested\n",
4094 "* item text\n\tcontinuation\n\t- nested\n",
4095 ] {
4096 let warnings = lint(content);
4097 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4098 assert_eq!(fix(content), content, "{content:?}");
4099 }
4100
4101 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
4104 let warnings = lint(content);
4105 assert_eq!(
4106 warnings
4107 .iter()
4108 .map(|w| (w.line, w.message.as_str()))
4109 .collect::<Vec<_>>(),
4110 vec![
4111 (1, "List should be followed by blank line"),
4112 (2, "List should be preceded by blank line"),
4113 ],
4114 "{content:?}: got {warnings:?}"
4115 );
4116 }
4117 }
4118
4119 #[test]
4120 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4121 for (content, expected) in [
4126 (
4127 "- item\n<div>\nx\n</div>\n- next\n",
4128 "- item\n\n<div>\nx\n</div>\n- next\n",
4129 ),
4130 (
4131 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4132 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4133 ),
4134 ] {
4135 let warnings = lint(content);
4136 assert_eq!(
4137 warnings
4138 .iter()
4139 .map(|w| (w.line, w.message.as_str()))
4140 .collect::<Vec<_>>(),
4141 vec![(1, "List should be followed by blank line")],
4142 "{content:?}: got {warnings:?}"
4143 );
4144 assert_eq!(fix(content), expected, "{content:?}");
4145 }
4146 }
4147
4148 #[test]
4149 fn test_html_block_at_content_column_is_item_content() {
4150 for content in [
4153 "- item\n <script>\n x\n </script>\n- next\n",
4154 "- item\n <div>\n x\n </div>\n- next\n",
4155 "1. item\n <pre>\n x\n </pre>\n2. next\n",
4156 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4157 ] {
4158 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4159 assert_eq!(fix(content), content, "{content:?}");
4160 }
4161 }
4162}