1use crate::lint_context::LazyContLine;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::pandoc;
6use crate::utils::range_utils::{LineIndex, calculate_line_range};
7use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17fn is_thematic_break(line: &str) -> bool {
20 if calculate_indentation_width_default(line) > 3 {
22 return false;
23 }
24
25 let trimmed = line.trim();
26 if trimmed.len() < 3 {
27 return false;
28 }
29
30 let chars: Vec<char> = trimmed.chars().collect();
31 let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33 if let Some(&marker) = first_non_space {
34 if marker != '-' && marker != '*' && marker != '_' {
35 return false;
36 }
37 let marker_count = chars.iter().filter(|&&c| c == marker).count();
38 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39 marker_count >= 3 && other_count == 0
40 } else {
41 false
42 }
43}
44
45#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116 config: MD032Config,
117}
118
119impl MD032BlanksAroundLists {
120 pub fn from_config_struct(config: MD032Config) -> Self {
121 Self { config }
122 }
123}
124
125impl MD032BlanksAroundLists {
126 fn should_require_blank_line_before(
128 ctx: &crate::lint_context::LintContext,
129 prev_line_num: usize,
130 current_line_num: usize,
131 ) -> bool {
132 if ctx
134 .line_info(prev_line_num)
135 .is_some_and(|info| info.in_code_block || info.in_front_matter)
136 {
137 return true;
138 }
139
140 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
142 return false;
143 }
144
145 true
147 }
148
149 fn is_nested_list(
151 ctx: &crate::lint_context::LintContext,
152 prev_line_num: usize, current_line_num: usize, ) -> bool {
155 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
157 let current_line = &ctx.lines[current_line_num - 1];
158 if current_line.indent >= 2 {
159 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
161 let prev_line = &ctx.lines[prev_line_num - 1];
162 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
164 return true;
165 }
166 }
167 }
168 }
169 false
170 }
171
172 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
175 ctx.lines
176 .get(line_num.saturating_sub(1))
177 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
178 }
179
180 fn calculate_lazy_continuation_fix(
183 ctx: &crate::lint_context::LintContext,
184 line_num: usize,
185 lazy_info: &LazyContLine,
186 ) -> Option<Fix> {
187 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
188 let line_content = line_info.content(ctx.content);
189
190 if lazy_info.blockquote_level == 0 {
191 let start_byte = line_info.byte_offset;
193 let end_byte = start_byte + lazy_info.current_indent;
194 let replacement = " ".repeat(lazy_info.expected_indent);
195
196 Some(Fix::new(start_byte..end_byte, replacement))
197 } else {
198 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
200 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
201 if prefix_byte_len == 0 {
202 return None;
203 }
204
205 let current_indent = after_bq.len() - after_bq.trim_start().len();
206 let start_byte = line_info.byte_offset + prefix_byte_len;
207 let end_byte = start_byte + current_indent;
208 let replacement = " ".repeat(lazy_info.expected_indent);
209
210 Some(Fix::new(start_byte..end_byte, replacement))
211 }
212 }
213
214 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
217 if lazy_info.blockquote_level == 0 {
218 let content = line.trim_start();
220 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
221 } else {
222 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
224 let prefix_len = line.len().saturating_sub(after_bq.len());
225 if prefix_len == 0 {
226 return line.to_string();
227 }
228
229 let prefix = &line[..prefix_len];
230 let rest = after_bq.trim_start();
231 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
232 }
233 }
234
235 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
243 let is_pandoc = ctx.flavor.is_pandoc_compatible();
244 for line_num in (1..before_line).rev() {
245 let idx = line_num - 1;
246 if let Some(info) = ctx.lines.get(idx) {
247 if info.in_html_comment || info.in_mdx_comment {
249 continue;
250 }
251 if is_pandoc {
253 let trimmed = info.content(ctx.content).trim();
254 if pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed) {
255 continue;
256 }
257 }
258 return (line_num, info.is_blank);
259 }
260 }
261 (0, true)
263 }
264
265 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
272 let is_pandoc = ctx.flavor.is_pandoc_compatible();
273 let num_lines = ctx.lines.len();
274 for line_num in (after_line + 1)..=num_lines {
275 let idx = line_num - 1;
276 if let Some(info) = ctx.lines.get(idx) {
277 if info.in_html_comment || info.in_mdx_comment {
279 continue;
280 }
281 if is_pandoc {
283 let trimmed = info.content(ctx.content).trim();
284 if pandoc::is_div_open(trimmed) || pandoc::is_div_close(trimmed) {
285 continue;
286 }
287 }
288 return (line_num, info.is_blank);
289 }
290 }
291 (0, true)
293 }
294
295 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
297 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
298
299 for block in &ctx.list_blocks {
300 if ctx
302 .line_info(block.start_line)
303 .is_some_and(|info| info.in_footnote_definition)
304 {
305 continue;
306 }
307
308 let mut segments: Vec<(usize, usize)> = Vec::new();
314 let mut current_start = block.start_line;
315 let mut prev_item_line = 0;
316
317 let get_blockquote_level = |line_num: usize| -> usize {
319 if line_num == 0 || line_num > ctx.lines.len() {
320 return 0;
321 }
322 let line_content = ctx.lines[line_num - 1].content(ctx.content);
323 BLOCKQUOTE_PREFIX_RE
324 .find(line_content)
325 .map_or(0, |m| m.as_str().chars().filter(|&c| c == '>').count())
326 };
327
328 let mut prev_bq_level = 0;
329
330 for &item_line in &block.item_lines {
331 let current_bq_level = get_blockquote_level(item_line);
332
333 if prev_item_line > 0 {
334 let blockquote_level_changed = prev_bq_level != current_bq_level;
336
337 let mut has_standalone_code_fence = false;
340
341 let min_indent_for_content = if block.is_ordered {
343 3 } else {
347 2 };
350
351 for check_line in (prev_item_line + 1)..item_line {
352 if check_line - 1 < ctx.lines.len() {
353 let line = &ctx.lines[check_line - 1];
354 let line_content = line.content(ctx.content);
355 if line.in_code_block
356 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
357 {
358 if line.indent < min_indent_for_content {
361 has_standalone_code_fence = true;
362 break;
363 }
364 }
365 }
366 }
367
368 if has_standalone_code_fence || blockquote_level_changed {
369 segments.push((current_start, prev_item_line));
371 current_start = item_line;
372 }
373 }
374 prev_item_line = item_line;
375 prev_bq_level = current_bq_level;
376 }
377
378 if prev_item_line > 0 {
381 segments.push((current_start, prev_item_line));
382 }
383
384 let has_code_fence_splits = segments.len() > 1 && {
386 let mut found_fence = false;
388 for i in 0..segments.len() - 1 {
389 let seg_end = segments[i].1;
390 let next_start = segments[i + 1].0;
391 for check_line in (seg_end + 1)..next_start {
393 if check_line - 1 < ctx.lines.len() {
394 let line = &ctx.lines[check_line - 1];
395 let line_content = line.content(ctx.content);
396 if line.in_code_block
397 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
398 {
399 found_fence = true;
400 break;
401 }
402 }
403 }
404 if found_fence {
405 break;
406 }
407 }
408 found_fence
409 };
410
411 for (start, end) in &segments {
413 let mut actual_end = *end;
415
416 if !has_code_fence_splits && *end < block.end_line {
419 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
421
422 let min_continuation_indent = if block_bq_level > 0 {
425 if block.is_ordered {
427 block.max_marker_width
428 } else {
429 2 }
431 } else {
432 ctx.lines
433 .get(*end - 1)
434 .and_then(|line_info| line_info.list_item.as_ref())
435 .map_or(2, |item| item.content_column)
436 };
437
438 for check_line in (*end + 1)..=block.end_line {
439 if check_line - 1 < ctx.lines.len() {
440 let line = &ctx.lines[check_line - 1];
441 let line_content = line.content(ctx.content);
442 if block.item_lines.contains(&check_line) || line.heading.is_some() {
444 break;
445 }
446 if line.in_code_block {
448 break;
449 }
450
451 let effective_indent =
453 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
454
455 if effective_indent >= min_continuation_indent {
457 actual_end = check_line;
458 }
459 else if !line.is_blank
464 && line.heading.is_none()
465 && !block.item_lines.contains(&check_line)
466 && !is_thematic_break(line_content)
467 {
468 actual_end = check_line;
470 } else if !line.is_blank {
471 break;
473 }
474 }
475 }
476 }
477
478 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
479 }
480 }
481
482 blocks.retain(|(start, end, _)| {
484 let all_in_comment = (*start..=*end).all(|line_num| {
486 ctx.lines
487 .get(line_num - 1)
488 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
489 });
490 !all_in_comment
491 });
492
493 blocks
494 }
495
496 fn perform_checks(
497 &self,
498 ctx: &crate::lint_context::LintContext,
499 lines: &[&str],
500 list_blocks: &[(usize, usize, String)],
501 line_index: &LineIndex,
502 ) -> Vec<LintWarning> {
503 let mut warnings = Vec::new();
504 let num_lines = lines.len();
505
506 for (line_idx, line) in lines.iter().enumerate() {
509 let line_num = line_idx + 1;
510
511 let is_in_list = list_blocks
513 .iter()
514 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
515 if is_in_list {
516 continue;
517 }
518
519 if ctx.line_info(line_num).is_some_and(|info| {
521 info.in_code_block
522 || info.in_front_matter
523 || info.in_html_comment
524 || info.in_mdx_comment
525 || info.in_html_block
526 || info.in_jsx_block
527 }) {
528 continue;
529 }
530
531 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
533 if line_idx > 0 {
535 let prev_line = lines[line_idx - 1];
536 let prev_is_blank = is_blank_in_context(prev_line);
537 let prev_excluded = ctx
538 .line_info(line_idx)
539 .is_some_and(|info| info.in_code_block || info.in_front_matter);
540
541 let prev_trimmed = prev_line.trim();
546 let is_sentence_continuation = !prev_is_blank
547 && !prev_trimmed.is_empty()
548 && !prev_trimmed.ends_with('.')
549 && !prev_trimmed.ends_with('!')
550 && !prev_trimmed.ends_with('?')
551 && !prev_trimmed.ends_with(':')
552 && !prev_trimmed.ends_with(';')
553 && !prev_trimmed.ends_with('>')
554 && !prev_trimmed.ends_with('-')
555 && !prev_trimmed.ends_with('*');
556
557 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
558 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
560
561 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
562 warnings.push(LintWarning {
563 line: start_line,
564 column: start_col,
565 end_line,
566 end_column: end_col,
567 severity: Severity::Warning,
568 rule_name: Some(self.name().to_string()),
569 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
570 fix: Some(Fix::new(
571 line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
572 format!("{bq_prefix}\n"),
573 )),
574 });
575 }
576
577 if line_idx + 1 < num_lines {
580 let next_line = lines[line_idx + 1];
581 let next_is_blank = is_blank_in_context(next_line);
582 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
583
584 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
585 let next_trimmed = next_line.trim_start();
589 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
590 || next_line.starts_with("1. ")
591 || (next_line.len() > next_trimmed.len()
592 && !next_trimmed.starts_with("- ")
593 && !next_trimmed.starts_with("* ")
594 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
597 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
598 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
599 warnings.push(LintWarning {
600 line: start_line,
601 column: start_col,
602 end_line,
603 end_column: end_col,
604 severity: Severity::Warning,
605 rule_name: Some(self.name().to_string()),
606 message: "List should be followed by blank line".to_string(),
607 fix: Some(Fix::new(
608 line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
609 format!("{bq_prefix}\n"),
610 )),
611 });
612 }
613 }
614 }
615 }
616 }
617 }
618
619 for &(start_line, end_line, ref prefix) in list_blocks {
620 if ctx
622 .line_info(start_line)
623 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
624 {
625 continue;
626 }
627
628 if start_line > 1 {
629 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
631
632 if !has_blank_separation && content_line > 0 {
634 let prev_line_str = lines[content_line - 1];
635 let is_prev_excluded = ctx
636 .line_info(content_line)
637 .is_some_and(|info| info.in_code_block || info.in_front_matter);
638 let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
639 let prefixes_match = prev_prefix.trim() == prefix.trim();
640
641 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
644 if !is_prev_excluded && prefixes_match && should_require {
645 let (start_line, start_col, end_line, end_col) =
647 calculate_line_range(start_line, lines[start_line - 1]);
648
649 warnings.push(LintWarning {
650 line: start_line,
651 column: start_col,
652 end_line,
653 end_column: end_col,
654 severity: Severity::Warning,
655 rule_name: Some(self.name().to_string()),
656 message: "List should be preceded by blank line".to_string(),
657 fix: Some(Fix::new(
658 line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
659 format!("{prefix}\n"),
660 )),
661 });
662 }
663 }
664 }
665
666 if end_line < num_lines {
667 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
669
670 if !has_blank_separation && content_line > 0 {
672 let next_line_str = lines[content_line - 1];
673 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
676 || (content_line <= ctx.lines.len()
677 && ctx.lines[content_line - 1].in_code_block
678 && ctx.lines[content_line - 1].indent >= 2);
679 let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
680
681 let end_line_str = lines[end_line - 1];
686 let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
687 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
688 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
689 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
690
691 let prefixes_match = next_prefix.trim() == prefix.trim();
692
693 let is_tight_continuation_of_last_item = ctx
699 .lines
700 .get(end_line - 1)
701 .and_then(|last_li| last_li.list_item.as_ref())
702 .is_some_and(|last_item| {
703 let marker_col = last_item.marker_column;
704 ctx.lines.get(content_line - 1).is_some_and(|next_li| {
705 !next_li.is_blank && next_li.list_item.is_none() && next_li.indent > marker_col
706 })
707 });
708
709 if !is_next_excluded && prefixes_match && !exits_blockquote && !is_tight_continuation_of_last_item {
712 let (start_line_last, start_col_last, end_line_last, end_col_last) =
714 calculate_line_range(end_line, lines[end_line - 1]);
715
716 warnings.push(LintWarning {
717 line: start_line_last,
718 column: start_col_last,
719 end_line: end_line_last,
720 end_column: end_col_last,
721 severity: Severity::Warning,
722 rule_name: Some(self.name().to_string()),
723 message: "List should be followed by blank line".to_string(),
724 fix: Some(Fix::new(
725 line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
726 format!("{prefix}\n"),
727 )),
728 });
729 }
730 }
731 }
732 }
733 warnings
734 }
735}
736
737impl Rule for MD032BlanksAroundLists {
738 fn name(&self) -> &'static str {
739 "MD032"
740 }
741
742 fn description(&self) -> &'static str {
743 "Lists should be surrounded by blank lines"
744 }
745
746 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
747 let lines = ctx.raw_lines();
748 let line_index = &ctx.line_index;
749
750 if lines.is_empty() {
752 return Ok(Vec::new());
753 }
754
755 let list_blocks = self.convert_list_blocks(ctx);
756
757 if list_blocks.is_empty() {
758 return Ok(Vec::new());
759 }
760
761 let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index);
762
763 if !self.config.allow_lazy_continuation {
768 let lazy_cont_lines = ctx.lazy_continuation_lines();
769
770 for lazy_info in lazy_cont_lines.iter() {
771 let line_num = lazy_info.line_num;
772
773 let is_within_block = list_blocks
777 .iter()
778 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
779
780 if !is_within_block {
781 continue;
782 }
783
784 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
786 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
787
788 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
790 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
791 } else {
792 None
793 };
794
795 warnings.push(LintWarning {
796 line: start_line,
797 column: start_col,
798 end_line,
799 end_column: end_col,
800 severity: Severity::Warning,
801 rule_name: Some(self.name().to_string()),
802 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
803 fix,
804 });
805 }
806 }
807
808 Ok(warnings)
809 }
810
811 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
812 Ok(self.fix_with_structure_impl(ctx))
813 }
814
815 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
816 ctx.content.is_empty() || ctx.list_blocks.is_empty()
819 }
820
821 fn category(&self) -> RuleCategory {
822 RuleCategory::List
823 }
824
825 fn as_any(&self) -> &dyn std::any::Any {
826 self
827 }
828
829 crate::impl_rule_config_methods!(MD032Config);
830}
831
832impl MD032BlanksAroundLists {
833 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
835 let lines = ctx.raw_lines();
836 let num_lines = lines.len();
837 if num_lines == 0 {
838 return String::new();
839 }
840
841 let list_blocks = self.convert_list_blocks(ctx);
842 if list_blocks.is_empty() {
843 return ctx.content.to_string();
844 }
845
846 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
849 if !self.config.allow_lazy_continuation {
850 let lazy_cont_lines = ctx.lazy_continuation_lines();
851 for lazy_info in lazy_cont_lines.iter() {
852 let line_num = lazy_info.line_num;
853 let is_within_block = list_blocks
855 .iter()
856 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
857 if !is_within_block {
858 continue;
859 }
860 if !Self::should_apply_lazy_fix(ctx, line_num) {
862 continue;
863 }
864 lazy_fixes.insert(line_num, lazy_info.clone());
865 }
866 }
867
868 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
869
870 for &(start_line, end_line, ref prefix) in &list_blocks {
872 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
874 continue;
875 }
876
877 if ctx
879 .line_info(start_line)
880 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
881 {
882 continue;
883 }
884
885 if start_line > 1 {
887 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
889
890 if !has_blank_separation && content_line > 0 {
892 let prev_line_str = lines[content_line - 1];
893 let is_prev_excluded = ctx
894 .line_info(content_line)
895 .is_some_and(|info| info.in_code_block || info.in_front_matter);
896 let prev_prefix = BLOCKQUOTE_PREFIX_RE.find(prev_line_str).map_or("", |m| m.as_str());
897
898 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
899 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
901 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
903 insertions.insert(start_line, bq_prefix);
904 }
905 }
906 }
907
908 if end_line < num_lines {
910 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
912
913 if !has_blank_separation && content_line > 0 {
915 let next_line_str = lines[content_line - 1];
916 let is_next_excluded = ctx
918 .line_info(content_line)
919 .is_some_and(|info| info.in_code_block || info.in_front_matter)
920 || (content_line <= ctx.lines.len()
921 && ctx.lines[content_line - 1].in_code_block
922 && ctx.lines[content_line - 1].indent >= 2
923 && (ctx.lines[content_line - 1]
924 .content(ctx.content)
925 .trim()
926 .starts_with("```")
927 || ctx.lines[content_line - 1]
928 .content(ctx.content)
929 .trim()
930 .starts_with("~~~")));
931 let next_prefix = BLOCKQUOTE_PREFIX_RE.find(next_line_str).map_or("", |m| m.as_str());
932
933 let end_line_str = lines[end_line - 1];
935 let end_line_prefix = BLOCKQUOTE_PREFIX_RE.find(end_line_str).map_or("", |m| m.as_str());
936 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
937 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
938 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
939
940 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
943 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
945 insertions.insert(end_line + 1, bq_prefix);
946 }
947 }
948 }
949 }
950
951 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
953 for (i, line) in lines.iter().enumerate() {
954 let current_line_num = i + 1;
955 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
956 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
957 {
958 result_lines.push(prefix_to_insert.clone());
959 }
960
961 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
963 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
964 {
965 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
966 result_lines.push(fixed_line);
967 } else {
968 result_lines.push(line.to_string());
969 }
970 }
971
972 let mut result = result_lines.join("\n");
974 if ctx.content.ends_with('\n') {
975 result.push('\n');
976 }
977 result
978 }
979}
980
981fn is_blank_in_context(line: &str) -> bool {
983 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
986 line[m.end()..].trim().is_empty()
988 } else {
989 line.trim().is_empty()
991 }
992}
993
994#[cfg(test)]
995mod tests {
996 use super::*;
997 use crate::lint_context::LintContext;
998 use crate::rule::Rule;
999
1000 fn lint(content: &str) -> Vec<LintWarning> {
1001 let rule = MD032BlanksAroundLists::default();
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003 rule.check(&ctx).expect("Lint check failed")
1004 }
1005
1006 fn fix(content: &str) -> String {
1007 let rule = MD032BlanksAroundLists::default();
1008 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1009 rule.fix(&ctx).expect("Lint fix failed")
1010 }
1011
1012 #[test]
1013 fn test_fix_does_not_split_item_before_different_list_type() {
1014 let content = "- alpha beta\n aligned\n1. ordered item\n cont\n";
1018 assert_eq!(fix(content), "- alpha beta\n aligned\n\n1. ordered item\n cont\n");
1019
1020 let warnings = lint(content);
1023 assert_eq!(warnings.len(), 2);
1024 assert_eq!(warnings[0].line, 2);
1025 assert_eq!(warnings[1].line, 3);
1026 }
1027
1028 #[test]
1029 fn test_fix_does_not_split_blockquoted_item_before_different_list_type() {
1030 let content = "> - alpha beta\n> aligned\n> 1. ordered item\n";
1031 assert_eq!(fix(content), "> - alpha beta\n> aligned\n>\n> 1. ordered item\n");
1032 }
1033
1034 #[test]
1035 fn test_fix_keeps_lazy_continuation_with_its_item() {
1036 let content = "- alpha beta\nlazy\n1. ordered item\n";
1040 assert_eq!(fix(content), "- alpha beta\nlazy\n\n1. ordered item\n");
1041
1042 let warnings = lint(content);
1043 assert_eq!(warnings.len(), 2);
1044 assert_eq!(warnings[0].line, 2);
1045 assert_eq!(warnings[1].line, 3);
1046 }
1047
1048 #[test]
1049 fn test_fix_keeps_blockquoted_lazy_continuation_with_its_item() {
1050 let content = "> - alpha beta\n> lazy\n> 1. ordered item\n";
1051 assert_eq!(fix(content), "> - alpha beta\n> lazy\n>\n> 1. ordered item\n");
1052 }
1053
1054 #[test]
1055 fn test_fix_indents_lazy_continuation_when_not_allowed() {
1056 let rule = MD032BlanksAroundLists::from_config_struct(MD032Config {
1059 allow_lazy_continuation: false,
1060 });
1061 let content = "- alpha beta\nlazy\n1. ordered item\n";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let fixed = rule.fix(&ctx).expect("Lint fix failed");
1064 assert_eq!(fixed, "- alpha beta\n lazy\n\n1. ordered item\n");
1065 }
1066
1067 fn check_warnings_have_fixes(content: &str) {
1069 let warnings = lint(content);
1070 for warning in &warnings {
1071 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1072 }
1073 }
1074
1075 #[test]
1076 fn test_list_at_start() {
1077 let content = "- Item 1\n- Item 2\nText";
1080 let warnings = lint(content);
1081 assert_eq!(
1082 warnings.len(),
1083 0,
1084 "Trailing text is lazy continuation per CommonMark - no warning expected"
1085 );
1086 }
1087
1088 #[test]
1089 fn test_list_at_end() {
1090 let content = "Text\n- Item 1\n- Item 2";
1091 let warnings = lint(content);
1092 assert_eq!(
1093 warnings.len(),
1094 1,
1095 "Expected 1 warning for list at end without preceding blank line"
1096 );
1097 assert_eq!(
1098 warnings[0].line, 2,
1099 "Warning should be on the first line of the list (line 2)"
1100 );
1101 assert!(warnings[0].message.contains("preceded by blank line"));
1102
1103 check_warnings_have_fixes(content);
1105
1106 let fixed_content = fix(content);
1107 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1108
1109 let warnings_after_fix = lint(&fixed_content);
1111 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1112 }
1113
1114 #[test]
1115 fn test_list_in_middle() {
1116 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1119 let warnings = lint(content);
1120 assert_eq!(
1121 warnings.len(),
1122 1,
1123 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1124 );
1125 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1126 assert!(warnings[0].message.contains("preceded by blank line"));
1127
1128 check_warnings_have_fixes(content);
1130
1131 let fixed_content = fix(content);
1132 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1133
1134 let warnings_after_fix = lint(&fixed_content);
1136 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1137 }
1138
1139 #[test]
1140 fn test_correct_spacing() {
1141 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1142 let warnings = lint(content);
1143 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1144
1145 let fixed_content = fix(content);
1146 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1147 }
1148
1149 #[test]
1150 fn test_list_with_content() {
1151 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1154 let warnings = lint(content);
1155 assert_eq!(
1156 warnings.len(),
1157 1,
1158 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1159 );
1160 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1161 assert!(warnings[0].message.contains("preceded by blank line"));
1162
1163 check_warnings_have_fixes(content);
1165
1166 let fixed_content = fix(content);
1167 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1168 assert_eq!(
1169 fixed_content, expected_fixed,
1170 "Fix did not produce the expected output. Got:\n{fixed_content}"
1171 );
1172
1173 let warnings_after_fix = lint(&fixed_content);
1175 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1176 }
1177
1178 #[test]
1179 fn test_nested_list() {
1180 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1182 let warnings = lint(content);
1183 assert_eq!(
1184 warnings.len(),
1185 1,
1186 "Nested list block needs preceding blank only. Got: {warnings:?}"
1187 );
1188 assert_eq!(warnings[0].line, 2);
1189 assert!(warnings[0].message.contains("preceded by blank line"));
1190
1191 check_warnings_have_fixes(content);
1193
1194 let fixed_content = fix(content);
1195 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1196
1197 let warnings_after_fix = lint(&fixed_content);
1199 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1200 }
1201
1202 #[test]
1203 fn test_list_with_internal_blanks() {
1204 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1206 let warnings = lint(content);
1207 assert_eq!(
1208 warnings.len(),
1209 1,
1210 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1211 );
1212 assert_eq!(warnings[0].line, 2);
1213 assert!(warnings[0].message.contains("preceded by blank line"));
1214
1215 check_warnings_have_fixes(content);
1217
1218 let fixed_content = fix(content);
1219 assert_eq!(
1220 fixed_content,
1221 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1222 );
1223
1224 let warnings_after_fix = lint(&fixed_content);
1226 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1227 }
1228
1229 #[test]
1230 fn test_ignore_code_blocks() {
1231 let content = "```\n- Not a list item\n```\nText";
1232 let warnings = lint(content);
1233 assert_eq!(warnings.len(), 0);
1234 let fixed_content = fix(content);
1235 assert_eq!(fixed_content, content);
1236 }
1237
1238 #[test]
1239 fn test_ignore_front_matter() {
1240 let content = "---\ntitle: Test\n---\n- List Item\nText";
1242 let warnings = lint(content);
1243 assert_eq!(
1244 warnings.len(),
1245 0,
1246 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1247 );
1248
1249 let fixed_content = fix(content);
1251 assert_eq!(fixed_content, content, "No changes when no warnings");
1252 }
1253
1254 #[test]
1255 fn test_multiple_lists() {
1256 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1261 let warnings = lint(content);
1262 assert!(
1264 !warnings.is_empty(),
1265 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1266 );
1267
1268 check_warnings_have_fixes(content);
1270
1271 let fixed_content = fix(content);
1272 let warnings_after_fix = lint(&fixed_content);
1274 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1275 }
1276
1277 #[test]
1278 fn test_adjacent_lists() {
1279 let content = "- List 1\n\n* List 2";
1280 let warnings = lint(content);
1281 assert_eq!(warnings.len(), 0);
1282 let fixed_content = fix(content);
1283 assert_eq!(fixed_content, content);
1284 }
1285
1286 #[test]
1287 fn test_list_in_blockquote() {
1288 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1290 let warnings = lint(content);
1291 assert_eq!(
1292 warnings.len(),
1293 1,
1294 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1295 );
1296 assert_eq!(warnings[0].line, 2);
1297
1298 check_warnings_have_fixes(content);
1300
1301 let fixed_content = fix(content);
1302 assert_eq!(
1304 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1305 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1306 );
1307
1308 let warnings_after_fix = lint(&fixed_content);
1310 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1311 }
1312
1313 #[test]
1314 fn test_ordered_list() {
1315 let content = "Text\n1. Item 1\n2. Item 2\nText";
1317 let warnings = lint(content);
1318 assert_eq!(warnings.len(), 1);
1319
1320 check_warnings_have_fixes(content);
1322
1323 let fixed_content = fix(content);
1324 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1325
1326 let warnings_after_fix = lint(&fixed_content);
1328 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1329 }
1330
1331 #[test]
1332 fn test_no_double_blank_fix() {
1333 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1336 assert_eq!(
1337 warnings.len(),
1338 0,
1339 "Should have no warnings - properly preceded, trailing is lazy"
1340 );
1341
1342 let fixed_content = fix(content);
1343 assert_eq!(
1344 fixed_content, content,
1345 "No fix needed when no warnings. Got:\n{fixed_content}"
1346 );
1347
1348 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1350 assert_eq!(warnings2.len(), 1);
1351 if !warnings2.is_empty() {
1352 assert_eq!(
1353 warnings2[0].line, 2,
1354 "Warning line for missing blank before should be the first line of the block"
1355 );
1356 }
1357
1358 check_warnings_have_fixes(content2);
1360
1361 let fixed_content2 = fix(content2);
1362 assert_eq!(
1363 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1364 "Fix added extra blank before. Got:\n{fixed_content2}"
1365 );
1366 }
1367
1368 #[test]
1369 fn test_empty_input() {
1370 let content = "";
1371 let warnings = lint(content);
1372 assert_eq!(warnings.len(), 0);
1373 let fixed_content = fix(content);
1374 assert_eq!(fixed_content, "");
1375 }
1376
1377 #[test]
1378 fn test_only_list() {
1379 let content = "- Item 1\n- Item 2";
1380 let warnings = lint(content);
1381 assert_eq!(warnings.len(), 0);
1382 let fixed_content = fix(content);
1383 assert_eq!(fixed_content, content);
1384 }
1385
1386 #[test]
1389 fn test_fix_complex_nested_blockquote() {
1390 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1392 let warnings = lint(content);
1393 assert_eq!(
1394 warnings.len(),
1395 1,
1396 "Should warn for missing preceding blank only. Got: {warnings:?}"
1397 );
1398
1399 check_warnings_have_fixes(content);
1401
1402 let fixed_content = fix(content);
1403 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1405 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1406
1407 let warnings_after_fix = lint(&fixed_content);
1408 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1409 }
1410
1411 #[test]
1412 fn test_fix_mixed_list_markers() {
1413 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1416 let warnings = lint(content);
1417 assert!(
1419 !warnings.is_empty(),
1420 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1421 );
1422
1423 check_warnings_have_fixes(content);
1425
1426 let fixed_content = fix(content);
1427 assert!(
1429 fixed_content.contains("Text\n\n-"),
1430 "Fix should add blank line before first list item"
1431 );
1432
1433 let warnings_after_fix = lint(&fixed_content);
1435 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1436 }
1437
1438 #[test]
1439 fn test_fix_ordered_list_with_different_numbers() {
1440 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1442 let warnings = lint(content);
1443 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1444
1445 check_warnings_have_fixes(content);
1447
1448 let fixed_content = fix(content);
1449 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1450 assert_eq!(
1451 fixed_content, expected,
1452 "Fix should handle ordered lists with non-sequential numbers"
1453 );
1454
1455 let warnings_after_fix = lint(&fixed_content);
1457 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1458 }
1459
1460 #[test]
1461 fn test_fix_list_with_code_blocks_inside() {
1462 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1464 let warnings = lint(content);
1465 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1466
1467 check_warnings_have_fixes(content);
1469
1470 let fixed_content = fix(content);
1471 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1472 assert_eq!(
1473 fixed_content, expected,
1474 "Fix should handle lists with internal code blocks"
1475 );
1476
1477 let warnings_after_fix = lint(&fixed_content);
1479 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1480 }
1481
1482 #[test]
1483 fn test_fix_deeply_nested_lists() {
1484 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1486 let warnings = lint(content);
1487 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1488
1489 check_warnings_have_fixes(content);
1491
1492 let fixed_content = fix(content);
1493 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1494 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1495
1496 let warnings_after_fix = lint(&fixed_content);
1498 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1499 }
1500
1501 #[test]
1502 fn test_fix_list_with_multiline_items() {
1503 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1506 let warnings = lint(content);
1507 assert_eq!(
1508 warnings.len(),
1509 1,
1510 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1511 );
1512
1513 check_warnings_have_fixes(content);
1515
1516 let fixed_content = fix(content);
1517 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1518 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1519
1520 let warnings_after_fix = lint(&fixed_content);
1522 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1523 }
1524
1525 #[test]
1526 fn test_fix_list_at_document_boundaries() {
1527 let content1 = "- Item 1\n- Item 2";
1529 let warnings1 = lint(content1);
1530 assert_eq!(
1531 warnings1.len(),
1532 0,
1533 "List at document start should not need blank before"
1534 );
1535 let fixed1 = fix(content1);
1536 assert_eq!(fixed1, content1, "No fix needed for list at start");
1537
1538 let content2 = "Text\n- Item 1\n- Item 2";
1540 let warnings2 = lint(content2);
1541 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1542 check_warnings_have_fixes(content2);
1543 let fixed2 = fix(content2);
1544 assert_eq!(
1545 fixed2, "Text\n\n- Item 1\n- Item 2",
1546 "Should add blank before list at end"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_fix_preserves_existing_blank_lines() {
1552 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1553 let warnings = lint(content);
1554 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1555 let fixed_content = fix(content);
1556 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1557 }
1558
1559 #[test]
1560 fn test_fix_handles_tabs_and_spaces() {
1561 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1564 let warnings = lint(content);
1565 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1567
1568 check_warnings_have_fixes(content);
1570
1571 let fixed_content = fix(content);
1572 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1575 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1576
1577 let warnings_after_fix = lint(&fixed_content);
1579 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1580 }
1581
1582 #[test]
1583 fn test_fix_warning_objects_have_correct_ranges() {
1584 let content = "Text\n- Item 1\n- Item 2\nText";
1586 let warnings = lint(content);
1587 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1588
1589 for warning in &warnings {
1591 assert!(warning.fix.is_some(), "Warning should have fix");
1592 let fix = warning.fix.as_ref().unwrap();
1593 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1594 assert!(
1595 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1596 "Fix should have replacement or be insertion"
1597 );
1598 }
1599 }
1600
1601 #[test]
1602 fn test_fix_idempotent() {
1603 let content = "Text\n- Item 1\n- Item 2\nText";
1605
1606 let fixed_once = fix(content);
1608 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1609
1610 let fixed_twice = fix(&fixed_once);
1612 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1613
1614 let warnings_after_fix = lint(&fixed_once);
1616 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1617 }
1618
1619 #[test]
1620 fn test_fix_with_normalized_line_endings() {
1621 let content = "Text\n- Item 1\n- Item 2\nText";
1625 let warnings = lint(content);
1626 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1627
1628 check_warnings_have_fixes(content);
1630
1631 let fixed_content = fix(content);
1632 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1634 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1635 }
1636
1637 #[test]
1638 fn test_fix_preserves_final_newline() {
1639 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1642 let fixed_with_newline = fix(content_with_newline);
1643 assert!(
1644 fixed_with_newline.ends_with('\n'),
1645 "Fix should preserve final newline when present"
1646 );
1647 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1649
1650 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1652 let fixed_without_newline = fix(content_without_newline);
1653 assert!(
1654 !fixed_without_newline.ends_with('\n'),
1655 "Fix should not add final newline when not present"
1656 );
1657 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1659 }
1660
1661 #[test]
1662 fn test_fix_multiline_list_items_no_indent() {
1663 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";
1664
1665 let warnings = lint(content);
1666 assert_eq!(
1668 warnings.len(),
1669 0,
1670 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1671 );
1672
1673 let fixed_content = fix(content);
1674 assert_eq!(
1676 fixed_content, content,
1677 "Should not modify correctly formatted multi-line list items"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_nested_list_with_lazy_continuation() {
1683 let content = r#"# Test
1689
1690- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1691 1. Switch/case dispatcher statements (original Phase 3.2)
1692 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1693`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1694 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1695 references"#;
1696
1697 let warnings = lint(content);
1698 let md032_warnings: Vec<_> = warnings
1701 .iter()
1702 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1703 .collect();
1704 assert_eq!(
1705 md032_warnings.len(),
1706 0,
1707 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_pipes_in_code_spans_not_detected_as_table() {
1713 let content = r#"# Test
1715
1716- Item with `a | b` inline code
1717 - Nested item should work
1718
1719"#;
1720
1721 let warnings = lint(content);
1722 let md032_warnings: Vec<_> = warnings
1723 .iter()
1724 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1725 .collect();
1726 assert_eq!(
1727 md032_warnings.len(),
1728 0,
1729 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1730 );
1731 }
1732
1733 #[test]
1734 fn test_multiple_code_spans_with_pipes() {
1735 let content = r#"# Test
1737
1738- Item with `a | b` and `c || d` operators
1739 - Nested item should work
1740
1741"#;
1742
1743 let warnings = lint(content);
1744 let md032_warnings: Vec<_> = warnings
1745 .iter()
1746 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1747 .collect();
1748 assert_eq!(
1749 md032_warnings.len(),
1750 0,
1751 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1752 );
1753 }
1754
1755 #[test]
1756 fn test_actual_table_breaks_list() {
1757 let content = r#"# Test
1759
1760- Item before table
1761
1762| Col1 | Col2 |
1763|------|------|
1764| A | B |
1765
1766- Item after table
1767
1768"#;
1769
1770 let warnings = lint(content);
1771 let md032_warnings: Vec<_> = warnings
1773 .iter()
1774 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1775 .collect();
1776 assert_eq!(
1777 md032_warnings.len(),
1778 0,
1779 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_thematic_break_not_lazy_continuation() {
1785 let content = r#"- Item 1
1788- Item 2
1789***
1790
1791More text.
1792"#;
1793
1794 let warnings = lint(content);
1795 let md032_warnings: Vec<_> = warnings
1796 .iter()
1797 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1798 .collect();
1799 assert_eq!(
1800 md032_warnings.len(),
1801 1,
1802 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1803 );
1804 assert!(
1805 md032_warnings[0].message.contains("followed by blank line"),
1806 "Warning should be about missing blank after list"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_thematic_break_with_blank_line() {
1812 let content = r#"- Item 1
1814- Item 2
1815
1816***
1817
1818More text.
1819"#;
1820
1821 let warnings = lint(content);
1822 let md032_warnings: Vec<_> = warnings
1823 .iter()
1824 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1825 .collect();
1826 assert_eq!(
1827 md032_warnings.len(),
1828 0,
1829 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1830 );
1831 }
1832
1833 #[test]
1834 fn test_various_thematic_break_styles() {
1835 for hr in ["---", "***", "___"] {
1840 let content = format!(
1841 r#"- Item 1
1842- Item 2
1843{hr}
1844
1845More text.
1846"#
1847 );
1848
1849 let warnings = lint(&content);
1850 let md032_warnings: Vec<_> = warnings
1851 .iter()
1852 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1853 .collect();
1854 assert_eq!(
1855 md032_warnings.len(),
1856 1,
1857 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1858 );
1859 }
1860 }
1861
1862 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1865 let rule = MD032BlanksAroundLists::from_config_struct(config);
1866 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1867 rule.check(&ctx).expect("Lint check failed")
1868 }
1869
1870 fn fix_with_config(content: &str, config: MD032Config) -> String {
1871 let rule = MD032BlanksAroundLists::from_config_struct(config);
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873 rule.fix(&ctx).expect("Lint fix failed")
1874 }
1875
1876 #[test]
1877 fn test_lazy_continuation_allowed_by_default() {
1878 let content = "# Heading\n\n1. List\nSome text.";
1880 let warnings = lint(content);
1881 assert_eq!(
1882 warnings.len(),
1883 0,
1884 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_lazy_continuation_disallowed() {
1890 let content = "# Heading\n\n1. List\nSome text.";
1892 let config = MD032Config {
1893 allow_lazy_continuation: false,
1894 };
1895 let warnings = lint_with_config(content, config);
1896 assert_eq!(
1897 warnings.len(),
1898 1,
1899 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1900 );
1901 assert!(
1902 warnings[0].message.contains("Lazy continuation"),
1903 "Warning message should mention lazy continuation"
1904 );
1905 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1906 }
1907
1908 #[test]
1909 fn test_lazy_continuation_fix() {
1910 let content = "# Heading\n\n1. List\nSome text.";
1912 let config = MD032Config {
1913 allow_lazy_continuation: false,
1914 };
1915 let fixed = fix_with_config(content, config.clone());
1916 assert_eq!(
1918 fixed, "# Heading\n\n1. List\n Some text.",
1919 "Fix should add proper indentation to lazy continuation"
1920 );
1921
1922 let warnings_after = lint_with_config(&fixed, config);
1924 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1925 }
1926
1927 #[test]
1928 fn test_lazy_continuation_multiple_lines() {
1929 let content = "- Item 1\nLine 2\nLine 3";
1931 let config = MD032Config {
1932 allow_lazy_continuation: false,
1933 };
1934 let warnings = lint_with_config(content, config.clone());
1935 assert_eq!(
1937 warnings.len(),
1938 2,
1939 "Should warn for each lazy continuation line. Got: {warnings:?}"
1940 );
1941
1942 let fixed = fix_with_config(content, config.clone());
1943 assert_eq!(
1945 fixed, "- Item 1\n Line 2\n Line 3",
1946 "Fix should add proper indentation to lazy continuation lines"
1947 );
1948
1949 let warnings_after = lint_with_config(&fixed, config);
1951 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1952 }
1953
1954 #[test]
1955 fn test_lazy_continuation_with_indented_content() {
1956 let content = "- Item 1\n Indented content\nLazy text";
1958 let config = MD032Config {
1959 allow_lazy_continuation: false,
1960 };
1961 let warnings = lint_with_config(content, config);
1962 assert_eq!(
1963 warnings.len(),
1964 1,
1965 "Should warn for lazy text after indented content. Got: {warnings:?}"
1966 );
1967 }
1968
1969 #[test]
1970 fn test_lazy_continuation_properly_separated() {
1971 let content = "- Item 1\n\nSome text.";
1973 let config = MD032Config {
1974 allow_lazy_continuation: false,
1975 };
1976 let warnings = lint_with_config(content, config);
1977 assert_eq!(
1978 warnings.len(),
1979 0,
1980 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1981 );
1982 }
1983
1984 #[test]
1987 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1988 let content = "1) First item\nLazy continuation";
1990 let config = MD032Config {
1991 allow_lazy_continuation: false,
1992 };
1993 let warnings = lint_with_config(content, config.clone());
1994 assert_eq!(
1995 warnings.len(),
1996 1,
1997 "Should warn for lazy continuation with parenthesis marker"
1998 );
1999
2000 let fixed = fix_with_config(content, config);
2001 assert_eq!(fixed, "1) First item\n Lazy continuation");
2003 }
2004
2005 #[test]
2006 fn test_lazy_continuation_followed_by_another_list() {
2007 let content = "- Item 1\nSome text\n- Item 2";
2013 let config = MD032Config {
2014 allow_lazy_continuation: false,
2015 };
2016 let warnings = lint_with_config(content, config);
2017 assert_eq!(
2019 warnings.len(),
2020 1,
2021 "Should warn about lazy continuation within list. Got: {warnings:?}"
2022 );
2023 assert!(
2024 warnings[0].message.contains("Lazy continuation"),
2025 "Warning should be about lazy continuation"
2026 );
2027 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2028 }
2029
2030 #[test]
2031 fn test_lazy_continuation_multiple_in_document() {
2032 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2037 let config = MD032Config {
2038 allow_lazy_continuation: false,
2039 };
2040 let warnings = lint_with_config(content, config.clone());
2041 assert_eq!(
2043 warnings.len(),
2044 2,
2045 "Should warn for both lazy continuations. Got: {warnings:?}"
2046 );
2047
2048 let fixed = fix_with_config(content, config.clone());
2049 assert!(
2051 fixed.contains(" Lazy 1"),
2052 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2053 );
2054 assert!(
2055 fixed.contains(" Lazy 2"),
2056 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2057 );
2058
2059 let warnings_after = lint_with_config(&fixed, config);
2060 assert_eq!(
2062 warnings_after.len(),
2063 0,
2064 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_lazy_continuation_end_of_document_no_newline() {
2070 let content = "- Item\nNo trailing newline";
2072 let config = MD032Config {
2073 allow_lazy_continuation: false,
2074 };
2075 let warnings = lint_with_config(content, config.clone());
2076 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2077
2078 let fixed = fix_with_config(content, config);
2079 assert_eq!(fixed, "- Item\n No trailing newline");
2081 }
2082
2083 #[test]
2084 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2085 let content = "- Item 1\n---";
2088 let config = MD032Config {
2089 allow_lazy_continuation: false,
2090 };
2091 let warnings = lint_with_config(content, config.clone());
2092 assert_eq!(
2094 warnings.len(),
2095 1,
2096 "List should need blank line before thematic break. Got: {warnings:?}"
2097 );
2098
2099 let fixed = fix_with_config(content, config);
2101 assert_eq!(fixed, "- Item 1\n\n---");
2102 }
2103
2104 #[test]
2105 fn test_lazy_continuation_heading_not_flagged() {
2106 let content = "- Item 1\n# Heading";
2109 let config = MD032Config {
2110 allow_lazy_continuation: false,
2111 };
2112 let warnings = lint_with_config(content, config);
2113 assert!(
2116 warnings.iter().all(|w| !w.message.contains("lazy")),
2117 "Heading should not trigger lazy continuation warning"
2118 );
2119 }
2120
2121 #[test]
2122 fn test_lazy_continuation_mixed_list_types() {
2123 let content = "- Unordered\n1. Ordered\nLazy text";
2125 let config = MD032Config {
2126 allow_lazy_continuation: false,
2127 };
2128 let warnings = lint_with_config(content, config.clone());
2129 assert!(!warnings.is_empty(), "Should warn about structure issues");
2130 }
2131
2132 #[test]
2133 fn test_lazy_continuation_deep_nesting() {
2134 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2136 let config = MD032Config {
2137 allow_lazy_continuation: false,
2138 };
2139 let warnings = lint_with_config(content, config.clone());
2140 assert!(
2141 !warnings.is_empty(),
2142 "Should warn about lazy continuation after nested list"
2143 );
2144
2145 let fixed = fix_with_config(content, config.clone());
2146 let warnings_after = lint_with_config(&fixed, config);
2147 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2148 }
2149
2150 #[test]
2151 fn test_lazy_continuation_with_emphasis_in_text() {
2152 let content = "- Item\n*emphasized* continuation";
2154 let config = MD032Config {
2155 allow_lazy_continuation: false,
2156 };
2157 let warnings = lint_with_config(content, config.clone());
2158 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2159
2160 let fixed = fix_with_config(content, config);
2161 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2163 }
2164
2165 #[test]
2166 fn test_lazy_continuation_with_code_span() {
2167 let content = "- Item\n`code` continuation";
2169 let config = MD032Config {
2170 allow_lazy_continuation: false,
2171 };
2172 let warnings = lint_with_config(content, config.clone());
2173 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2174
2175 let fixed = fix_with_config(content, config);
2176 assert_eq!(fixed, "- Item\n `code` continuation");
2178 }
2179
2180 #[test]
2187 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2188 let content = r#"1. Create a new Chat conversation:
2191 - On the sidebar, select **New Chat**.
2192 - In the box, type `/new`.
2193 A new Chat conversation replaces the previous one.
21941. Under the Chat text box, turn off the toggle."#;
2195 let config = MD032Config {
2196 allow_lazy_continuation: false,
2197 };
2198 let warnings = lint_with_config(content, config);
2199 let lazy_warnings: Vec<_> = warnings
2201 .iter()
2202 .filter(|w| w.message.contains("Lazy continuation"))
2203 .collect();
2204 assert!(
2205 !lazy_warnings.is_empty(),
2206 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2207 );
2208 assert!(
2209 lazy_warnings.iter().any(|w| w.line == 4),
2210 "Should warn on line 4. Got: {lazy_warnings:?}"
2211 );
2212 }
2213
2214 #[test]
2215 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2216 let content = r#"- `field`: Is the specific key:
2219 - `password`: Accesses the password.
2220 - `api_key`: Accesses the api_key.
2221 `token`: Specifies which ID token to use.
2222- `version_id`: Is the unique identifier."#;
2223 let config = MD032Config {
2224 allow_lazy_continuation: false,
2225 };
2226 let warnings = lint_with_config(content, config);
2227 let lazy_warnings: Vec<_> = warnings
2229 .iter()
2230 .filter(|w| w.message.contains("Lazy continuation"))
2231 .collect();
2232 assert!(
2233 !lazy_warnings.is_empty(),
2234 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2235 );
2236 assert!(
2237 lazy_warnings.iter().any(|w| w.line == 4),
2238 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2239 );
2240 }
2241
2242 #[test]
2243 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2244 let content = r#"- Check out the branch, and test locally.
2246 - If the MR requires significant modifications:
2247 - **Skip local testing** and review instead.
2248 - **Request verification** from the author.
2249 - **Identify the minimal change** needed.
2250 Your testing might result in opportunities.
2251- If you don't understand, _say so_."#;
2252 let config = MD032Config {
2253 allow_lazy_continuation: false,
2254 };
2255 let warnings = lint_with_config(content, config);
2256 let lazy_warnings: Vec<_> = warnings
2258 .iter()
2259 .filter(|w| w.message.contains("Lazy continuation"))
2260 .collect();
2261 assert!(
2262 !lazy_warnings.is_empty(),
2263 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2264 );
2265 assert!(
2266 lazy_warnings.iter().any(|w| w.line == 6),
2267 "Should warn on line 6. Got: {lazy_warnings:?}"
2268 );
2269 }
2270
2271 #[test]
2272 fn test_issue295_ordered_list_nested_bullets_continuation() {
2273 let content = r#"# Test
2276
22771. First item.
2278 - Nested A.
2279 - Nested B.
2280 Continuation at outer level.
22811. Second item."#;
2282 let config = MD032Config {
2283 allow_lazy_continuation: false,
2284 };
2285 let warnings = lint_with_config(content, config);
2286 let lazy_warnings: Vec<_> = warnings
2288 .iter()
2289 .filter(|w| w.message.contains("Lazy continuation"))
2290 .collect();
2291 assert!(
2292 !lazy_warnings.is_empty(),
2293 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2294 );
2295 assert!(
2297 lazy_warnings.iter().any(|w| w.line == 6),
2298 "Should warn on line 6. Got: {lazy_warnings:?}"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_issue295_multiple_lazy_lines_after_nested() {
2304 let content = r#"1. The device client receives a response.
2306 - Those defined by OAuth Framework.
2307 - Those specific to device authorization.
2308 Those error responses are described below.
2309 For more information on each response,
2310 see the documentation.
23111. Next step in the process."#;
2312 let config = MD032Config {
2313 allow_lazy_continuation: false,
2314 };
2315 let warnings = lint_with_config(content, config);
2316 let lazy_warnings: Vec<_> = warnings
2318 .iter()
2319 .filter(|w| w.message.contains("Lazy continuation"))
2320 .collect();
2321 assert!(
2322 lazy_warnings.len() >= 3,
2323 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2324 lazy_warnings.len()
2325 );
2326 }
2327
2328 #[test]
2329 fn test_issue295_properly_indented_not_lazy() {
2330 let content = r#"1. First item.
2332 - Nested A.
2333 - Nested B.
2334
2335 Properly indented continuation.
23361. Second item."#;
2337 let config = MD032Config {
2338 allow_lazy_continuation: false,
2339 };
2340 let warnings = lint_with_config(content, config);
2341 let lazy_warnings: Vec<_> = warnings
2343 .iter()
2344 .filter(|w| w.message.contains("Lazy continuation"))
2345 .collect();
2346 assert_eq!(
2347 lazy_warnings.len(),
2348 0,
2349 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2350 );
2351 }
2352
2353 #[test]
2360 fn test_html_comment_before_list_with_preceding_blank() {
2361 let content = "Some text.\n\n<!-- comment -->\n- List item";
2364 let warnings = lint(content);
2365 assert_eq!(
2366 warnings.len(),
2367 0,
2368 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2369 );
2370 }
2371
2372 #[test]
2373 fn test_html_comment_after_list_with_following_blank() {
2374 let content = "- List item\n<!-- comment -->\n\nSome text.";
2376 let warnings = lint(content);
2377 assert_eq!(
2378 warnings.len(),
2379 0,
2380 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2381 );
2382 }
2383
2384 #[test]
2385 fn test_list_inside_html_comment_ignored() {
2386 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2388 let warnings = lint(content);
2389 assert_eq!(
2390 warnings.len(),
2391 0,
2392 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn test_multiline_html_comment_before_list() {
2398 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2400 let warnings = lint(content);
2401 assert_eq!(
2402 warnings.len(),
2403 0,
2404 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_no_blank_before_html_comment_still_warns() {
2410 let content = "Some text.\n<!-- comment -->\n- List item";
2412 let warnings = lint(content);
2413 assert_eq!(
2414 warnings.len(),
2415 1,
2416 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2417 );
2418 assert!(
2419 warnings[0].message.contains("preceded by blank line"),
2420 "Should be 'preceded by blank line' warning"
2421 );
2422 }
2423
2424 #[test]
2425 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2426 let content = "- List item\n<!-- comment -->\nSome text.";
2429 let warnings = lint(content);
2430 assert_eq!(
2431 warnings.len(),
2432 0,
2433 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_list_followed_by_heading_through_comment_should_warn() {
2439 let content = "- List item\n<!-- comment -->\n# Heading";
2441 let warnings = lint(content);
2442 assert!(
2445 warnings.len() <= 1,
2446 "Should handle heading after comment gracefully. Got: {warnings:?}"
2447 );
2448 }
2449
2450 #[test]
2451 fn test_html_comment_between_list_and_text_both_directions() {
2452 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2454 let warnings = lint(content);
2455 assert_eq!(
2456 warnings.len(),
2457 0,
2458 "Should not warn with proper separation through comments. Got: {warnings:?}"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2464 let content = "Text.\n\n<!-- comment -->\n- Item";
2466 let fixed = fix(content);
2467 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2468 }
2469
2470 #[test]
2471 fn test_html_comment_fix_adds_blank_when_needed() {
2472 let content = "Text.\n<!-- comment -->\n- Item";
2475 let fixed = fix(content);
2476 assert!(
2477 fixed.contains("<!-- comment -->\n\n- Item"),
2478 "Fix should add blank line before list. Got: {fixed}"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_ordered_list_inside_html_comment() {
2484 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2486 let warnings = lint(content);
2487 assert_eq!(
2488 warnings.len(),
2489 0,
2490 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2491 );
2492 }
2493
2494 #[test]
2501 fn test_blockquote_list_exit_no_warning() {
2502 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2504 let warnings = lint(content);
2505 assert_eq!(
2506 warnings.len(),
2507 0,
2508 "Should not warn when exiting blockquote. Got: {warnings:?}"
2509 );
2510 }
2511
2512 #[test]
2513 fn test_nested_blockquote_list_exit() {
2514 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2516 let warnings = lint(content);
2517 assert_eq!(
2518 warnings.len(),
2519 0,
2520 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_blockquote_same_level_no_warning() {
2526 let content = "> - item 1\n> - item 2\n> Text after";
2529 let warnings = lint(content);
2530 assert_eq!(
2531 warnings.len(),
2532 0,
2533 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2534 );
2535 }
2536
2537 #[test]
2538 fn test_blockquote_list_with_special_chars() {
2539 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2541 let warnings = lint(content);
2542 assert_eq!(
2543 warnings.len(),
2544 0,
2545 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_lazy_continuation_whitespace_only_line() {
2551 let content = "- Item\n \nText after whitespace-only line";
2554 let config = MD032Config {
2555 allow_lazy_continuation: false,
2556 };
2557 let warnings = lint_with_config(content, config);
2558 assert_eq!(
2560 warnings.len(),
2561 0,
2562 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2563 );
2564 }
2565
2566 #[test]
2567 fn test_lazy_continuation_blockquote_context() {
2568 let content = "> - Item\n> Lazy in quote";
2570 let config = MD032Config {
2571 allow_lazy_continuation: false,
2572 };
2573 let warnings = lint_with_config(content, config);
2574 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2577 }
2578
2579 #[test]
2580 fn test_lazy_continuation_fix_preserves_content() {
2581 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2583 let config = MD032Config {
2584 allow_lazy_continuation: false,
2585 };
2586 let fixed = fix_with_config(content, config);
2587 assert!(fixed.contains("<>&"), "Should preserve special chars");
2588 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2589 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2591 }
2592
2593 #[test]
2594 fn test_lazy_continuation_fix_idempotent() {
2595 let content = "- Item\nLazy";
2597 let config = MD032Config {
2598 allow_lazy_continuation: false,
2599 };
2600 let fixed_once = fix_with_config(content, config.clone());
2601 let fixed_twice = fix_with_config(&fixed_once, config);
2602 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2603 }
2604
2605 #[test]
2606 fn test_lazy_continuation_config_default_allows() {
2607 let content = "- Item\nLazy text that continues";
2609 let default_config = MD032Config::default();
2610 assert!(
2611 default_config.allow_lazy_continuation,
2612 "Default should allow lazy continuation"
2613 );
2614 let warnings = lint_with_config(content, default_config);
2615 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2616 }
2617
2618 #[test]
2619 fn test_lazy_continuation_after_multi_line_item() {
2620 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2622 let config = MD032Config {
2623 allow_lazy_continuation: false,
2624 };
2625 let warnings = lint_with_config(content, config.clone());
2626 assert_eq!(
2627 warnings.len(),
2628 1,
2629 "Should warn only for the lazy line, not the indented line"
2630 );
2631 }
2632
2633 #[test]
2635 fn test_blockquote_list_with_continuation_and_nested() {
2636 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2639 let warnings = lint(content);
2640 assert_eq!(
2641 warnings.len(),
2642 0,
2643 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2644 );
2645 }
2646
2647 #[test]
2648 fn test_blockquote_list_simple() {
2649 let content = "> - item 1\n> - item 2";
2651 let warnings = lint(content);
2652 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2653 }
2654
2655 #[test]
2656 fn test_blockquote_list_with_continuation_only() {
2657 let content = "> - item 1\n> continuation\n> - item 2";
2659 let warnings = lint(content);
2660 assert_eq!(
2661 warnings.len(),
2662 0,
2663 "Blockquoted list with continuation should have no warnings"
2664 );
2665 }
2666
2667 #[test]
2668 fn test_blockquote_list_with_lazy_continuation() {
2669 let content = "> - item 1\n> lazy continuation\n> - item 2";
2671 let warnings = lint(content);
2672 assert_eq!(
2673 warnings.len(),
2674 0,
2675 "Blockquoted list with lazy continuation should have no warnings"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_nested_blockquote_list() {
2681 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2683 let warnings = lint(content);
2684 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2685 }
2686
2687 #[test]
2688 fn test_blockquote_list_needs_preceding_blank() {
2689 let content = "> Text before\n> - item 1\n> - item 2";
2691 let warnings = lint(content);
2692 assert_eq!(
2693 warnings.len(),
2694 1,
2695 "Should warn for missing blank before blockquoted list"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_blockquote_list_properly_separated() {
2701 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2703 let warnings = lint(content);
2704 assert_eq!(
2705 warnings.len(),
2706 0,
2707 "Properly separated blockquoted list should have no warnings"
2708 );
2709 }
2710
2711 #[test]
2712 fn test_blockquote_ordered_list() {
2713 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2715 let warnings = lint(content);
2716 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2717 }
2718
2719 #[test]
2720 fn test_blockquote_list_with_empty_blockquote_line() {
2721 let content = "> - item 1\n>\n> - item 2";
2723 let warnings = lint(content);
2724 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2725 }
2726
2727 #[test]
2729 fn test_blockquote_list_multi_paragraph_items() {
2730 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2733 let warnings = lint(content);
2734 assert_eq!(
2735 warnings.len(),
2736 0,
2737 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2738 );
2739 }
2740
2741 #[test]
2743 fn test_blockquote_ordered_list_multi_paragraph_items() {
2744 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2745 let warnings = lint(content);
2746 assert_eq!(
2747 warnings.len(),
2748 0,
2749 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2750 );
2751 }
2752
2753 #[test]
2755 fn test_blockquote_list_multiple_continuations() {
2756 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2757 let warnings = lint(content);
2758 assert_eq!(
2759 warnings.len(),
2760 0,
2761 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2762 );
2763 }
2764
2765 #[test]
2767 fn test_nested_blockquote_multi_paragraph_list() {
2768 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2769 let warnings = lint(content);
2770 assert_eq!(
2771 warnings.len(),
2772 0,
2773 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2774 );
2775 }
2776
2777 #[test]
2779 fn test_triple_nested_blockquote_multi_paragraph_list() {
2780 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2781 let warnings = lint(content);
2782 assert_eq!(
2783 warnings.len(),
2784 0,
2785 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2786 );
2787 }
2788
2789 #[test]
2791 fn test_blockquote_list_last_item_continuation() {
2792 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2793 let warnings = lint(content);
2794 assert_eq!(
2795 warnings.len(),
2796 0,
2797 "Last item with continuation should have no warnings. Got: {warnings:?}"
2798 );
2799 }
2800
2801 #[test]
2803 fn test_blockquote_list_first_item_only_continuation() {
2804 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2805 let warnings = lint(content);
2806 assert_eq!(
2807 warnings.len(),
2808 0,
2809 "Single item with continuation should have no warnings. Got: {warnings:?}"
2810 );
2811 }
2812
2813 #[test]
2817 fn test_blockquote_level_change_breaks_list() {
2818 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2820 let warnings = lint(content);
2821 assert!(
2825 warnings.len() <= 2,
2826 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2827 );
2828 }
2829
2830 #[test]
2832 fn test_exit_blockquote_needs_blank_before_list() {
2833 let content = "> Blockquote text\n\n- List outside blockquote\n";
2835 let warnings = lint(content);
2836 assert_eq!(
2837 warnings.len(),
2838 0,
2839 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2840 );
2841
2842 let content2 = "> Blockquote text\n- List outside blockquote\n";
2846 let warnings2 = lint(content2);
2847 assert!(
2849 warnings2.len() <= 1,
2850 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2851 );
2852 }
2853
2854 #[test]
2856 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2857 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2859 let warnings = lint(content_dash);
2860 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2861
2862 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2864 let warnings = lint(content_asterisk);
2865 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2866
2867 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2869 let warnings = lint(content_plus);
2870 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2871 }
2872
2873 #[test]
2875 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2876 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2877 let warnings = lint(content);
2878 assert_eq!(
2879 warnings.len(),
2880 0,
2881 "Parenthesis ordered markers should work. Got: {warnings:?}"
2882 );
2883 }
2884
2885 #[test]
2887 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2888 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2890 let warnings = lint(content);
2891 assert_eq!(
2892 warnings.len(),
2893 0,
2894 "Multi-digit ordered list should work. Got: {warnings:?}"
2895 );
2896 }
2897
2898 #[test]
2900 fn test_blockquote_multi_paragraph_with_formatting() {
2901 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2902 let warnings = lint(content);
2903 assert_eq!(
2904 warnings.len(),
2905 0,
2906 "Continuation with inline formatting should work. Got: {warnings:?}"
2907 );
2908 }
2909
2910 #[test]
2912 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2913 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2914 let warnings = lint(content);
2915 assert_eq!(
2916 warnings.len(),
2917 0,
2918 "All items with continuations should work. Got: {warnings:?}"
2919 );
2920 }
2921
2922 #[test]
2924 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2925 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2926 let warnings = lint(content);
2927 assert_eq!(
2928 warnings.len(),
2929 0,
2930 "Lowercase continuation should work. Got: {warnings:?}"
2931 );
2932 }
2933
2934 #[test]
2936 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2937 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2938 let warnings = lint(content);
2939 assert_eq!(
2940 warnings.len(),
2941 0,
2942 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2943 );
2944 }
2945
2946 #[test]
2948 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2949 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2951 let warnings = lint(content);
2952 assert!(
2954 warnings.len() <= 1,
2955 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2956 );
2957 }
2958
2959 #[test]
2961 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2962 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2964 let warnings = lint(content);
2965 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2966 }
2967
2968 #[test]
2969 fn test_blockquote_list_varying_spaces_after_marker() {
2970 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2972 let warnings = lint(content);
2973 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2974 }
2975
2976 #[test]
2977 fn test_deeply_nested_blockquote_list() {
2978 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2980 let warnings = lint(content);
2981 assert_eq!(
2982 warnings.len(),
2983 0,
2984 "Deeply nested blockquote list should have no warnings"
2985 );
2986 }
2987
2988 #[test]
2989 fn test_blockquote_level_change_in_list() {
2990 let content = "> - item 1\n>> - deeper item\n> - item 2";
2992 let warnings = lint(content);
2995 assert!(
2996 !warnings.is_empty(),
2997 "Blockquote level change should break list and trigger warnings"
2998 );
2999 }
3000
3001 #[test]
3002 fn test_blockquote_list_with_code_span() {
3003 let content = "> - item with `code`\n> continuation\n> - item 2";
3005 let warnings = lint(content);
3006 assert_eq!(
3007 warnings.len(),
3008 0,
3009 "Blockquote list with code span should have no warnings"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_code_span_html_comment_delimiters_no_false_positive() {
3015 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";
3021 let warnings = lint(content);
3022 assert_eq!(
3023 warnings.len(),
3024 0,
3025 "code-span HTML comment delimiters must not cause MD032 false positives, got: {warnings:?}"
3026 );
3027 }
3028
3029 #[test]
3030 fn test_code_span_html_comment_delimiters_fix_is_idempotent() {
3031 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";
3036 let fixed = fix(content);
3037 assert_eq!(
3038 fixed, content,
3039 "MD032 fix must be a no-op for content whose only `<!--`/`-->` are inside code spans"
3040 );
3041 }
3042
3043 #[test]
3044 fn test_blockquote_list_at_document_end() {
3045 let content = "> Some text\n>\n> - item 1\n> - item 2";
3047 let warnings = lint(content);
3048 assert_eq!(
3049 warnings.len(),
3050 0,
3051 "Blockquote list at document end should have no warnings"
3052 );
3053 }
3054
3055 #[test]
3056 fn test_fix_preserves_blockquote_prefix_before_list() {
3057 let content = "> Text before
3059> - Item 1
3060> - Item 2";
3061 let fixed = fix(content);
3062
3063 let expected = "> Text before
3065>
3066> - Item 1
3067> - Item 2";
3068 assert_eq!(
3069 fixed, expected,
3070 "Fix should insert '>' blank line, not plain blank line"
3071 );
3072 }
3073
3074 #[test]
3075 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3076 let content = ">>> Triple nested
3079>>> - Item 1
3080>>> - Item 2
3081>>> More text";
3082 let fixed = fix(content);
3083
3084 let expected = ">>> Triple nested
3086>>>
3087>>> - Item 1
3088>>> - Item 2
3089>>> More text";
3090 assert_eq!(
3091 fixed, expected,
3092 "Fix should preserve triple-nested blockquote prefix '>>>'"
3093 );
3094 }
3095
3096 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3099 let rule = MD032BlanksAroundLists::default();
3100 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3101 rule.check(&ctx).unwrap()
3102 }
3103
3104 #[test]
3105 fn test_quarto_list_after_div_open() {
3106 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3108 let warnings = lint_quarto(content);
3109 assert!(
3111 warnings.is_empty(),
3112 "Quarto div marker should be transparent before list: {warnings:?}"
3113 );
3114 }
3115
3116 #[test]
3117 fn test_quarto_list_before_div_close() {
3118 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3120 let warnings = lint_quarto(content);
3121 assert!(
3123 warnings.is_empty(),
3124 "Quarto div marker should be transparent after list: {warnings:?}"
3125 );
3126 }
3127
3128 #[test]
3129 fn test_quarto_list_needs_blank_without_div() {
3130 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3132 let warnings = lint_quarto(content);
3133 assert!(
3136 !warnings.is_empty(),
3137 "Should still require blank when not present: {warnings:?}"
3138 );
3139 }
3140
3141 #[test]
3142 fn test_quarto_list_in_callout_with_content() {
3143 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3145 let warnings = lint_quarto(content);
3146 assert!(
3147 warnings.is_empty(),
3148 "List with proper blanks inside callout should pass: {warnings:?}"
3149 );
3150 }
3151
3152 #[test]
3153 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3154 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3156 let warnings = lint(content); assert!(
3159 !warnings.is_empty(),
3160 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3161 );
3162 }
3163
3164 #[test]
3165 fn test_quarto_nested_divs_with_list() {
3166 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3168 let warnings = lint_quarto(content);
3169 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3170 }
3171
3172 #[test]
3173 fn test_issue512_complex_nested_list_with_continuation() {
3174 let content = "\
3177- First level of indentation.
3178 - Second level of indentation.
3179 - Third level of indentation.
3180 - Third level of indentation.
3181
3182 Second level list continuation.
3183
3184 First level list continuation.
3185- First level of indentation.
3186";
3187 let warnings = lint(content);
3188 assert!(
3189 warnings.is_empty(),
3190 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_issue512_continuation_at_root_level() {
3196 let content = "\
3200- First level.
3201 - Second level.
3202
3203 First level continuation.
3204
3205Root level lazy continuation.
3206- Another first level item.
3207";
3208 let warnings = lint(content);
3209 assert_eq!(
3210 warnings.len(),
3211 1,
3212 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3213 );
3214 assert_eq!(warnings[0].line, 7);
3215 }
3216
3217 #[test]
3218 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3219 let content = "\
3221- Level 1 item.
3222 - Level 2 item.
3223 - Level 3 item.
3224
3225 Level 3 continuation.
3226
3227 Level 2 continuation.
3228
3229 Level 1 continuation (indented under marker).
3230- Another level 1 item.
3231";
3232 let warnings = lint(content);
3233 assert!(
3234 warnings.is_empty(),
3235 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3236 );
3237 }
3238
3239 #[test]
3240 fn test_pandoc_list_after_div_open() {
3241 let rule = MD032BlanksAroundLists::default();
3244 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3246 let warnings = rule.check(&ctx).unwrap();
3247 assert!(
3248 warnings.is_empty(),
3249 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3250 );
3251 }
3252
3253 #[test]
3254 fn test_md032_html_comment() {
3255 let rule = MD032BlanksAroundLists::default();
3256 let content = "text\n<!--\n- Item 1\n- Item 2\n-->\ntext";
3257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3258 let warnings = rule.check(&ctx).unwrap();
3259 assert!(
3260 warnings.is_empty(),
3261 "MD032 should not require blank lines around lists inside HTML comments: {warnings:?}"
3262 );
3263 }
3264}