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.is_valid_heading() {
501 break;
502 }
503 if line.in_code_block {
505 break;
506 }
507
508 let effective_indent =
510 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
511
512 if effective_indent >= min_continuation_indent {
514 actual_end = check_line;
515 }
516 else if !line.is_blank
521 && !line.is_valid_heading()
522 && !block.item_lines.contains(&check_line)
523 && !is_thematic_break(line_content)
524 {
525 actual_end = check_line;
527 } else if !line.is_blank {
528 break;
530 }
531 }
532 }
533 }
534
535 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
536 }
537 }
538
539 blocks.retain(|(start, end, _)| {
541 let all_in_comment = (*start..=*end).all(|line_num| {
543 ctx.lines
544 .get(line_num - 1)
545 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
546 });
547 !all_in_comment
548 });
549
550 blocks
551 }
552
553 fn perform_checks(
554 &self,
555 ctx: &crate::lint_context::LintContext,
556 lines: &[&str],
557 list_blocks: &[(usize, usize, String)],
558 ) -> Vec<LintWarning> {
559 let mut warnings = Vec::new();
560 let num_lines = lines.len();
561
562 for (line_idx, line) in lines.iter().enumerate() {
565 let line_num = line_idx + 1;
566
567 let is_in_list = list_blocks
569 .iter()
570 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
571 if is_in_list {
572 continue;
573 }
574
575 if ctx.line_info(line_num).is_some_and(|info| {
577 info.in_code_block
578 || info.in_front_matter
579 || info.in_html_comment
580 || info.in_mdx_comment
581 || info.in_html_block
582 || info.in_jsx_block
583 }) {
584 continue;
585 }
586
587 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
589 if line_idx > 0 {
591 let prev_line = lines[line_idx - 1];
592 let prev_is_blank = is_blank_in_context(prev_line);
593 let prev_line_info = ctx.line_info(line_idx);
594 let prev_excluded = prev_line_info.is_some_and(|info| info.in_code_block || info.in_front_matter);
595
596 let prev_in_mkdocs_container =
612 prev_line_info.is_some_and(|info| info.in_admonition || info.in_content_tab);
613 let continues_stale_container_list = prev_in_mkdocs_container && {
614 let item_indent = calculate_indentation_width_default(line);
615 let mut found_marker = false;
616 for j in (0..line_idx).rev() {
617 let in_container = ctx
618 .line_info(j + 1)
619 .is_some_and(|info| info.in_admonition || info.in_content_tab);
620 if !in_container {
621 break;
622 }
623 let candidate = lines[j];
624 if is_blank_in_context(candidate) {
625 continue;
626 }
627 let candidate_indent = calculate_indentation_width_default(candidate);
628 if crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX.is_match(candidate)
629 && candidate_indent == item_indent
630 {
631 found_marker = true;
632 break;
633 }
634 if candidate_indent <= item_indent {
635 break;
636 }
637 }
638 found_marker
639 };
640
641 let prev_trimmed = prev_line.trim();
646 let is_sentence_continuation = continues_stale_container_list
647 || (!prev_is_blank
648 && !prev_trimmed.is_empty()
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 && !prev_trimmed.ends_with('*'));
657
658 if prev_is_blank || !is_sentence_continuation {
659 if !prev_is_blank && !prev_excluded {
660 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
662
663 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
664 warnings.push(LintWarning {
665 line: start_line,
666 column: start_col,
667 end_line,
668 end_column: end_col,
669 severity: Severity::Warning,
670 rule_name: Some(self.name().to_string()),
671 message: "Ordered list starting with non-1 should be preceded by blank line"
672 .to_string(),
673 fix: Some(Fix::new(
674 ctx.line_column_byte_range_with_length(line_num, 1, 0),
675 format!("{bq_prefix}\n"),
676 )),
677 });
678 }
679
680 if line_idx + 1 < num_lines {
683 let next_line = lines[line_idx + 1];
684 let next_is_blank = is_blank_in_context(next_line);
685 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
686
687 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
688 let next_trimmed = next_line.trim_start();
692 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
693 || next_line.starts_with("1. ")
694 || (next_line.len() > next_trimmed.len()
695 && !next_trimmed.starts_with("- ")
696 && !next_trimmed.starts_with("* ")
697 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
700 let (start_line, start_col, end_line, end_col) =
701 calculate_line_range(line_num, line);
702 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
703 warnings.push(LintWarning {
704 line: start_line,
705 column: start_col,
706 end_line,
707 end_column: end_col,
708 severity: Severity::Warning,
709 rule_name: Some(self.name().to_string()),
710 message: "List should be followed by blank line".to_string(),
711 fix: Some(Fix::new(
712 ctx.line_column_byte_range_with_length(line_num + 1, 1, 0),
713 format!("{bq_prefix}\n"),
714 )),
715 });
716 }
717 }
718 }
719 }
720 }
721 }
722 }
723
724 for &(start_line, end_line, ref prefix) in list_blocks {
725 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
726 if ctx
728 .line_info(start_line)
729 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
730 {
731 continue;
732 }
733
734 if start_line > 1 {
735 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
737
738 if !has_blank_separation && content_line > 0 {
740 let prev_line_str = lines[content_line - 1];
741 let is_prev_excluded = ctx
742 .line_info(content_line)
743 .is_some_and(|info| info.in_code_block || info.in_front_matter);
744 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
745 let prefixes_match = prev_bq_level == block_bq_level;
746
747 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
750 if !is_prev_excluded && prefixes_match && should_require {
751 let (start_line, start_col, end_line, end_col) =
753 calculate_line_range(start_line, lines[start_line - 1]);
754
755 warnings.push(LintWarning {
756 line: start_line,
757 column: start_col,
758 end_line,
759 end_column: end_col,
760 severity: Severity::Warning,
761 rule_name: Some(self.name().to_string()),
762 message: "List should be preceded by blank line".to_string(),
763 fix: Some(Fix::new(
764 ctx.line_column_byte_range_with_length(start_line, 1, 0),
765 format!("{}\n", ctx.blockquote_prefix_for_blank_line(start_line - 1)),
766 )),
767 });
768 }
769 }
770 }
771
772 if end_line < num_lines && !Self::block_ends_in_comment_line(lines, end_line) {
773 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
775
776 if !has_blank_separation && content_line > 0 {
778 let next_line_str = lines[content_line - 1];
779 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
782 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
783
784 let end_line_str = lines[end_line - 1];
789 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
790 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
791
792 let prefixes_match = next_line_bq_level == block_bq_level;
793
794 if !is_next_excluded && prefixes_match && !exits_blockquote {
797 let (start_line_last, start_col_last, end_line_last, end_col_last) =
799 calculate_line_range(end_line, lines[end_line - 1]);
800
801 warnings.push(LintWarning {
802 line: start_line_last,
803 column: start_col_last,
804 end_line: end_line_last,
805 end_column: end_col_last,
806 severity: Severity::Warning,
807 rule_name: Some(self.name().to_string()),
808 message: "List should be followed by blank line".to_string(),
809 fix: Some(Fix::new(
810 ctx.line_column_byte_range_with_length(end_line + 1, 1, 0),
811 format!("{}\n", ctx.blockquote_prefix_for_blank_line(end_line - 1)),
812 )),
813 });
814 }
815 }
816 }
817 }
818 warnings
819 }
820}
821
822impl Rule for MD032BlanksAroundLists {
823 fn name(&self) -> &'static str {
824 "MD032"
825 }
826
827 fn description(&self) -> &'static str {
828 "Lists should be surrounded by blank lines"
829 }
830
831 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
832 let lines = ctx.raw_lines();
833 if lines.is_empty() {
835 return Ok(Vec::new());
836 }
837
838 let list_blocks = self.convert_list_blocks(ctx);
839
840 if list_blocks.is_empty() {
841 return Ok(Vec::new());
842 }
843
844 let mut warnings = self.perform_checks(ctx, lines, &list_blocks);
845
846 if !self.config.allow_lazy_continuation {
851 let lazy_cont_lines = ctx.lazy_continuation_lines();
852
853 for lazy_info in lazy_cont_lines.iter() {
854 let line_num = lazy_info.line_num;
855
856 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
860 continue;
861 }
862
863 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
865 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
866
867 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
869 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
870 } else {
871 None
872 };
873
874 warnings.push(LintWarning {
875 line: start_line,
876 column: start_col,
877 end_line,
878 end_column: end_col,
879 severity: Severity::Warning,
880 rule_name: Some(self.name().to_string()),
881 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
882 fix,
883 });
884 }
885 }
886
887 Ok(warnings)
888 }
889
890 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
891 Ok(self.fix_with_structure_impl(ctx))
892 }
893
894 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
895 ctx.content.is_empty() || ctx.list_blocks.is_empty()
898 }
899
900 fn category(&self) -> RuleCategory {
901 RuleCategory::List
902 }
903
904 fn as_any(&self) -> &dyn std::any::Any {
905 self
906 }
907
908 crate::impl_rule_config_methods!(MD032Config);
909}
910
911impl MD032BlanksAroundLists {
912 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
914 let lines = ctx.raw_lines();
915 let num_lines = lines.len();
916 if num_lines == 0 {
917 return String::new();
918 }
919
920 let list_blocks = self.convert_list_blocks(ctx);
921 if list_blocks.is_empty() {
922 return ctx.content.to_string();
923 }
924
925 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
928 if !self.config.allow_lazy_continuation {
929 let lazy_cont_lines = ctx.lazy_continuation_lines();
930 for lazy_info in lazy_cont_lines.iter() {
931 let line_num = lazy_info.line_num;
932 if !Self::is_reportable_lazy_line(ctx, &list_blocks, line_num) {
934 continue;
935 }
936 if !Self::should_apply_lazy_fix(ctx, line_num)
938 || ctx.inline_config().is_rule_disabled(self.name(), line_num)
939 {
940 continue;
941 }
942 lazy_fixes.insert(line_num, lazy_info.clone());
943 }
944 }
945
946 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
947
948 for &(start_line, end_line, ref prefix) in &list_blocks {
950 let block_bq_level = prefix.chars().filter(|&c| c == '>').count();
951 if ctx
953 .line_info(start_line)
954 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
955 {
956 continue;
957 }
958
959 if start_line > 1 && !ctx.inline_config().is_rule_disabled(self.name(), start_line) {
961 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
963
964 if !has_blank_separation && content_line > 0 {
966 let prev_line_str = lines[content_line - 1];
967 let is_prev_excluded = ctx
968 .line_info(content_line)
969 .is_some_and(|info| info.in_code_block || info.in_front_matter);
970 let prev_bq_level = parse_blockquote_prefix(prev_line_str).map_or(0, |bq| bq.nesting_level);
971
972 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
973 if !is_prev_excluded && prev_bq_level == block_bq_level && should_require {
975 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
977 insertions.insert(start_line, bq_prefix);
978 }
979 }
980 }
981
982 if end_line < num_lines
984 && !ctx.inline_config().is_rule_disabled(self.name(), end_line)
985 && !Self::block_ends_in_comment_line(lines, end_line)
986 {
987 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
989
990 if !has_blank_separation && content_line > 0 {
992 let next_line_str = lines[content_line - 1];
993 let is_next_excluded = Self::is_following_content_excluded(ctx, content_line, prefix);
996 let next_line_bq_level = parse_blockquote_prefix(next_line_str).map_or(0, |bq| bq.nesting_level);
997
998 let end_line_str = lines[end_line - 1];
1000 let end_line_bq_level = parse_blockquote_prefix(end_line_str).map_or(0, |bq| bq.nesting_level);
1001 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
1002
1003 if !is_next_excluded && next_line_bq_level == block_bq_level && !exits_blockquote {
1006 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
1008 insertions.insert(end_line + 1, bq_prefix);
1009 }
1010 }
1011 }
1012 }
1013
1014 if insertions.is_empty() && lazy_fixes.is_empty() {
1015 return ctx.content.to_string();
1016 }
1017
1018 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
1020 for (i, line) in lines.iter().enumerate() {
1021 let current_line_num = i + 1;
1022 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
1023 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
1024 {
1025 result_lines.push(prefix_to_insert.clone());
1026 }
1027
1028 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num) {
1030 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1031 result_lines.push(fixed_line);
1032 } else {
1033 result_lines.push(line.to_string());
1034 }
1035 }
1036
1037 let line_ending = crate::utils::detect_line_ending(ctx.content);
1039 let mut result = result_lines.join(line_ending);
1040 if ctx.content.ends_with('\n') {
1041 result.push_str(line_ending);
1042 }
1043 result
1044 }
1045}
1046
1047fn is_blank_in_context(line: &str) -> bool {
1050 parse_blockquote_prefix(line)
1051 .map_or(line, |bq| bq.content)
1052 .trim()
1053 .is_empty()
1054 || crate::utils::blank_lines::is_blank_or_comment_only(line)
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060 use crate::lint_context::LintContext;
1061 use crate::rule::Rule;
1062
1063 fn lint(content: &str) -> Vec<LintWarning> {
1064 let rule = MD032BlanksAroundLists::default();
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 rule.check(&ctx).expect("Lint check failed")
1067 }
1068
1069 fn fix(content: &str) -> String {
1070 let rule = MD032BlanksAroundLists::default();
1071 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1072 rule.fix(&ctx).expect("Lint fix failed")
1073 }
1074
1075 #[test]
1076 fn test_spaced_nested_blockquotes_list_separation() {
1077 for (list_prefix, surrounding_prefix) in [
1078 ("> >", "> >"),
1079 ("> >", "> >"),
1080 ("> > >", "> > >"),
1081 ("> >", ">>"),
1082 (">>", "> >"),
1083 ] {
1084 let content = format!(
1085 "{surrounding_prefix} Introduction\n{list_prefix} - item\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1086 );
1087 let expected = format!(
1088 "{surrounding_prefix} Introduction\n{list_prefix}\n{list_prefix} - item\n{list_prefix}\n{surrounding_prefix} ~~~\n{surrounding_prefix} code\n{surrounding_prefix} ~~~\n"
1089 );
1090 let warnings = lint(&content);
1091 assert_eq!(warnings.len(), 2, "{content:?}: {warnings:?}");
1092 assert!(warnings.iter().all(|warning| warning.line == 2));
1093 let mut edited = content.clone();
1094 for warning in warnings.iter().rev() {
1095 let edit = warning.fix.as_ref().expect("missing diagnostic fix");
1096 edited.replace_range(edit.range.clone(), &edit.replacement);
1097 }
1098 assert_eq!(edited, expected, "Diagnostic fixes must preserve marker spacing");
1099 assert_eq!(fix(&content), expected);
1100 assert!(lint(&expected).is_empty(), "{expected:?}: {:?}", lint(&expected));
1101 assert_eq!(fix(&expected), expected, "Fix must be idempotent");
1102 }
1103 }
1104
1105 #[test]
1106 fn test_spaced_nested_blockquotes_preserve_list_code_and_exits() {
1107 for content in [
1108 "> > - item\n> > ```\n> > code\n> > ```\n",
1109 "> > 1. item\n> > ~~~\n> > code\n> > ~~~\n",
1110 "> > - item\n> ~~~\n> code\n> ~~~\n",
1111 "> > - item\n~~~\ncode\n~~~\n",
1112 "> > - item\n>> - next item\n",
1113 ] {
1114 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1115 assert_eq!(fix(content), content);
1116 }
1117 }
1118
1119 #[test]
1120 fn test_fix_separates_list_from_standalone_code_fence() {
1121 for (content, expected) in [
1122 (
1123 "# Test\n\n> - List item 1\n> - List item 2\n> ```\n> code\n> ```\n",
1124 "# Test\n\n> - List item 1\n> - List item 2\n>\n> ```\n> code\n> ```\n",
1125 ),
1126 ("- item\n```rust\ncode\n```\n", "- item\n\n```rust\ncode\n```\n"),
1127 ("1. item\n~~~\ncode\n~~~", "1. item\n\n~~~\ncode\n~~~"),
1128 (
1129 ">> - item\n>> ~~~\n>> code\n>> ~~~\n",
1130 ">> - item\n>>\n>> ~~~\n>> code\n>> ~~~\n",
1131 ),
1132 ] {
1133 let warnings = lint(content);
1134 assert_eq!(warnings.len(), 1, "{content:?}: {warnings:?}");
1135 assert_eq!(warnings[0].message, "List should be followed by blank line");
1136 let edit = warnings[0].fix.as_ref().expect("missing warning fix");
1137 let mut edited = content.to_string();
1138 edited.replace_range(edit.range.clone(), &edit.replacement);
1139 assert_eq!(edited, expected, "Diagnostic and document fixes must agree");
1140 assert_eq!(fix(content), expected, "{content:?}");
1141 assert!(lint(expected).is_empty(), "{expected:?}");
1142 assert_eq!(fix(expected), expected, "Fix must be idempotent");
1143 }
1144 }
1145
1146 #[test]
1147 fn test_fix_preserves_code_fence_inside_list_item() {
1148 for content in [
1149 "- item\n ```\n code\n ```\n",
1150 "1. item\n ~~~\n code\n ~~~\n",
1151 "> - item\n> ```\n> code\n> ```\n",
1152 ] {
1153 assert!(lint(content).is_empty(), "{content:?}: {:?}", lint(content));
1154 assert_eq!(fix(content), content, "A nested fence must stay inside its list item");
1155 }
1156 }
1157
1158 #[test]
1159 fn test_fix_does_not_split_item_before_different_list_type() {
1160 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1164 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1165
1166 let warnings = lint(content);
1169 assert_eq!(warnings.len(), 2);
1170 assert_eq!(warnings[0].line, 2);
1171 assert_eq!(warnings[1].line, 3);
1172 }
1173
1174 #[test]
1175 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1176 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1177 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1178 }
1179
1180 #[test]
1181 fn test_fix_keeps_lazy_continuation_with_its_item() {
1182 let content = "- alpha beta\nlazy\n1. ordered item\n";
1186 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1187
1188 let warnings = lint(content);
1189 assert_eq!(warnings.len(), 2);
1190 assert_eq!(warnings[0].line, 2);
1191 assert_eq!(warnings[1].line, 3);
1192 }
1193
1194 #[test]
1195 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1196 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1197 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1198 }
1199
1200 #[test]
1201 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1202 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1205 allow_lazy_continuation: false,
1206 });
1207 let content = "- alpha beta\nlazy\n1. ordered item\n";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1210 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1211 }
1212
1213 #[test]
1214 fn test_div_closer_after_list_is_not_a_lazy_continuation_in_quarto() {
1215 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1219 allow_lazy_continuation: false,
1220 });
1221 let content = "::: callout-note\n- List item 1\n- List item 2\n:::\n";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1223 let warnings = rule.check(&ctx).expect("Lint check failed");
1224 assert!(warnings.is_empty(), "Expected no warnings, got: {warnings:?}");
1225 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1226 assert_eq!(fixed, content);
1227 }
1228
1229 #[test]
1230 fn test_prose_after_list_in_quarto_div_is_still_a_lazy_continuation() {
1231 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1235 allow_lazy_continuation: false,
1236 });
1237 let content = "::: callout-note\n- List item 1\nlazy\n:::\n";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
1239 let warnings = rule.check(&ctx).expect("Lint check failed");
1240 assert_eq!(
1241 warnings.len(),
1242 1,
1243 "Expected one lazy-continuation warning, got: {warnings:?}"
1244 );
1245 assert_eq!(warnings[0].line, 3);
1246 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1247 assert_eq!(fixed, "::: callout-note\n- List item 1\n lazy\n:::\n");
1248 }
1249
1250 #[test]
1251 fn test_div_closer_after_list_is_a_lazy_continuation_in_standard() {
1252 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1255 allow_lazy_continuation: false,
1256 });
1257 let content = "Intro\n\n- List item 1\n- List item 2\n:::\n";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1260 assert_eq!(fixed, "Intro\n\n- List item 1\n- List item 2\n :::\n");
1261 }
1262
1263 fn check_warnings_have_fixes(content: &str) {
1265 let warnings = lint(content);
1266 for warning in &warnings {
1267 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1268 }
1269 }
1270
1271 #[test]
1272 fn test_list_at_start() {
1273 let content = "- Item 1\n- Item 2\nText";
1276 let warnings = lint(content);
1277 assert_eq!(
1278 warnings.len(),
1279 0,
1280 "Trailing text is lazy continuation per CommonMark - no warning expected"
1281 );
1282 }
1283
1284 #[test]
1285 fn test_list_at_end() {
1286 let content = "Text\n- Item 1\n- Item 2";
1287 let warnings = lint(content);
1288 assert_eq!(
1289 warnings.len(),
1290 1,
1291 "Expected 1 warning for list at end without preceding blank line"
1292 );
1293 assert_eq!(
1294 warnings[0].line, 2,
1295 "Warning should be on the first line of the list (line 2)"
1296 );
1297 assert!(warnings[0].message.contains("preceded by blank line"));
1298
1299 check_warnings_have_fixes(content);
1301
1302 let fixed_content = fix(content);
1303 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1304
1305 let warnings_after_fix = lint(&fixed_content);
1307 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1308 }
1309
1310 #[test]
1311 fn test_list_in_middle() {
1312 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1315 let warnings = lint(content);
1316 assert_eq!(
1317 warnings.len(),
1318 1,
1319 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1320 );
1321 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1322 assert!(warnings[0].message.contains("preceded by blank line"));
1323
1324 check_warnings_have_fixes(content);
1326
1327 let fixed_content = fix(content);
1328 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1329
1330 let warnings_after_fix = lint(&fixed_content);
1332 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1333 }
1334
1335 #[test]
1336 fn test_correct_spacing() {
1337 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1338 let warnings = lint(content);
1339 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1340
1341 let fixed_content = fix(content);
1342 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1343 }
1344
1345 #[test]
1346 fn test_list_with_content() {
1347 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1350 let warnings = lint(content);
1351 assert_eq!(
1352 warnings.len(),
1353 1,
1354 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1355 );
1356 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1357 assert!(warnings[0].message.contains("preceded by blank line"));
1358
1359 check_warnings_have_fixes(content);
1361
1362 let fixed_content = fix(content);
1363 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1364 assert_eq!(
1365 fixed_content, expected_fixed,
1366 "Fix did not produce the expected output. Got:\n{fixed_content}"
1367 );
1368
1369 let warnings_after_fix = lint(&fixed_content);
1371 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1372 }
1373
1374 #[test]
1375 fn test_nested_list() {
1376 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1378 let warnings = lint(content);
1379 assert_eq!(
1380 warnings.len(),
1381 1,
1382 "Nested list block needs preceding blank only. Got: {warnings:?}"
1383 );
1384 assert_eq!(warnings[0].line, 2);
1385 assert!(warnings[0].message.contains("preceded by blank line"));
1386
1387 check_warnings_have_fixes(content);
1389
1390 let fixed_content = fix(content);
1391 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1392
1393 let warnings_after_fix = lint(&fixed_content);
1395 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1396 }
1397
1398 #[test]
1399 fn test_list_with_internal_blanks() {
1400 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1402 let warnings = lint(content);
1403 assert_eq!(
1404 warnings.len(),
1405 1,
1406 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1407 );
1408 assert_eq!(warnings[0].line, 2);
1409 assert!(warnings[0].message.contains("preceded by blank line"));
1410
1411 check_warnings_have_fixes(content);
1413
1414 let fixed_content = fix(content);
1415 assert_eq!(
1416 fixed_content,
1417 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1418 );
1419
1420 let warnings_after_fix = lint(&fixed_content);
1422 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1423 }
1424
1425 #[test]
1426 fn test_ignore_code_blocks() {
1427 let content = "```\n- Not a list item\n```\nText";
1428 let warnings = lint(content);
1429 assert_eq!(warnings.len(), 0);
1430 let fixed_content = fix(content);
1431 assert_eq!(fixed_content, content);
1432 }
1433
1434 #[test]
1435 fn test_ignore_front_matter() {
1436 let content = "---\ntitle: Test\n---\n- List Item\nText";
1438 let warnings = lint(content);
1439 assert_eq!(
1440 warnings.len(),
1441 0,
1442 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1443 );
1444
1445 let fixed_content = fix(content);
1447 assert_eq!(fixed_content, content, "No changes when no warnings");
1448 }
1449
1450 #[test]
1451 fn test_multiple_lists() {
1452 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1457 let warnings = lint(content);
1458 assert!(
1460 !warnings.is_empty(),
1461 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1462 );
1463
1464 check_warnings_have_fixes(content);
1466
1467 let fixed_content = fix(content);
1468 let warnings_after_fix = lint(&fixed_content);
1470 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1471 }
1472
1473 #[test]
1474 fn test_adjacent_lists() {
1475 let content = "- List 1\n\n* List 2";
1476 let warnings = lint(content);
1477 assert_eq!(warnings.len(), 0);
1478 let fixed_content = fix(content);
1479 assert_eq!(fixed_content, content);
1480 }
1481
1482 #[test]
1483 fn test_list_in_blockquote() {
1484 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1486 let warnings = lint(content);
1487 assert_eq!(
1488 warnings.len(),
1489 1,
1490 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1491 );
1492 assert_eq!(warnings[0].line, 2);
1493
1494 check_warnings_have_fixes(content);
1496
1497 let fixed_content = fix(content);
1498 assert_eq!(
1500 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1501 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1502 );
1503
1504 let warnings_after_fix = lint(&fixed_content);
1506 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1507 }
1508
1509 #[test]
1510 fn test_ordered_list() {
1511 let content = "Text\n1. Item 1\n2. Item 2\nText";
1513 let warnings = lint(content);
1514 assert_eq!(warnings.len(), 1);
1515
1516 check_warnings_have_fixes(content);
1518
1519 let fixed_content = fix(content);
1520 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1521
1522 let warnings_after_fix = lint(&fixed_content);
1524 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1525 }
1526
1527 #[test]
1528 fn test_no_double_blank_fix() {
1529 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1532 assert_eq!(
1533 warnings.len(),
1534 0,
1535 "Should have no warnings - properly preceded, trailing is lazy"
1536 );
1537
1538 let fixed_content = fix(content);
1539 assert_eq!(
1540 fixed_content, content,
1541 "No fix needed when no warnings. Got:\n{fixed_content}"
1542 );
1543
1544 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1546 assert_eq!(warnings2.len(), 1);
1547 if !warnings2.is_empty() {
1548 assert_eq!(
1549 warnings2[0].line, 2,
1550 "Warning line for missing blank before should be the first line of the block"
1551 );
1552 }
1553
1554 check_warnings_have_fixes(content2);
1556
1557 let fixed_content2 = fix(content2);
1558 assert_eq!(
1559 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1560 "Fix added extra blank before. Got:\n{fixed_content2}"
1561 );
1562 }
1563
1564 #[test]
1565 fn test_empty_input() {
1566 let content = "";
1567 let warnings = lint(content);
1568 assert_eq!(warnings.len(), 0);
1569 let fixed_content = fix(content);
1570 assert_eq!(fixed_content, "");
1571 }
1572
1573 #[test]
1574 fn test_only_list() {
1575 let content = "- Item 1\n- Item 2";
1576 let warnings = lint(content);
1577 assert_eq!(warnings.len(), 0);
1578 let fixed_content = fix(content);
1579 assert_eq!(fixed_content, content);
1580 }
1581
1582 #[test]
1585 fn test_fix_complex_nested_blockquote() {
1586 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1588 let warnings = lint(content);
1589 assert_eq!(
1590 warnings.len(),
1591 1,
1592 "Should warn for missing preceding blank only. Got: {warnings:?}"
1593 );
1594
1595 check_warnings_have_fixes(content);
1597
1598 let fixed_content = fix(content);
1599 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1601 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1602
1603 let warnings_after_fix = lint(&fixed_content);
1604 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1605 }
1606
1607 #[test]
1608 fn test_fix_mixed_list_markers() {
1609 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1612 let warnings = lint(content);
1613 assert!(
1615 !warnings.is_empty(),
1616 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1617 );
1618
1619 check_warnings_have_fixes(content);
1621
1622 let fixed_content = fix(content);
1623 assert!(
1625 fixed_content.contains("Text\n\n-"),
1626 "Fix should add blank line before first list item"
1627 );
1628
1629 let warnings_after_fix = lint(&fixed_content);
1631 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1632 }
1633
1634 #[test]
1635 fn test_fix_ordered_list_with_different_numbers() {
1636 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1638 let warnings = lint(content);
1639 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1640
1641 check_warnings_have_fixes(content);
1643
1644 let fixed_content = fix(content);
1645 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1646 assert_eq!(
1647 fixed_content, expected,
1648 "Fix should handle ordered lists with non-sequential numbers"
1649 );
1650
1651 let warnings_after_fix = lint(&fixed_content);
1653 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1654 }
1655
1656 #[test]
1657 fn test_fix_list_with_code_blocks_inside() {
1658 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1660 let warnings = lint(content);
1661 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1662
1663 check_warnings_have_fixes(content);
1665
1666 let fixed_content = fix(content);
1667 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1668 assert_eq!(
1669 fixed_content, expected,
1670 "Fix should handle lists with internal code blocks"
1671 );
1672
1673 let warnings_after_fix = lint(&fixed_content);
1675 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1676 }
1677
1678 #[test]
1679 fn test_fix_deeply_nested_lists() {
1680 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1682 let warnings = lint(content);
1683 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1684
1685 check_warnings_have_fixes(content);
1687
1688 let fixed_content = fix(content);
1689 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1690 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1691
1692 let warnings_after_fix = lint(&fixed_content);
1694 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1695 }
1696
1697 #[test]
1698 fn test_fix_list_with_multiline_items() {
1699 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1702 let warnings = lint(content);
1703 assert_eq!(
1704 warnings.len(),
1705 1,
1706 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1707 );
1708
1709 check_warnings_have_fixes(content);
1711
1712 let fixed_content = fix(content);
1713 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1714 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1715
1716 let warnings_after_fix = lint(&fixed_content);
1718 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1719 }
1720
1721 #[test]
1722 fn test_fix_list_at_document_boundaries() {
1723 let content1 = "- Item 1\n- Item 2";
1725 let warnings1 = lint(content1);
1726 assert_eq!(
1727 warnings1.len(),
1728 0,
1729 "List at document start should not need blank before"
1730 );
1731 let fixed1 = fix(content1);
1732 assert_eq!(fixed1, content1, "No fix needed for list at start");
1733
1734 let content2 = "Text\n- Item 1\n- Item 2";
1736 let warnings2 = lint(content2);
1737 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1738 check_warnings_have_fixes(content2);
1739 let fixed2 = fix(content2);
1740 assert_eq!(
1741 fixed2, "Text\n\n- Item 1\n- Item 2",
1742 "Should add blank before list at end"
1743 );
1744 }
1745
1746 #[test]
1747 fn test_fix_preserves_existing_blank_lines() {
1748 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1749 let warnings = lint(content);
1750 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1751 let fixed_content = fix(content);
1752 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1753 }
1754
1755 #[test]
1756 fn test_fix_handles_tabs_and_spaces() {
1757 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1760 let warnings = lint(content);
1761 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1763
1764 check_warnings_have_fixes(content);
1766
1767 let fixed_content = fix(content);
1768 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1771 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1772
1773 let warnings_after_fix = lint(&fixed_content);
1775 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1776 }
1777
1778 #[test]
1779 fn test_fix_warning_objects_have_correct_ranges() {
1780 let content = "Text\n- Item 1\n- Item 2\nText";
1782 let warnings = lint(content);
1783 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1784
1785 for warning in &warnings {
1787 assert!(warning.fix.is_some(), "Warning should have fix");
1788 let fix = warning.fix.as_ref().unwrap();
1789 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1790 assert!(
1791 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1792 "Fix should have replacement or be insertion"
1793 );
1794 }
1795 }
1796
1797 #[test]
1798 fn test_fix_idempotent() {
1799 let content = "Text\n- Item 1\n- Item 2\nText";
1801
1802 let fixed_once = fix(content);
1804 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1805
1806 let fixed_twice = fix(&fixed_once);
1808 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1809
1810 let warnings_after_fix = lint(&fixed_once);
1812 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1813 }
1814
1815 #[test]
1816 fn test_fix_preserves_crlf_and_matches_diagnostic_edits() {
1817 let rule = MD032BlanksAroundLists::default();
1818 for (content, expected) in [
1819 ("Text\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1820 (
1821 "> > - item\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1822 "> > - item\r\n> >\r\n> > ~~~\r\n> > code\r\n> > ~~~",
1823 ),
1824 ("Text\r\n\r\n- item\r\n", "Text\r\n\r\n- item\r\n"),
1825 ("Text\r\n\n- item\r\n", "Text\r\n\n- item\r\n"),
1826 ] {
1827 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828 let warnings = rule.check(&ctx).unwrap();
1829 let edited = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
1830 assert_eq!(edited, expected);
1831 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1832 let fixed_ctx = LintContext::new(expected, crate::config::MarkdownFlavor::Standard, None);
1833 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1834 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1835 }
1836 }
1837
1838 #[test]
1839 fn test_fix_respects_inline_config_at_each_list_boundary() {
1840 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1841
1842 let rule = MD032BlanksAroundLists::default();
1843 for (content, expected, warning_lines) in [
1846 (
1847 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n# Heading\n",
1848 "Text\n<!-- rumdl-disable-next-line MD032 -->\n- item\ntail <!-- comment -->\n\n# Heading\n",
1849 vec![4],
1850 ),
1851 (
1852 "Text\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1853 "Text\n\n- item\n<!-- rumdl-disable-next-line MD032 -->\n<!-- comment -->\n# Heading\n",
1854 vec![2],
1855 ),
1856 (
1857 "Text\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n- enabled\n",
1858 "Text\n\n- item\n<!-- rumdl-disable MD032 -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable MD032 -->\nText\n\n- enabled\n",
1859 vec![2, 8],
1860 ),
1861 (
1862 "Text\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n- enabled\n",
1863 "Text\n\n- item\n<!-- rumdl-disable -->\n<!-- comment -->\n# Heading\n<!-- rumdl-enable -->\nText\n\n- enabled\n",
1864 vec![2, 8],
1865 ),
1866 (
1867 "Text\n<!-- rumdl-disable MD013 -->\n- item\n# Heading\n",
1872 "Text\n<!-- rumdl-disable MD013 -->\n- item\n\n# Heading\n",
1873 vec![3],
1874 ),
1875 ] {
1876 for ending in ["\n", "\r\n"] {
1877 for final_newline in [true, false] {
1878 let content = if final_newline {
1879 content
1880 } else {
1881 content.trim_end_matches('\n')
1882 };
1883 let expected = if final_newline {
1884 expected
1885 } else {
1886 expected.trim_end_matches('\n')
1887 };
1888 let content = content.replace('\n', ending);
1889 let expected = expected.replace('\n', ending);
1890 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1891 let warnings =
1892 filter_warnings_by_inline_config(rule.check(&ctx).unwrap(), ctx.inline_config(), rule.name());
1893 assert_eq!(warnings.iter().map(|w| w.line).collect::<Vec<_>>(), warning_lines);
1894 assert_eq!(apply_warning_fixes(&content, &warnings).unwrap(), expected);
1895 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1896 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1897 assert!(
1898 filter_warnings_by_inline_config(
1899 rule.check(&fixed_ctx).unwrap(),
1900 fixed_ctx.inline_config(),
1901 rule.name()
1902 )
1903 .is_empty()
1904 );
1905 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1906 }
1907 }
1908 }
1909 }
1910
1911 #[test]
1912 fn test_disabled_lazy_fix_preserves_mixed_line_endings() {
1913 use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
1914
1915 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1916 allow_lazy_continuation: false,
1917 });
1918 let content = "<!-- rumdl-disable MD032 -->\r\n\r\n- item\ncontinuation\r\n- next\r\n";
1919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1920 let raw = rule.check(&ctx).unwrap();
1921 assert_eq!(raw.len(), 1);
1922 assert!(raw[0].fix.is_some());
1923 let warnings = filter_warnings_by_inline_config(raw, ctx.inline_config(), rule.name());
1924 assert!(warnings.is_empty());
1925 assert_eq!(apply_warning_fixes(content, &warnings).unwrap(), content);
1926 assert_eq!(rule.fix(&ctx).unwrap(), content);
1927 }
1928
1929 #[test]
1930 fn test_fix_with_normalized_line_endings() {
1931 let content = "Text\n- Item 1\n- Item 2\nText";
1935 let warnings = lint(content);
1936 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1937
1938 check_warnings_have_fixes(content);
1940
1941 let fixed_content = fix(content);
1942 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1944 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1945 }
1946
1947 #[test]
1948 fn test_fix_preserves_final_newline() {
1949 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1952 let fixed_with_newline = fix(content_with_newline);
1953 assert!(
1954 fixed_with_newline.ends_with('\n'),
1955 "Fix should preserve final newline when present"
1956 );
1957 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1959
1960 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1962 let fixed_without_newline = fix(content_without_newline);
1963 assert!(
1964 !fixed_without_newline.ends_with('\n'),
1965 "Fix should not add final newline when not present"
1966 );
1967 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1969 }
1970
1971 #[test]
1972 fn test_fix_multiline_list_items_no_indent() {
1973 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";
1974
1975 let warnings = lint(content);
1976 assert_eq!(
1978 warnings.len(),
1979 0,
1980 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1981 );
1982
1983 let fixed_content = fix(content);
1984 assert_eq!(
1986 fixed_content, content,
1987 "Should not modify correctly formatted multi-line list items"
1988 );
1989 }
1990
1991 #[test]
1992 fn test_nested_list_with_lazy_continuation() {
1993 let content = r#"# Test
1999
2000- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
2001 1. Switch/case dispatcher statements (original Phase 3.2)
2002 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
2003`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
2004 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
2005 references"#;
2006
2007 let warnings = lint(content);
2008 let md032_warnings: Vec<_> = warnings
2011 .iter()
2012 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2013 .collect();
2014 assert_eq!(
2015 md032_warnings.len(),
2016 0,
2017 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
2018 );
2019 }
2020
2021 #[test]
2022 fn test_pipes_in_code_spans_not_detected_as_table() {
2023 let content = r#"# Test
2025
2026- Item with `a | b` inline code
2027 - Nested item should work
2028
2029"#;
2030
2031 let warnings = lint(content);
2032 let md032_warnings: Vec<_> = warnings
2033 .iter()
2034 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2035 .collect();
2036 assert_eq!(
2037 md032_warnings.len(),
2038 0,
2039 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_multiple_code_spans_with_pipes() {
2045 let content = r#"# Test
2047
2048- Item with `a | b` and `c || d` operators
2049 - Nested item should work
2050
2051"#;
2052
2053 let warnings = lint(content);
2054 let md032_warnings: Vec<_> = warnings
2055 .iter()
2056 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2057 .collect();
2058 assert_eq!(
2059 md032_warnings.len(),
2060 0,
2061 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
2062 );
2063 }
2064
2065 #[test]
2066 fn test_actual_table_breaks_list() {
2067 let content = r#"# Test
2069
2070- Item before table
2071
2072| Col1 | Col2 |
2073|------|------|
2074| A | B |
2075
2076- Item after table
2077
2078"#;
2079
2080 let warnings = lint(content);
2081 let md032_warnings: Vec<_> = warnings
2083 .iter()
2084 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2085 .collect();
2086 assert_eq!(
2087 md032_warnings.len(),
2088 0,
2089 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
2090 );
2091 }
2092
2093 #[test]
2094 fn test_thematic_break_not_lazy_continuation() {
2095 let content = r#"- Item 1
2098- Item 2
2099***
2100
2101More text.
2102"#;
2103
2104 let warnings = lint(content);
2105 let md032_warnings: Vec<_> = warnings
2106 .iter()
2107 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2108 .collect();
2109 assert_eq!(
2110 md032_warnings.len(),
2111 1,
2112 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
2113 );
2114 assert!(
2115 md032_warnings[0].message.contains("followed by blank line"),
2116 "Warning should be about missing blank after list"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_thematic_break_with_blank_line() {
2122 let content = r#"- Item 1
2124- Item 2
2125
2126***
2127
2128More text.
2129"#;
2130
2131 let warnings = lint(content);
2132 let md032_warnings: Vec<_> = warnings
2133 .iter()
2134 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2135 .collect();
2136 assert_eq!(
2137 md032_warnings.len(),
2138 0,
2139 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
2140 );
2141 }
2142
2143 #[test]
2144 fn test_various_thematic_break_styles() {
2145 for hr in ["---", "***", "___"] {
2150 let content = format!(
2151 r#"- Item 1
2152- Item 2
2153{hr}
2154
2155More text.
2156"#
2157 );
2158
2159 let warnings = lint(&content);
2160 let md032_warnings: Vec<_> = warnings
2161 .iter()
2162 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
2163 .collect();
2164 assert_eq!(
2165 md032_warnings.len(),
2166 1,
2167 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
2168 );
2169 }
2170 }
2171
2172 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
2175 let rule = MD032BlanksAroundLists::from_config_struct(config);
2176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2177 rule.check(&ctx).expect("Lint check failed")
2178 }
2179
2180 fn fix_with_config(content: &str, config: MD032Config) -> String {
2181 let rule = MD032BlanksAroundLists::from_config_struct(config);
2182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2183 rule.fix(&ctx).expect("Lint fix failed")
2184 }
2185
2186 #[test]
2187 fn test_lazy_continuation_allowed_by_default() {
2188 let content = "# Heading\n\n1. List\nSome text.";
2190 let warnings = lint(content);
2191 assert_eq!(
2192 warnings.len(),
2193 0,
2194 "Default behavior should allow lazy continuation. Got: {warnings:?}"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_lazy_continuation_disallowed() {
2200 let content = "# Heading\n\n1. List\nSome text.";
2202 let config = MD032Config {
2203 allow_lazy_continuation: false,
2204 };
2205 let warnings = lint_with_config(content, config);
2206 assert_eq!(
2207 warnings.len(),
2208 1,
2209 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
2210 );
2211 assert!(
2212 warnings[0].message.contains("Lazy continuation"),
2213 "Warning message should mention lazy continuation"
2214 );
2215 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
2216 }
2217
2218 #[test]
2219 fn test_lazy_continuation_fix() {
2220 let content = "# Heading\n\n1. List\nSome text.";
2222 let config = MD032Config {
2223 allow_lazy_continuation: false,
2224 };
2225 let fixed = fix_with_config(content, config.clone());
2226 assert_eq!(
2228 fixed, "# Heading\n\n1. List\n Some text.",
2229 "Fix should add proper indentation to lazy continuation"
2230 );
2231
2232 let warnings_after = lint_with_config(&fixed, config);
2234 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2235 }
2236
2237 #[test]
2238 fn test_lazy_continuation_multiple_lines() {
2239 let content = "- Item 1\nLine 2\nLine 3";
2241 let config = MD032Config {
2242 allow_lazy_continuation: false,
2243 };
2244 let warnings = lint_with_config(content, config.clone());
2245 assert_eq!(
2247 warnings.len(),
2248 2,
2249 "Should warn for each lazy continuation line. Got: {warnings:?}"
2250 );
2251
2252 let fixed = fix_with_config(content, config.clone());
2253 assert_eq!(
2255 fixed, "- Item 1\n Line 2\n Line 3",
2256 "Fix should add proper indentation to lazy continuation lines"
2257 );
2258
2259 let warnings_after = lint_with_config(&fixed, config);
2261 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2262 }
2263
2264 #[test]
2265 fn test_lazy_continuation_with_indented_content() {
2266 let content = "- Item 1\n Indented content\nLazy text";
2268 let config = MD032Config {
2269 allow_lazy_continuation: false,
2270 };
2271 let warnings = lint_with_config(content, config);
2272 assert_eq!(
2273 warnings.len(),
2274 1,
2275 "Should warn for lazy text after indented content. Got: {warnings:?}"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_lazy_continuation_properly_separated() {
2281 let content = "- Item 1\n\nSome text.";
2283 let config = MD032Config {
2284 allow_lazy_continuation: false,
2285 };
2286 let warnings = lint_with_config(content, config);
2287 assert_eq!(
2288 warnings.len(),
2289 0,
2290 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
2291 );
2292 }
2293
2294 #[test]
2297 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
2298 let content = "1) First item\nLazy continuation";
2300 let config = MD032Config {
2301 allow_lazy_continuation: false,
2302 };
2303 let warnings = lint_with_config(content, config.clone());
2304 assert_eq!(
2305 warnings.len(),
2306 1,
2307 "Should warn for lazy continuation with parenthesis marker"
2308 );
2309
2310 let fixed = fix_with_config(content, config);
2311 assert_eq!(fixed, "1) First item\n Lazy continuation");
2313 }
2314
2315 #[test]
2316 fn test_lazy_continuation_followed_by_another_list() {
2317 let content = "- Item 1\nSome text\n- Item 2";
2323 let config = MD032Config {
2324 allow_lazy_continuation: false,
2325 };
2326 let warnings = lint_with_config(content, config);
2327 assert_eq!(
2329 warnings.len(),
2330 1,
2331 "Should warn about lazy continuation within list. Got: {warnings:?}"
2332 );
2333 assert!(
2334 warnings[0].message.contains("Lazy continuation"),
2335 "Warning should be about lazy continuation"
2336 );
2337 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2338 }
2339
2340 #[test]
2341 fn test_lazy_continuation_multiple_in_document() {
2342 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2347 let config = MD032Config {
2348 allow_lazy_continuation: false,
2349 };
2350 let warnings = lint_with_config(content, config.clone());
2351 assert_eq!(
2353 warnings.len(),
2354 2,
2355 "Should warn for both lazy continuations. Got: {warnings:?}"
2356 );
2357
2358 let fixed = fix_with_config(content, config.clone());
2359 assert!(
2361 fixed.contains(" Lazy 1"),
2362 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2363 );
2364 assert!(
2365 fixed.contains(" Lazy 2"),
2366 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2367 );
2368
2369 let warnings_after = lint_with_config(&fixed, config);
2370 assert_eq!(
2372 warnings_after.len(),
2373 0,
2374 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_lazy_continuation_end_of_document_no_newline() {
2380 let content = "- Item\nNo trailing newline";
2382 let config = MD032Config {
2383 allow_lazy_continuation: false,
2384 };
2385 let warnings = lint_with_config(content, config.clone());
2386 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2387
2388 let fixed = fix_with_config(content, config);
2389 assert_eq!(fixed, "- Item\n No trailing newline");
2391 }
2392
2393 #[test]
2394 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2395 let content = "- Item 1\n---";
2398 let config = MD032Config {
2399 allow_lazy_continuation: false,
2400 };
2401 let warnings = lint_with_config(content, config.clone());
2402 assert_eq!(
2404 warnings.len(),
2405 1,
2406 "List should need blank line before thematic break. Got: {warnings:?}"
2407 );
2408
2409 let fixed = fix_with_config(content, config);
2411 assert_eq!(fixed, "- Item 1\n\n---");
2412 }
2413
2414 #[test]
2415 fn test_lazy_continuation_heading_not_flagged() {
2416 let content = "- Item 1\n# Heading";
2419 let config = MD032Config {
2420 allow_lazy_continuation: false,
2421 };
2422 let warnings = lint_with_config(content, config);
2423 assert!(
2426 warnings.iter().all(|w| !w.message.contains("lazy")),
2427 "Heading should not trigger lazy continuation warning"
2428 );
2429 }
2430
2431 #[test]
2432 fn test_lazy_continuation_mixed_list_types() {
2433 let content = "- Unordered\n1. Ordered\nLazy text";
2435 let config = MD032Config {
2436 allow_lazy_continuation: false,
2437 };
2438 let warnings = lint_with_config(content, config.clone());
2439 assert!(!warnings.is_empty(), "Should warn about structure issues");
2440 }
2441
2442 #[test]
2443 fn test_lazy_continuation_deep_nesting() {
2444 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2446 let config = MD032Config {
2447 allow_lazy_continuation: false,
2448 };
2449 let warnings = lint_with_config(content, config.clone());
2450 assert!(
2451 !warnings.is_empty(),
2452 "Should warn about lazy continuation after nested list"
2453 );
2454
2455 let fixed = fix_with_config(content, config.clone());
2456 let warnings_after = lint_with_config(&fixed, config);
2457 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2458 }
2459
2460 #[test]
2461 fn test_lazy_continuation_with_emphasis_in_text() {
2462 let content = "- Item\n*emphasized* continuation";
2464 let config = MD032Config {
2465 allow_lazy_continuation: false,
2466 };
2467 let warnings = lint_with_config(content, config.clone());
2468 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2469
2470 let fixed = fix_with_config(content, config);
2471 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2473 }
2474
2475 #[test]
2476 fn test_lazy_continuation_with_code_span() {
2477 let content = "- Item\n`code` continuation";
2479 let config = MD032Config {
2480 allow_lazy_continuation: false,
2481 };
2482 let warnings = lint_with_config(content, config.clone());
2483 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2484
2485 let fixed = fix_with_config(content, config);
2486 assert_eq!(fixed, "- Item\n `code` continuation");
2488 }
2489
2490 #[test]
2497 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2498 let content = r#"1. Create a new Chat conversation:
2501 - On the sidebar, select **New Chat**.
2502 - In the box, type `/new`.
2503 A new Chat conversation replaces the previous one.
25041. Under the Chat text box, turn off the toggle."#;
2505 let config = MD032Config {
2506 allow_lazy_continuation: false,
2507 };
2508 let warnings = lint_with_config(content, config);
2509 let lazy_warnings: Vec<_> = warnings
2511 .iter()
2512 .filter(|w| w.message.contains("Lazy continuation"))
2513 .collect();
2514 assert!(
2515 !lazy_warnings.is_empty(),
2516 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2517 );
2518 assert!(
2519 lazy_warnings.iter().any(|w| w.line == 4),
2520 "Should warn on line 4. Got: {lazy_warnings:?}"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2526 let content = r#"- `field`: Is the specific key:
2529 - `password`: Accesses the password.
2530 - `api_key`: Accesses the api_key.
2531 `token`: Specifies which ID token to use.
2532- `version_id`: Is the unique identifier."#;
2533 let config = MD032Config {
2534 allow_lazy_continuation: false,
2535 };
2536 let warnings = lint_with_config(content, config);
2537 let lazy_warnings: Vec<_> = warnings
2539 .iter()
2540 .filter(|w| w.message.contains("Lazy continuation"))
2541 .collect();
2542 assert!(
2543 !lazy_warnings.is_empty(),
2544 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2545 );
2546 assert!(
2547 lazy_warnings.iter().any(|w| w.line == 4),
2548 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2554 let content = r#"- Check out the branch, and test locally.
2556 - If the MR requires significant modifications:
2557 - **Skip local testing** and review instead.
2558 - **Request verification** from the author.
2559 - **Identify the minimal change** needed.
2560 Your testing might result in opportunities.
2561- If you don't understand, _say so_."#;
2562 let config = MD032Config {
2563 allow_lazy_continuation: false,
2564 };
2565 let warnings = lint_with_config(content, config);
2566 let lazy_warnings: Vec<_> = warnings
2568 .iter()
2569 .filter(|w| w.message.contains("Lazy continuation"))
2570 .collect();
2571 assert!(
2572 !lazy_warnings.is_empty(),
2573 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2574 );
2575 assert!(
2576 lazy_warnings.iter().any(|w| w.line == 6),
2577 "Should warn on line 6. Got: {lazy_warnings:?}"
2578 );
2579 }
2580
2581 #[test]
2582 fn test_issue295_ordered_list_nested_bullets_continuation() {
2583 let content = r#"# Test
2586
25871. First item.
2588 - Nested A.
2589 - Nested B.
2590 Continuation at outer level.
25911. Second item."#;
2592 let config = MD032Config {
2593 allow_lazy_continuation: false,
2594 };
2595 let warnings = lint_with_config(content, config);
2596 let lazy_warnings: Vec<_> = warnings
2598 .iter()
2599 .filter(|w| w.message.contains("Lazy continuation"))
2600 .collect();
2601 assert!(
2602 !lazy_warnings.is_empty(),
2603 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2604 );
2605 assert!(
2607 lazy_warnings.iter().any(|w| w.line == 6),
2608 "Should warn on line 6. Got: {lazy_warnings:?}"
2609 );
2610 }
2611
2612 #[test]
2613 fn test_issue295_multiple_lazy_lines_after_nested() {
2614 let content = r#"1. The device client receives a response.
2616 - Those defined by OAuth Framework.
2617 - Those specific to device authorization.
2618 Those error responses are described below.
2619 For more information on each response,
2620 see the documentation.
26211. Next step in the process."#;
2622 let config = MD032Config {
2623 allow_lazy_continuation: false,
2624 };
2625 let warnings = lint_with_config(content, config);
2626 let lazy_warnings: Vec<_> = warnings
2628 .iter()
2629 .filter(|w| w.message.contains("Lazy continuation"))
2630 .collect();
2631 assert!(
2632 lazy_warnings.len() >= 3,
2633 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2634 lazy_warnings.len()
2635 );
2636 }
2637
2638 #[test]
2639 fn test_issue295_properly_indented_not_lazy() {
2640 let content = r#"1. First item.
2642 - Nested A.
2643 - Nested B.
2644
2645 Properly indented continuation.
26461. Second item."#;
2647 let config = MD032Config {
2648 allow_lazy_continuation: false,
2649 };
2650 let warnings = lint_with_config(content, config);
2651 let lazy_warnings: Vec<_> = warnings
2653 .iter()
2654 .filter(|w| w.message.contains("Lazy continuation"))
2655 .collect();
2656 assert_eq!(
2657 lazy_warnings.len(),
2658 0,
2659 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2660 );
2661 }
2662
2663 #[test]
2670 fn test_html_comment_before_list_with_preceding_blank() {
2671 let content = "Some text.\n\n<!-- comment -->\n- List item";
2674 let warnings = lint(content);
2675 assert_eq!(
2676 warnings.len(),
2677 0,
2678 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_html_comment_after_list_with_following_blank() {
2684 let content = "- List item\n<!-- comment -->\n\nSome text.";
2686 let warnings = lint(content);
2687 assert_eq!(
2688 warnings.len(),
2689 0,
2690 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn test_list_inside_html_comment_ignored() {
2696 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2698 let warnings = lint(content);
2699 assert_eq!(
2700 warnings.len(),
2701 0,
2702 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2703 );
2704 }
2705
2706 #[test]
2707 fn test_multiline_html_comment_before_list() {
2708 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2710 let warnings = lint(content);
2711 assert_eq!(
2712 warnings.len(),
2713 0,
2714 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2715 );
2716 }
2717
2718 #[test]
2719 fn test_a_comment_line_separates_the_paragraph_from_the_list() {
2720 let content = "Some text.\n<!-- comment -->\n- List item";
2723 let warnings = lint(content);
2724 assert_eq!(
2725 warnings.len(),
2726 0,
2727 "A comment-only line separates the blocks around it. Got: {warnings:?}"
2728 );
2729 }
2730
2731 #[test]
2732 fn test_a_line_carrying_text_beside_a_comment_still_warns() {
2733 let content = "Some text. <!-- comment -->\n- List item";
2736 let warnings = lint(content);
2737 assert_eq!(
2738 warnings.len(),
2739 1,
2740 "A paragraph with a trailing comment is still a paragraph. Got: {warnings:?}"
2741 );
2742 assert!(
2743 warnings[0].message.contains("preceded by blank line"),
2744 "Should be 'preceded by blank line' warning"
2745 );
2746 }
2747
2748 #[test]
2749 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2750 let content = "- List item\n<!-- comment -->\nSome text.";
2753 let warnings = lint(content);
2754 assert_eq!(
2755 warnings.len(),
2756 0,
2757 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2758 );
2759 }
2760
2761 #[test]
2762 fn test_list_followed_by_heading_through_comment_should_warn() {
2763 let content = "- List item\n<!-- comment -->\n# Heading";
2765 let warnings = lint(content);
2766 assert!(
2769 warnings.len() <= 1,
2770 "Should handle heading after comment gracefully. Got: {warnings:?}"
2771 );
2772 }
2773
2774 #[test]
2775 fn test_html_comment_between_list_and_text_both_directions() {
2776 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2778 let warnings = lint(content);
2779 assert_eq!(
2780 warnings.len(),
2781 0,
2782 "Should not warn with proper separation through comments. Got: {warnings:?}"
2783 );
2784 }
2785
2786 #[test]
2787 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2788 let content = "Text.\n\n<!-- comment -->\n- Item";
2790 let fixed = fix(content);
2791 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2792 }
2793
2794 #[test]
2795 fn test_html_comment_fix_adds_blank_when_needed() {
2796 let separated = "Text.\n<!-- comment -->\n- Item";
2799 assert_eq!(
2800 fix(separated),
2801 separated,
2802 "A comment-only line needs no blank line inserted around it"
2803 );
2804
2805 let content = "Text. <!-- comment -->\n- Item";
2806 let fixed = fix(content);
2807 assert!(
2808 fixed.contains("Text. <!-- comment -->\n\n- Item"),
2809 "Fix should add blank line before list. Got: {fixed}"
2810 );
2811 }
2812
2813 #[test]
2814 fn test_ordered_list_inside_html_comment() {
2815 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2817 let warnings = lint(content);
2818 assert_eq!(
2819 warnings.len(),
2820 0,
2821 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2822 );
2823 }
2824
2825 #[test]
2832 fn test_blockquote_list_exit_no_warning() {
2833 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2835 let warnings = lint(content);
2836 assert_eq!(
2837 warnings.len(),
2838 0,
2839 "Should not warn when exiting blockquote. Got: {warnings:?}"
2840 );
2841 }
2842
2843 #[test]
2844 fn test_nested_blockquote_list_exit() {
2845 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2847 let warnings = lint(content);
2848 assert_eq!(
2849 warnings.len(),
2850 0,
2851 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2852 );
2853 }
2854
2855 #[test]
2856 fn test_blockquote_same_level_no_warning() {
2857 let content = "> - item 1\n> - item 2\n> Text after";
2860 let warnings = lint(content);
2861 assert_eq!(
2862 warnings.len(),
2863 0,
2864 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_blockquote_list_with_special_chars() {
2870 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2872 let warnings = lint(content);
2873 assert_eq!(
2874 warnings.len(),
2875 0,
2876 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2877 );
2878 }
2879
2880 #[test]
2881 fn test_lazy_continuation_whitespace_only_line() {
2882 let content = "- Item\n \nText after whitespace-only line";
2885 let config = MD032Config {
2886 allow_lazy_continuation: false,
2887 };
2888 let warnings = lint_with_config(content, config);
2889 assert_eq!(
2891 warnings.len(),
2892 0,
2893 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2894 );
2895 }
2896
2897 #[test]
2898 fn test_lazy_continuation_blockquote_context() {
2899 let content = "> - Item\n> Lazy in quote";
2901 let config = MD032Config {
2902 allow_lazy_continuation: false,
2903 };
2904 let warnings = lint_with_config(content, config);
2905 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2908 }
2909
2910 #[test]
2911 fn test_lazy_continuation_fix_preserves_content() {
2912 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2914 let config = MD032Config {
2915 allow_lazy_continuation: false,
2916 };
2917 let fixed = fix_with_config(content, config);
2918 assert!(fixed.contains("<>&"), "Should preserve special chars");
2919 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2920 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2922 }
2923
2924 #[test]
2925 fn test_lazy_continuation_fix_idempotent() {
2926 let content = "- Item\nLazy";
2928 let config = MD032Config {
2929 allow_lazy_continuation: false,
2930 };
2931 let fixed_once = fix_with_config(content, config.clone());
2932 let fixed_twice = fix_with_config(&fixed_once, config);
2933 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2934 }
2935
2936 #[test]
2937 fn test_lazy_continuation_config_default_allows() {
2938 let content = "- Item\nLazy text that continues";
2940 let default_config = MD032Config::default();
2941 assert!(
2942 default_config.allow_lazy_continuation,
2943 "Default should allow lazy continuation"
2944 );
2945 let warnings = lint_with_config(content, default_config);
2946 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2947 }
2948
2949 #[test]
2950 fn test_lazy_continuation_after_multi_line_item() {
2951 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2953 let config = MD032Config {
2954 allow_lazy_continuation: false,
2955 };
2956 let warnings = lint_with_config(content, config.clone());
2957 assert_eq!(
2958 warnings.len(),
2959 1,
2960 "Should warn only for the lazy line, not the indented line"
2961 );
2962 }
2963
2964 #[test]
2966 fn test_blockquote_list_with_continuation_and_nested() {
2967 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2970 let warnings = lint(content);
2971 assert_eq!(
2972 warnings.len(),
2973 0,
2974 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2975 );
2976 }
2977
2978 #[test]
2979 fn test_blockquote_list_simple() {
2980 let content = "> - item 1\n> - item 2";
2982 let warnings = lint(content);
2983 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2984 }
2985
2986 #[test]
2987 fn test_blockquote_list_with_continuation_only() {
2988 let content = "> - item 1\n> continuation\n> - item 2";
2990 let warnings = lint(content);
2991 assert_eq!(
2992 warnings.len(),
2993 0,
2994 "Blockquoted list with continuation should have no warnings"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_blockquote_list_with_lazy_continuation() {
3000 let content = "> - item 1\n> lazy continuation\n> - item 2";
3002 let warnings = lint(content);
3003 assert_eq!(
3004 warnings.len(),
3005 0,
3006 "Blockquoted list with lazy continuation should have no warnings"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_nested_blockquote_list() {
3012 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
3014 let warnings = lint(content);
3015 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
3016 }
3017
3018 #[test]
3019 fn test_blockquote_list_needs_preceding_blank() {
3020 let content = "> Text before\n> - item 1\n> - item 2";
3022 let warnings = lint(content);
3023 assert_eq!(
3024 warnings.len(),
3025 1,
3026 "Should warn for missing blank before blockquoted list"
3027 );
3028 }
3029
3030 #[test]
3031 fn test_blockquote_list_properly_separated() {
3032 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
3034 let warnings = lint(content);
3035 assert_eq!(
3036 warnings.len(),
3037 0,
3038 "Properly separated blockquoted list should have no warnings"
3039 );
3040 }
3041
3042 #[test]
3043 fn test_blockquote_ordered_list() {
3044 let content = "> 1. item 1\n> continuation\n> 2. item 2";
3046 let warnings = lint(content);
3047 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
3048 }
3049
3050 #[test]
3051 fn test_blockquote_list_with_empty_blockquote_line() {
3052 let content = "> - item 1\n>\n> - item 2";
3054 let warnings = lint(content);
3055 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
3056 }
3057
3058 #[test]
3060 fn test_blockquote_list_multi_paragraph_items() {
3061 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
3064 let warnings = lint(content);
3065 assert_eq!(
3066 warnings.len(),
3067 0,
3068 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3069 );
3070 }
3071
3072 #[test]
3074 fn test_blockquote_ordered_list_multi_paragraph_items() {
3075 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
3076 let warnings = lint(content);
3077 assert_eq!(
3078 warnings.len(),
3079 0,
3080 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
3081 );
3082 }
3083
3084 #[test]
3086 fn test_blockquote_list_multiple_continuations() {
3087 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
3088 let warnings = lint(content);
3089 assert_eq!(
3090 warnings.len(),
3091 0,
3092 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
3093 );
3094 }
3095
3096 #[test]
3098 fn test_nested_blockquote_multi_paragraph_list() {
3099 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
3100 let warnings = lint(content);
3101 assert_eq!(
3102 warnings.len(),
3103 0,
3104 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3105 );
3106 }
3107
3108 #[test]
3110 fn test_triple_nested_blockquote_multi_paragraph_list() {
3111 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
3112 let warnings = lint(content);
3113 assert_eq!(
3114 warnings.len(),
3115 0,
3116 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
3117 );
3118 }
3119
3120 #[test]
3122 fn test_blockquote_list_last_item_continuation() {
3123 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
3124 let warnings = lint(content);
3125 assert_eq!(
3126 warnings.len(),
3127 0,
3128 "Last item with continuation should have no warnings. Got: {warnings:?}"
3129 );
3130 }
3131
3132 #[test]
3134 fn test_blockquote_list_first_item_only_continuation() {
3135 let content = "> - Item 1\n> \n> Continuation of item 1\n";
3136 let warnings = lint(content);
3137 assert_eq!(
3138 warnings.len(),
3139 0,
3140 "Single item with continuation should have no warnings. Got: {warnings:?}"
3141 );
3142 }
3143
3144 #[test]
3148 fn test_blockquote_level_change_breaks_list() {
3149 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
3151 let warnings = lint(content);
3152 assert!(
3156 warnings.len() <= 2,
3157 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
3158 );
3159 }
3160
3161 #[test]
3163 fn test_exit_blockquote_needs_blank_before_list() {
3164 let content = "> Blockquote text\n\n- List outside blockquote\n";
3166 let warnings = lint(content);
3167 assert_eq!(
3168 warnings.len(),
3169 0,
3170 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
3171 );
3172
3173 let content2 = "> Blockquote text\n- List outside blockquote\n";
3177 let warnings2 = lint(content2);
3178 assert!(
3180 warnings2.len() <= 1,
3181 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
3182 );
3183 }
3184
3185 #[test]
3187 fn test_blockquote_multi_paragraph_all_unordered_markers() {
3188 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
3190 let warnings = lint(content_dash);
3191 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
3192
3193 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
3195 let warnings = lint(content_asterisk);
3196 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
3197
3198 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
3200 let warnings = lint(content_plus);
3201 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
3202 }
3203
3204 #[test]
3206 fn test_blockquote_multi_paragraph_parenthesis_marker() {
3207 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
3208 let warnings = lint(content);
3209 assert_eq!(
3210 warnings.len(),
3211 0,
3212 "Parenthesis ordered markers should work. Got: {warnings:?}"
3213 );
3214 }
3215
3216 #[test]
3218 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
3219 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
3221 let warnings = lint(content);
3222 assert_eq!(
3223 warnings.len(),
3224 0,
3225 "Multi-digit ordered list should work. Got: {warnings:?}"
3226 );
3227 }
3228
3229 #[test]
3231 fn test_blockquote_multi_paragraph_with_formatting() {
3232 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
3233 let warnings = lint(content);
3234 assert_eq!(
3235 warnings.len(),
3236 0,
3237 "Continuation with inline formatting should work. Got: {warnings:?}"
3238 );
3239 }
3240
3241 #[test]
3243 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
3244 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
3245 let warnings = lint(content);
3246 assert_eq!(
3247 warnings.len(),
3248 0,
3249 "All items with continuations should work. Got: {warnings:?}"
3250 );
3251 }
3252
3253 #[test]
3255 fn test_blockquote_multi_paragraph_lowercase_continuation() {
3256 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
3257 let warnings = lint(content);
3258 assert_eq!(
3259 warnings.len(),
3260 0,
3261 "Lowercase continuation should work. Got: {warnings:?}"
3262 );
3263 }
3264
3265 #[test]
3267 fn test_blockquote_multi_paragraph_uppercase_continuation() {
3268 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
3269 let warnings = lint(content);
3270 assert_eq!(
3271 warnings.len(),
3272 0,
3273 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
3274 );
3275 }
3276
3277 #[test]
3279 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
3280 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
3282 let warnings = lint(content);
3283 assert!(
3285 warnings.len() <= 1,
3286 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
3287 );
3288 }
3289
3290 #[test]
3292 fn test_blockquote_multi_paragraph_bare_marker_blank() {
3293 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
3295 let warnings = lint(content);
3296 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
3297 }
3298
3299 #[test]
3300 fn test_blockquote_list_varying_spaces_after_marker() {
3301 let content = "> - item 1\n> continuation with more indent\n> - item 2";
3303 let warnings = lint(content);
3304 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
3305 }
3306
3307 #[test]
3308 fn test_deeply_nested_blockquote_list() {
3309 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
3311 let warnings = lint(content);
3312 assert_eq!(
3313 warnings.len(),
3314 0,
3315 "Deeply nested blockquote list should have no warnings"
3316 );
3317 }
3318
3319 #[test]
3320 fn test_blockquote_level_change_in_list() {
3321 let content = "> - item 1\n>> - deeper item\n> - item 2";
3323 let warnings = lint(content);
3326 assert!(
3327 !warnings.is_empty(),
3328 "Blockquote level change should break list and trigger warnings"
3329 );
3330 }
3331
3332 #[test]
3333 fn test_blockquote_list_with_code_span() {
3334 let content = "> - item with `code`\n> continuation\n> - item 2";
3336 let warnings = lint(content);
3337 assert_eq!(
3338 warnings.len(),
3339 0,
3340 "Blockquote list with code span should have no warnings"
3341 );
3342 }
3343
3344 #[test]
3345 fn test_code_span_html_comment_delimiters_no_false_positive() {
3346 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";
3352 let warnings = lint(content);
3353 assert_eq!(
3354 warnings.len(),
3355 0,
3356 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3362 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";
3367 let fixed = fix(content);
3368 assert_eq!(
3369 fixed, content,
3370 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3371 );
3372 }
3373
3374 #[test]
3375 fn test_blockquote_list_at_document_end() {
3376 let content = "> Some text\n>\n> - item 1\n> - item 2";
3378 let warnings = lint(content);
3379 assert_eq!(
3380 warnings.len(),
3381 0,
3382 "Blockquote list at document end should have no warnings"
3383 );
3384 }
3385
3386 #[test]
3387 fn test_fix_preserves_blockquote_prefix_before_list() {
3388 let content = "> Text before
3390> - Item 1
3391> - Item 2";
3392 let fixed = fix(content);
3393
3394 let expected = "> Text before
3396>
3397> - Item 1
3398> - Item 2";
3399 assert_eq!(
3400 fixed, expected,
3401 "Fix should insert '>' blank line, not plain blank line"
3402 );
3403 }
3404
3405 #[test]
3406 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3407 let content = ">>> Triple nested
3410>>> - Item 1
3411>>> - Item 2
3412>>> More text";
3413 let fixed = fix(content);
3414
3415 let expected = ">>> Triple nested
3417>>>
3418>>> - Item 1
3419>>> - Item 2
3420>>> More text";
3421 assert_eq!(
3422 fixed, expected,
3423 "Fix should preserve triple-nested blockquote prefix '>>>'"
3424 );
3425 }
3426
3427 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3430 let rule = MD032BlanksAroundLists::default();
3431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3432 rule.check(&ctx).unwrap()
3433 }
3434
3435 #[test]
3436 fn test_quarto_list_after_div_open() {
3437 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3439 let warnings = lint_quarto(content);
3440 assert!(
3442 warnings.is_empty(),
3443 "Quarto div marker should be transparent before list: {warnings:?}"
3444 );
3445 }
3446
3447 #[test]
3448 fn test_quarto_list_before_div_close() {
3449 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3451 let warnings = lint_quarto(content);
3452 assert!(
3454 warnings.is_empty(),
3455 "Quarto div marker should be transparent after list: {warnings:?}"
3456 );
3457 }
3458
3459 #[test]
3460 fn test_quarto_list_needs_blank_without_div() {
3461 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3463 let warnings = lint_quarto(content);
3464 assert!(
3467 !warnings.is_empty(),
3468 "Should still require blank when not present: {warnings:?}"
3469 );
3470 }
3471
3472 #[test]
3473 fn test_quarto_list_in_callout_with_content() {
3474 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3476 let warnings = lint_quarto(content);
3477 assert!(
3478 warnings.is_empty(),
3479 "List with proper blanks inside callout should pass: {warnings:?}"
3480 );
3481 }
3482
3483 #[test]
3484 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3485 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3487 let warnings = lint(content); assert!(
3490 !warnings.is_empty(),
3491 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3492 );
3493 }
3494
3495 #[test]
3496 fn test_quarto_nested_divs_with_list() {
3497 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3499 let warnings = lint_quarto(content);
3500 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3501 }
3502
3503 #[test]
3504 fn test_issue512_complex_nested_list_with_continuation() {
3505 let content = "\
3508- First level of indentation.
3509 - Second level of indentation.
3510 - Third level of indentation.
3511 - Third level of indentation.
3512
3513 Second level list continuation.
3514
3515 First level list continuation.
3516- First level of indentation.
3517";
3518 let warnings = lint(content);
3519 assert!(
3520 warnings.is_empty(),
3521 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3522 );
3523 }
3524
3525 #[test]
3526 fn test_issue512_continuation_at_root_level() {
3527 let content = "\
3531- First level.
3532 - Second level.
3533
3534 First level continuation.
3535
3536Root level lazy continuation.
3537- Another first level item.
3538";
3539 let warnings = lint(content);
3540 assert_eq!(
3541 warnings.len(),
3542 1,
3543 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3544 );
3545 assert_eq!(warnings[0].line, 7);
3546 }
3547
3548 #[test]
3549 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3550 let content = "\
3552- Level 1 item.
3553 - Level 2 item.
3554 - Level 3 item.
3555
3556 Level 3 continuation.
3557
3558 Level 2 continuation.
3559
3560 Level 1 continuation (indented under marker).
3561- Another level 1 item.
3562";
3563 let warnings = lint(content);
3564 assert!(
3565 warnings.is_empty(),
3566 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3567 );
3568 }
3569
3570 #[test]
3571 fn test_pandoc_list_after_div_open() {
3572 let rule = MD032BlanksAroundLists::default();
3575 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3577 let warnings = rule.check(&ctx).unwrap();
3578 assert!(
3579 warnings.is_empty(),
3580 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3581 );
3582 }
3583
3584 #[test]
3585 fn test_md032_html_comment() {
3586 let rule = MD032BlanksAroundLists::default();
3587 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3589 let warnings = rule.check(&ctx).unwrap();
3590 assert!(
3591 warnings.is_empty(),
3592 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3593 );
3594 }
3595
3596 #[test]
3597 fn test_mkdocs_admonition_nested_ordered_list_not_flagged() {
3598 let rule = MD032BlanksAroundLists::default();
3604 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";
3605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3606 let warnings = rule.check(&ctx).unwrap();
3607 assert!(
3608 warnings.is_empty(),
3609 "admonition-nested ordered list should not be flagged: {warnings:?}"
3610 );
3611 }
3612
3613 #[test]
3614 fn test_mkdocs_admonition_nested_ordered_list_cascade_not_flagged() {
3615 let rule = MD032BlanksAroundLists::default();
3619 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";
3620 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3621 let warnings = rule.check(&ctx).unwrap();
3622 assert!(
3623 warnings.is_empty(),
3624 "cascading admonition-nested ordered list should not be flagged: {warnings:?}"
3625 );
3626 }
3627
3628 #[test]
3629 fn test_mkdocs_content_tab_nested_ordered_list_not_flagged() {
3630 let rule = MD032BlanksAroundLists::default();
3632 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";
3633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3634 let warnings = rule.check(&ctx).unwrap();
3635 assert!(
3636 warnings.is_empty(),
3637 "content-tab-nested ordered list should not be flagged: {warnings:?}"
3638 );
3639 }
3640
3641 #[test]
3642 fn test_mkdocs_admonition_prose_then_non1_item_still_flagged() {
3643 let rule = MD032BlanksAroundLists::default();
3649 let content = "1. no error here\n\n!!! example\n\n Intro.\n 2. item\n";
3650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3651 let warnings = rule.check(&ctx).unwrap();
3652 assert_eq!(
3653 warnings.len(),
3654 1,
3655 "prose then non-1 item inside an admonition must stay flagged: {warnings:?}"
3656 );
3657 }
3658
3659 #[test]
3660 fn test_mkdocs_admonition_prose_after_list_item_breaks_continuation() {
3661 let rule = MD032BlanksAroundLists::default();
3666 let content = "1. no error here\n\n!!! example\n\n 1. one.\n Intro prose.\n 2. two\n";
3667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3668 let warnings = rule.check(&ctx).unwrap();
3669 assert_eq!(
3670 warnings.len(),
3671 1,
3672 "prose at item indent breaks the list continuation, item must stay flagged: {warnings:?}"
3673 );
3674 }
3675
3676 #[test]
3677 fn test_mkdocs_admonition_wrapped_item_continuation_not_flagged() {
3678 let rule = MD032BlanksAroundLists::default();
3683 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";
3684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3685 let warnings = rule.check(&ctx).unwrap();
3686 assert!(
3687 warnings.is_empty(),
3688 "wrapped continuation of a nested list item must not be flagged: {warnings:?}"
3689 );
3690 }
3691
3692 #[test]
3693 fn test_mkdocs_ambiguous_prose_non1_ordered_item_still_flagged() {
3694 let rule = MD032BlanksAroundLists::default();
3700 let content = "1. no error here\n\nno error here.\n2. error here because previous line ends with a period.\n";
3701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3702 let warnings = rule.check(&ctx).unwrap();
3703 assert_eq!(
3704 warnings.len(),
3705 1,
3706 "ambiguous non-1 ordered item outside any container should still be flagged: {warnings:?}"
3707 );
3708 assert_eq!(warnings[0].line, 4);
3709 assert!(warnings[0].message.contains("non-1"));
3710 }
3711
3712 #[test]
3713 fn test_mkdocs_admonition_nested_list_without_trailing_punctuation_not_flagged() {
3714 let rule = MD032BlanksAroundLists::default();
3720 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";
3721 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3722 let warnings = rule.check(&ctx).unwrap();
3723 assert!(
3724 warnings.is_empty(),
3725 "admonition-nested ordered list without trailing punctuation should not be flagged: {warnings:?}"
3726 );
3727 }
3728
3729 #[test]
3730 fn test_standard_flavor_admonition_indented_list_unchanged() {
3731 let rule = MD032BlanksAroundLists::default();
3736 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";
3737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3738 let warnings = rule.check(&ctx).unwrap();
3739 assert!(
3740 warnings.is_empty(),
3741 "indented code block under standard flavor should not be flagged: {warnings:?}"
3742 );
3743 }
3744
3745 #[test]
3746 fn test_mkdocs_html_markdown_div_nested_ordered_list_still_flagged() {
3747 let rule = MD032BlanksAroundLists::default();
3752 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";
3753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
3754 let warnings = rule.check(&ctx).unwrap();
3755 assert_eq!(
3756 warnings.len(),
3757 1,
3758 "markdown=\"1\" div nested ordered list behavior must stay unchanged: {warnings:?}"
3759 );
3760 assert_eq!(warnings[0].line, 6);
3761 }
3762
3763 #[test]
3764 fn test_pseudo_list_marker_after_list() {
3765 let content = indoc::indoc! {"
3766 - Item 1
3767 Item 1 content.
3768
3769 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3770 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3771 are not used except when those semantics are desired.
3772 "};
3773 let warnings = lint(content);
3774 assert!(
3775 warnings.is_empty(),
3776 "Expected no warnings for pseudo-list marker after list, but got: {warnings:?}"
3777 );
3778 }
3779
3780 #[test]
3781 fn test_pseudo_list_marker_without_preceding_list() {
3782 let content = indoc::indoc! {"
3783 The unsigned-integer types may be written `uN`, with `N` a positive multiple of
3784 8. Unsigned integer types wrap around on overflow; we strongly advise that they
3785 are not used except when those semantics are desired.
3786 "};
3787 let warnings = lint(content);
3788 assert!(
3789 warnings.is_empty(),
3790 "Expected no warnings for pseudo-list marker without preceding list, but got: {warnings:?}"
3791 );
3792 }
3793
3794 #[test]
3795 fn test_no_space_hash_continuation_line_stays_in_its_item() {
3796 let content = indoc::indoc! {"
3801 5. **`M.md`** - the deltas (esp. items #1,
3802 #2, #3, #5, #8).
3803
3804 ---
3805
3806 ## Plan
3807
3808 ### Phase 0
3809 - [ ] task one
3810 wrapped
3811 "};
3812 let warnings = lint(content);
3813 assert_eq!(
3814 warnings.len(),
3815 1,
3816 "only the task list is missing a blank line, got: {warnings:?}"
3817 );
3818 assert_eq!(warnings[0].line, 9);
3819 assert_eq!(warnings[0].message, "List should be preceded by blank line");
3820
3821 let expected = indoc::indoc! {"
3822 5. **`M.md`** - the deltas (esp. items #1,
3823 #2, #3, #5, #8).
3824
3825 ---
3826
3827 ## Plan
3828
3829 ### Phase 0
3830
3831 - [ ] task one
3832 wrapped
3833 "};
3834 assert_eq!(fix(content), expected);
3835 }
3836
3837 #[test]
3838 fn test_fix_keeps_tight_continuation_attached_while_fixing_elsewhere() {
3839 let content = indoc::indoc! {"
3845 1. first
3846
3847 3. item
3848 continuation
3849
3850 1. nested
3851 2. nested
3852
3853 ## Heading
3854 - task
3855 "};
3856 let warnings = lint(content);
3857 assert_eq!(
3858 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3859 vec![10],
3860 "only the list after the heading is missing a blank line, got: {warnings:?}"
3861 );
3862
3863 let expected = indoc::indoc! {"
3864 1. first
3865
3866 3. item
3867 continuation
3868
3869 1. nested
3870 2. nested
3871
3872 ## Heading
3873
3874 - task
3875 "};
3876 assert_eq!(fix(content), expected);
3877 }
3878
3879 #[test]
3880 fn test_no_space_hash_lazy_continuation_stays_in_its_item() {
3881 let content = indoc::indoc! {"
3885 - item (esp. #1,
3886 #2, #3).
3887 - next item
3888
3889 ## Heading
3890 - task
3891 "};
3892 let warnings = lint(content);
3893 assert_eq!(
3894 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3895 vec![6],
3896 "only the list after the heading is missing a blank line, got: {warnings:?}"
3897 );
3898
3899 let expected = indoc::indoc! {"
3900 - item (esp. #1,
3901 #2, #3).
3902 - next item
3903
3904 ## Heading
3905
3906 - task
3907 "};
3908 assert_eq!(fix(content), expected);
3909 }
3910
3911 #[test]
3912 fn test_under_indented_continuation_lines_stay_in_their_item() {
3913 for content in [
3917 "1. Helps to avoid situations\n changes that the team might not accept\n changes are in a direction.\n",
3918 "> 1. Helps to avoid situations\n> changes that the team might not accept\n> changes are in a direction.\n",
3919 "- Item\n lazy continuation\n- another item\n",
3920 "> - Item\n> lazy continuation\n> - another item\n",
3921 ] {
3922 let warnings = lint(content);
3923 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
3924 assert_eq!(fix(content), content, "{content:?}");
3925 }
3926 }
3927
3928 #[test]
3929 fn test_under_indented_continuation_lines_are_lazy_when_lazy_is_disallowed() {
3930 let config = MD032Config {
3933 allow_lazy_continuation: false,
3934 };
3935 for (content, lazy_lines) in [
3936 ("- Item\n lazy continuation\n- another item\n", vec![2]),
3937 ("> - Item\n> lazy continuation\n> - another item\n", vec![2]),
3938 ("> 1. Item\n> changes that\n> changes are\n> 2. next\n", vec![2, 3]),
3939 ] {
3940 let warnings = lint_with_config(content, config.clone());
3941 assert!(
3942 warnings.iter().all(|w| w.message.contains("Lazy continuation")),
3943 "{content:?}: got {warnings:?}"
3944 );
3945 assert_eq!(
3946 warnings.iter().map(|w| w.line).collect::<Vec<_>>(),
3947 lazy_lines,
3948 "{content:?}: got {warnings:?}"
3949 );
3950 }
3951 }
3952
3953 #[test]
3954 fn test_structural_line_at_short_indent_ends_the_list() {
3955 for (content, expected) in [
3959 ("1. item\n ---\n", "1. item\n\n ---\n"),
3960 ("1. item\n ## Heading\n", "1. item\n\n ## Heading\n"),
3961 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3962 ("> 1. item\n> ---\n", "> 1. item\n>\n> ---\n"),
3963 ] {
3964 let warnings = lint(content);
3965 assert_eq!(
3966 warnings
3967 .iter()
3968 .map(|w| (w.line, w.message.as_str()))
3969 .collect::<Vec<_>>(),
3970 vec![(1, "List should be followed by blank line")],
3971 "{content:?}: got {warnings:?}"
3972 );
3973 assert_eq!(fix(content), expected, "{content:?}");
3974 }
3975 }
3976
3977 #[test]
3978 fn test_html_block_at_short_indent_ends_the_list() {
3979 for (content, expected) in [
3986 (
3987 "- item\n<script>\nx\n</script>\n- next\n",
3988 "- item\n\n<script>\nx\n</script>\n\n- next\n",
3989 ),
3990 (
3991 "- item\n <script>\n x\n </script>\n- next\n",
3992 "- item\n\n <script>\n x\n </script>\n\n- next\n",
3993 ),
3994 (
3995 "- item\n <pre>\n x\n </pre>\n- next\n",
3996 "- item\n\n <pre>\n x\n </pre>\n\n- next\n",
3997 ),
3998 (
3999 "> - item\n> <script>\n> x\n> </script>\n> - next\n",
4000 "> - item\n>\n> <script>\n> x\n> </script>\n>\n> - next\n",
4001 ),
4002 (
4003 "> - item\n> <pre>\n> x\n> </pre>\n> - next\n",
4004 "> - item\n>\n> <pre>\n> x\n> </pre>\n>\n> - next\n",
4005 ),
4006 ] {
4007 let warnings = lint(content);
4008 assert_eq!(
4009 warnings
4010 .iter()
4011 .map(|w| (w.line, w.message.as_str()))
4012 .collect::<Vec<_>>(),
4013 vec![
4014 (1, "List should be followed by blank line"),
4015 (5, "List should be preceded by blank line"),
4016 ],
4017 "{content:?}: got {warnings:?}"
4018 );
4019 assert_eq!(fix(content), expected, "{content:?}");
4020 }
4021 }
4022
4023 #[test]
4024 fn test_html_looking_text_at_short_indent_is_a_lazy_continuation() {
4025 for content in [
4037 "100. item\n <div>\n101. next\n",
4038 "> 100. item\n> <div>\n> 101. next\n",
4039 "100. item\n <div>\ntext\n101. next\n",
4040 "- item\n<div.class>\n- next\n",
4041 "> - item\n> <div.class>\n> - next\n",
4042 "100. item\n\t<div>\n101. next\n",
4043 "- item\n \t<div>\n- next\n",
4044 "> - item\n> \t<div>\n> - next\n",
4045 "> - item\n>\t<div>\n> - next\n",
4046 "> 100. item\n> \t<div>\n> 101. next\n",
4047 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4048 "- outer\n - inner\n <script>\n x\n </script>\n- next\n",
4049 "> - outer\n> - inner\n> <div>\n> x\n> </div>\n> - next\n",
4050 ] {
4051 let warnings = lint(content);
4052 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4053 assert_eq!(fix(content), content, "{content:?}");
4054 }
4055
4056 for (content, last_item_line) in [
4060 ("100. item\n <div>\n101. next\n", 1),
4061 ("> 100. item\n> <div>\n> 101. next\n", 1),
4062 ("- item\n <div>\n- next\n", 1),
4063 ("> 1. item\n> \t<div>\n> 2. next\n", 1),
4064 ("> 1. item\n>\t<div>\n> 2. next\n", 1),
4065 ("1. outer\n 1. inner\n <div>\n2. next\n", 2),
4066 ] {
4067 let warnings = lint(content);
4068 assert_eq!(
4069 warnings
4070 .iter()
4071 .map(|w| (w.line, w.message.as_str()))
4072 .collect::<Vec<_>>(),
4073 vec![(last_item_line, "List should be followed by blank line")],
4074 "{content:?}: got {warnings:?}"
4075 );
4076 }
4077 }
4078
4079 #[test]
4080 fn test_tab_indented_nested_list_stays_inside_its_item() {
4081 for content in [
4090 "* item text\n\t1. nested\n\t more\n",
4091 "* item text\n\tcontinuation\n\t1. nested\n",
4092 "1. item text\n\t- nested\n",
4093 "> * item text\n>\t1. nested\n",
4094 "> * item text\n> \t1. nested\n",
4095 "* item text\n\tcontinuation\n\t- nested\n",
4096 ] {
4097 let warnings = lint(content);
4098 assert!(warnings.is_empty(), "{content:?}: got {warnings:?}");
4099 assert_eq!(fix(content), content, "{content:?}");
4100 }
4101
4102 for content in ["* item text\n 1. nested\n", "> * item text\n> 1. nested\n"] {
4105 let warnings = lint(content);
4106 assert_eq!(
4107 warnings
4108 .iter()
4109 .map(|w| (w.line, w.message.as_str()))
4110 .collect::<Vec<_>>(),
4111 vec![
4112 (1, "List should be followed by blank line"),
4113 (2, "List should be preceded by blank line"),
4114 ],
4115 "{content:?}: got {warnings:?}"
4116 );
4117 }
4118 }
4119
4120 #[test]
4121 fn test_list_marker_inside_an_unclosed_html_block_is_html() {
4122 for (content, expected) in [
4127 (
4128 "- item\n<div>\nx\n</div>\n- next\n",
4129 "- item\n\n<div>\nx\n</div>\n- next\n",
4130 ),
4131 (
4132 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4133 "> - item\n>\n> <div>\n> x\n> </div>\n> - next\n",
4134 ),
4135 ] {
4136 let warnings = lint(content);
4137 assert_eq!(
4138 warnings
4139 .iter()
4140 .map(|w| (w.line, w.message.as_str()))
4141 .collect::<Vec<_>>(),
4142 vec![(1, "List should be followed by blank line")],
4143 "{content:?}: got {warnings:?}"
4144 );
4145 assert_eq!(fix(content), expected, "{content:?}");
4146 }
4147 }
4148
4149 #[test]
4150 fn test_html_block_at_content_column_is_item_content() {
4151 for content in [
4154 "- item\n <script>\n x\n </script>\n- next\n",
4155 "- item\n <div>\n x\n </div>\n- next\n",
4156 "1. item\n <pre>\n x\n </pre>\n2. next\n",
4157 "> - item\n> <div>\n> x\n> </div>\n> - next\n",
4158 ] {
4159 assert!(lint(content).is_empty(), "{content:?}: got {:?}", lint(content));
4160 assert_eq!(fix(content), content, "{content:?}");
4161 }
4162 }
4163}