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
639 .find(prev_line_str)
640 .map_or(String::new(), |m| m.as_str().to_string());
641 let prefixes_match = prev_prefix.trim() == prefix.trim();
642
643 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
646 if !is_prev_excluded && prefixes_match && should_require {
647 let (start_line, start_col, end_line, end_col) =
649 calculate_line_range(start_line, lines[start_line - 1]);
650
651 warnings.push(LintWarning {
652 line: start_line,
653 column: start_col,
654 end_line,
655 end_column: end_col,
656 severity: Severity::Warning,
657 rule_name: Some(self.name().to_string()),
658 message: "List should be preceded by blank line".to_string(),
659 fix: Some(Fix::new(
660 line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
661 format!("{prefix}\n"),
662 )),
663 });
664 }
665 }
666 }
667
668 if end_line < num_lines {
669 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
671
672 if !has_blank_separation && content_line > 0 {
674 let next_line_str = lines[content_line - 1];
675 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
678 || (content_line <= ctx.lines.len()
679 && ctx.lines[content_line - 1].in_code_block
680 && ctx.lines[content_line - 1].indent >= 2);
681 let next_prefix = BLOCKQUOTE_PREFIX_RE
682 .find(next_line_str)
683 .map_or(String::new(), |m| m.as_str().to_string());
684
685 let end_line_str = lines[end_line - 1];
690 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
691 .find(end_line_str)
692 .map_or(String::new(), |m| m.as_str().to_string());
693 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
694 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
695 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
696
697 let prefixes_match = next_prefix.trim() == prefix.trim();
698
699 let is_tight_continuation_of_last_item = ctx
705 .lines
706 .get(end_line - 1)
707 .and_then(|last_li| last_li.list_item.as_ref())
708 .is_some_and(|last_item| {
709 let marker_col = last_item.marker_column;
710 ctx.lines.get(content_line - 1).is_some_and(|next_li| {
711 !next_li.is_blank && next_li.list_item.is_none() && next_li.indent > marker_col
712 })
713 });
714
715 if !is_next_excluded && prefixes_match && !exits_blockquote && !is_tight_continuation_of_last_item {
718 let (start_line_last, start_col_last, end_line_last, end_col_last) =
720 calculate_line_range(end_line, lines[end_line - 1]);
721
722 warnings.push(LintWarning {
723 line: start_line_last,
724 column: start_col_last,
725 end_line: end_line_last,
726 end_column: end_col_last,
727 severity: Severity::Warning,
728 rule_name: Some(self.name().to_string()),
729 message: "List should be followed by blank line".to_string(),
730 fix: Some(Fix::new(
731 line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
732 format!("{prefix}\n"),
733 )),
734 });
735 }
736 }
737 }
738 }
739 warnings
740 }
741}
742
743impl Rule for MD032BlanksAroundLists {
744 fn name(&self) -> &'static str {
745 "MD032"
746 }
747
748 fn description(&self) -> &'static str {
749 "Lists should be surrounded by blank lines"
750 }
751
752 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
753 let lines = ctx.raw_lines();
754 let line_index = &ctx.line_index;
755
756 if lines.is_empty() {
758 return Ok(Vec::new());
759 }
760
761 let list_blocks = self.convert_list_blocks(ctx);
762
763 if list_blocks.is_empty() {
764 return Ok(Vec::new());
765 }
766
767 let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index);
768
769 if !self.config.allow_lazy_continuation {
774 let lazy_cont_lines = ctx.lazy_continuation_lines();
775
776 for lazy_info in lazy_cont_lines.iter() {
777 let line_num = lazy_info.line_num;
778
779 let is_within_block = list_blocks
783 .iter()
784 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
785
786 if !is_within_block {
787 continue;
788 }
789
790 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
792 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
793
794 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
796 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
797 } else {
798 None
799 };
800
801 warnings.push(LintWarning {
802 line: start_line,
803 column: start_col,
804 end_line,
805 end_column: end_col,
806 severity: Severity::Warning,
807 rule_name: Some(self.name().to_string()),
808 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
809 fix,
810 });
811 }
812 }
813
814 Ok(warnings)
815 }
816
817 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
818 Ok(self.fix_with_structure_impl(ctx))
819 }
820
821 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
822 ctx.content.is_empty() || ctx.list_blocks.is_empty()
825 }
826
827 fn category(&self) -> RuleCategory {
828 RuleCategory::List
829 }
830
831 fn as_any(&self) -> &dyn std::any::Any {
832 self
833 }
834
835 fn default_config_section(&self) -> Option<(String, toml::Value)> {
836 use crate::rule_config_serde::RuleConfig;
837 let default_config = MD032Config::default();
838 let json_value = serde_json::to_value(&default_config).ok()?;
839 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
840
841 if let toml::Value::Table(table) = toml_value {
842 if !table.is_empty() {
843 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
844 } else {
845 None
846 }
847 } else {
848 None
849 }
850 }
851
852 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
853 where
854 Self: Sized,
855 {
856 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
857 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
858 }
859}
860
861impl MD032BlanksAroundLists {
862 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
864 let lines = ctx.raw_lines();
865 let num_lines = lines.len();
866 if num_lines == 0 {
867 return String::new();
868 }
869
870 let list_blocks = self.convert_list_blocks(ctx);
871 if list_blocks.is_empty() {
872 return ctx.content.to_string();
873 }
874
875 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
878 if !self.config.allow_lazy_continuation {
879 let lazy_cont_lines = ctx.lazy_continuation_lines();
880 for lazy_info in lazy_cont_lines.iter() {
881 let line_num = lazy_info.line_num;
882 let is_within_block = list_blocks
884 .iter()
885 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
886 if !is_within_block {
887 continue;
888 }
889 if !Self::should_apply_lazy_fix(ctx, line_num) {
891 continue;
892 }
893 lazy_fixes.insert(line_num, lazy_info.clone());
894 }
895 }
896
897 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
898
899 for &(start_line, end_line, ref prefix) in &list_blocks {
901 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
903 continue;
904 }
905
906 if ctx
908 .line_info(start_line)
909 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
910 {
911 continue;
912 }
913
914 if start_line > 1 {
916 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
918
919 if !has_blank_separation && content_line > 0 {
921 let prev_line_str = lines[content_line - 1];
922 let is_prev_excluded = ctx
923 .line_info(content_line)
924 .is_some_and(|info| info.in_code_block || info.in_front_matter);
925 let prev_prefix = BLOCKQUOTE_PREFIX_RE
926 .find(prev_line_str)
927 .map_or(String::new(), |m| m.as_str().to_string());
928
929 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
930 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
932 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
934 insertions.insert(start_line, bq_prefix);
935 }
936 }
937 }
938
939 if end_line < num_lines {
941 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
943
944 if !has_blank_separation && content_line > 0 {
946 let next_line_str = lines[content_line - 1];
947 let is_next_excluded = ctx
949 .line_info(content_line)
950 .is_some_and(|info| info.in_code_block || info.in_front_matter)
951 || (content_line <= ctx.lines.len()
952 && ctx.lines[content_line - 1].in_code_block
953 && ctx.lines[content_line - 1].indent >= 2
954 && (ctx.lines[content_line - 1]
955 .content(ctx.content)
956 .trim()
957 .starts_with("```")
958 || ctx.lines[content_line - 1]
959 .content(ctx.content)
960 .trim()
961 .starts_with("~~~")));
962 let next_prefix = BLOCKQUOTE_PREFIX_RE
963 .find(next_line_str)
964 .map_or(String::new(), |m| m.as_str().to_string());
965
966 let end_line_str = lines[end_line - 1];
968 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
969 .find(end_line_str)
970 .map_or(String::new(), |m| m.as_str().to_string());
971 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
972 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
973 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
974
975 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
978 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
980 insertions.insert(end_line + 1, bq_prefix);
981 }
982 }
983 }
984 }
985
986 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
988 for (i, line) in lines.iter().enumerate() {
989 let current_line_num = i + 1;
990 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
991 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
992 {
993 result_lines.push(prefix_to_insert.clone());
994 }
995
996 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
998 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
999 {
1000 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1001 result_lines.push(fixed_line);
1002 } else {
1003 result_lines.push(line.to_string());
1004 }
1005 }
1006
1007 let mut result = result_lines.join("\n");
1009 if ctx.content.ends_with('\n') {
1010 result.push('\n');
1011 }
1012 result
1013 }
1014}
1015
1016fn is_blank_in_context(line: &str) -> bool {
1018 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1021 line[m.end()..].trim().is_empty()
1023 } else {
1024 line.trim().is_empty()
1026 }
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032 use crate::lint_context::LintContext;
1033 use crate::rule::Rule;
1034
1035 fn lint(content: &str) -> Vec<LintWarning> {
1036 let rule = MD032BlanksAroundLists::default();
1037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038 rule.check(&ctx).expect("Lint check failed")
1039 }
1040
1041 fn fix(content: &str) -> String {
1042 let rule = MD032BlanksAroundLists::default();
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 rule.fix(&ctx).expect("Lint fix failed")
1045 }
1046
1047 fn check_warnings_have_fixes(content: &str) {
1049 let warnings = lint(content);
1050 for warning in &warnings {
1051 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1052 }
1053 }
1054
1055 #[test]
1056 fn test_list_at_start() {
1057 let content = "- Item 1\n- Item 2\nText";
1060 let warnings = lint(content);
1061 assert_eq!(
1062 warnings.len(),
1063 0,
1064 "Trailing text is lazy continuation per CommonMark - no warning expected"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_list_at_end() {
1070 let content = "Text\n- Item 1\n- Item 2";
1071 let warnings = lint(content);
1072 assert_eq!(
1073 warnings.len(),
1074 1,
1075 "Expected 1 warning for list at end without preceding blank line"
1076 );
1077 assert_eq!(
1078 warnings[0].line, 2,
1079 "Warning should be on the first line of the list (line 2)"
1080 );
1081 assert!(warnings[0].message.contains("preceded by blank line"));
1082
1083 check_warnings_have_fixes(content);
1085
1086 let fixed_content = fix(content);
1087 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1088
1089 let warnings_after_fix = lint(&fixed_content);
1091 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1092 }
1093
1094 #[test]
1095 fn test_list_in_middle() {
1096 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1099 let warnings = lint(content);
1100 assert_eq!(
1101 warnings.len(),
1102 1,
1103 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1104 );
1105 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1106 assert!(warnings[0].message.contains("preceded by blank line"));
1107
1108 check_warnings_have_fixes(content);
1110
1111 let fixed_content = fix(content);
1112 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1113
1114 let warnings_after_fix = lint(&fixed_content);
1116 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1117 }
1118
1119 #[test]
1120 fn test_correct_spacing() {
1121 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1122 let warnings = lint(content);
1123 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1124
1125 let fixed_content = fix(content);
1126 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1127 }
1128
1129 #[test]
1130 fn test_list_with_content() {
1131 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1134 let warnings = lint(content);
1135 assert_eq!(
1136 warnings.len(),
1137 1,
1138 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1139 );
1140 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1141 assert!(warnings[0].message.contains("preceded by blank line"));
1142
1143 check_warnings_have_fixes(content);
1145
1146 let fixed_content = fix(content);
1147 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1148 assert_eq!(
1149 fixed_content, expected_fixed,
1150 "Fix did not produce the expected output. Got:\n{fixed_content}"
1151 );
1152
1153 let warnings_after_fix = lint(&fixed_content);
1155 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1156 }
1157
1158 #[test]
1159 fn test_nested_list() {
1160 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1162 let warnings = lint(content);
1163 assert_eq!(
1164 warnings.len(),
1165 1,
1166 "Nested list block needs preceding blank only. Got: {warnings:?}"
1167 );
1168 assert_eq!(warnings[0].line, 2);
1169 assert!(warnings[0].message.contains("preceded by blank line"));
1170
1171 check_warnings_have_fixes(content);
1173
1174 let fixed_content = fix(content);
1175 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1176
1177 let warnings_after_fix = lint(&fixed_content);
1179 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1180 }
1181
1182 #[test]
1183 fn test_list_with_internal_blanks() {
1184 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1186 let warnings = lint(content);
1187 assert_eq!(
1188 warnings.len(),
1189 1,
1190 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1191 );
1192 assert_eq!(warnings[0].line, 2);
1193 assert!(warnings[0].message.contains("preceded by blank line"));
1194
1195 check_warnings_have_fixes(content);
1197
1198 let fixed_content = fix(content);
1199 assert_eq!(
1200 fixed_content,
1201 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1202 );
1203
1204 let warnings_after_fix = lint(&fixed_content);
1206 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1207 }
1208
1209 #[test]
1210 fn test_ignore_code_blocks() {
1211 let content = "```\n- Not a list item\n```\nText";
1212 let warnings = lint(content);
1213 assert_eq!(warnings.len(), 0);
1214 let fixed_content = fix(content);
1215 assert_eq!(fixed_content, content);
1216 }
1217
1218 #[test]
1219 fn test_ignore_front_matter() {
1220 let content = "---\ntitle: Test\n---\n- List Item\nText";
1222 let warnings = lint(content);
1223 assert_eq!(
1224 warnings.len(),
1225 0,
1226 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1227 );
1228
1229 let fixed_content = fix(content);
1231 assert_eq!(fixed_content, content, "No changes when no warnings");
1232 }
1233
1234 #[test]
1235 fn test_multiple_lists() {
1236 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1241 let warnings = lint(content);
1242 assert!(
1244 !warnings.is_empty(),
1245 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1246 );
1247
1248 check_warnings_have_fixes(content);
1250
1251 let fixed_content = fix(content);
1252 let warnings_after_fix = lint(&fixed_content);
1254 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1255 }
1256
1257 #[test]
1258 fn test_adjacent_lists() {
1259 let content = "- List 1\n\n* List 2";
1260 let warnings = lint(content);
1261 assert_eq!(warnings.len(), 0);
1262 let fixed_content = fix(content);
1263 assert_eq!(fixed_content, content);
1264 }
1265
1266 #[test]
1267 fn test_list_in_blockquote() {
1268 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1270 let warnings = lint(content);
1271 assert_eq!(
1272 warnings.len(),
1273 1,
1274 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1275 );
1276 assert_eq!(warnings[0].line, 2);
1277
1278 check_warnings_have_fixes(content);
1280
1281 let fixed_content = fix(content);
1282 assert_eq!(
1284 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1285 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1286 );
1287
1288 let warnings_after_fix = lint(&fixed_content);
1290 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1291 }
1292
1293 #[test]
1294 fn test_ordered_list() {
1295 let content = "Text\n1. Item 1\n2. Item 2\nText";
1297 let warnings = lint(content);
1298 assert_eq!(warnings.len(), 1);
1299
1300 check_warnings_have_fixes(content);
1302
1303 let fixed_content = fix(content);
1304 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1305
1306 let warnings_after_fix = lint(&fixed_content);
1308 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1309 }
1310
1311 #[test]
1312 fn test_no_double_blank_fix() {
1313 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1316 assert_eq!(
1317 warnings.len(),
1318 0,
1319 "Should have no warnings - properly preceded, trailing is lazy"
1320 );
1321
1322 let fixed_content = fix(content);
1323 assert_eq!(
1324 fixed_content, content,
1325 "No fix needed when no warnings. Got:\n{fixed_content}"
1326 );
1327
1328 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1330 assert_eq!(warnings2.len(), 1);
1331 if !warnings2.is_empty() {
1332 assert_eq!(
1333 warnings2[0].line, 2,
1334 "Warning line for missing blank before should be the first line of the block"
1335 );
1336 }
1337
1338 check_warnings_have_fixes(content2);
1340
1341 let fixed_content2 = fix(content2);
1342 assert_eq!(
1343 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1344 "Fix added extra blank before. Got:\n{fixed_content2}"
1345 );
1346 }
1347
1348 #[test]
1349 fn test_empty_input() {
1350 let content = "";
1351 let warnings = lint(content);
1352 assert_eq!(warnings.len(), 0);
1353 let fixed_content = fix(content);
1354 assert_eq!(fixed_content, "");
1355 }
1356
1357 #[test]
1358 fn test_only_list() {
1359 let content = "- Item 1\n- Item 2";
1360 let warnings = lint(content);
1361 assert_eq!(warnings.len(), 0);
1362 let fixed_content = fix(content);
1363 assert_eq!(fixed_content, content);
1364 }
1365
1366 #[test]
1369 fn test_fix_complex_nested_blockquote() {
1370 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1372 let warnings = lint(content);
1373 assert_eq!(
1374 warnings.len(),
1375 1,
1376 "Should warn for missing preceding blank only. Got: {warnings:?}"
1377 );
1378
1379 check_warnings_have_fixes(content);
1381
1382 let fixed_content = fix(content);
1383 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1385 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1386
1387 let warnings_after_fix = lint(&fixed_content);
1388 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1389 }
1390
1391 #[test]
1392 fn test_fix_mixed_list_markers() {
1393 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1396 let warnings = lint(content);
1397 assert!(
1399 !warnings.is_empty(),
1400 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1401 );
1402
1403 check_warnings_have_fixes(content);
1405
1406 let fixed_content = fix(content);
1407 assert!(
1409 fixed_content.contains("Text\n\n-"),
1410 "Fix should add blank line before first list item"
1411 );
1412
1413 let warnings_after_fix = lint(&fixed_content);
1415 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1416 }
1417
1418 #[test]
1419 fn test_fix_ordered_list_with_different_numbers() {
1420 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1422 let warnings = lint(content);
1423 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1424
1425 check_warnings_have_fixes(content);
1427
1428 let fixed_content = fix(content);
1429 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1430 assert_eq!(
1431 fixed_content, expected,
1432 "Fix should handle ordered lists with non-sequential numbers"
1433 );
1434
1435 let warnings_after_fix = lint(&fixed_content);
1437 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1438 }
1439
1440 #[test]
1441 fn test_fix_list_with_code_blocks_inside() {
1442 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1444 let warnings = lint(content);
1445 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1446
1447 check_warnings_have_fixes(content);
1449
1450 let fixed_content = fix(content);
1451 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1452 assert_eq!(
1453 fixed_content, expected,
1454 "Fix should handle lists with internal code blocks"
1455 );
1456
1457 let warnings_after_fix = lint(&fixed_content);
1459 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1460 }
1461
1462 #[test]
1463 fn test_fix_deeply_nested_lists() {
1464 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1466 let warnings = lint(content);
1467 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1468
1469 check_warnings_have_fixes(content);
1471
1472 let fixed_content = fix(content);
1473 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1474 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1475
1476 let warnings_after_fix = lint(&fixed_content);
1478 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1479 }
1480
1481 #[test]
1482 fn test_fix_list_with_multiline_items() {
1483 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1486 let warnings = lint(content);
1487 assert_eq!(
1488 warnings.len(),
1489 1,
1490 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1491 );
1492
1493 check_warnings_have_fixes(content);
1495
1496 let fixed_content = fix(content);
1497 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1498 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1499
1500 let warnings_after_fix = lint(&fixed_content);
1502 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1503 }
1504
1505 #[test]
1506 fn test_fix_list_at_document_boundaries() {
1507 let content1 = "- Item 1\n- Item 2";
1509 let warnings1 = lint(content1);
1510 assert_eq!(
1511 warnings1.len(),
1512 0,
1513 "List at document start should not need blank before"
1514 );
1515 let fixed1 = fix(content1);
1516 assert_eq!(fixed1, content1, "No fix needed for list at start");
1517
1518 let content2 = "Text\n- Item 1\n- Item 2";
1520 let warnings2 = lint(content2);
1521 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1522 check_warnings_have_fixes(content2);
1523 let fixed2 = fix(content2);
1524 assert_eq!(
1525 fixed2, "Text\n\n- Item 1\n- Item 2",
1526 "Should add blank before list at end"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_fix_preserves_existing_blank_lines() {
1532 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1533 let warnings = lint(content);
1534 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1535 let fixed_content = fix(content);
1536 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1537 }
1538
1539 #[test]
1540 fn test_fix_handles_tabs_and_spaces() {
1541 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1544 let warnings = lint(content);
1545 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1547
1548 check_warnings_have_fixes(content);
1550
1551 let fixed_content = fix(content);
1552 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1555 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1556
1557 let warnings_after_fix = lint(&fixed_content);
1559 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1560 }
1561
1562 #[test]
1563 fn test_fix_warning_objects_have_correct_ranges() {
1564 let content = "Text\n- Item 1\n- Item 2\nText";
1566 let warnings = lint(content);
1567 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1568
1569 for warning in &warnings {
1571 assert!(warning.fix.is_some(), "Warning should have fix");
1572 let fix = warning.fix.as_ref().unwrap();
1573 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1574 assert!(
1575 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1576 "Fix should have replacement or be insertion"
1577 );
1578 }
1579 }
1580
1581 #[test]
1582 fn test_fix_idempotent() {
1583 let content = "Text\n- Item 1\n- Item 2\nText";
1585
1586 let fixed_once = fix(content);
1588 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1589
1590 let fixed_twice = fix(&fixed_once);
1592 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1593
1594 let warnings_after_fix = lint(&fixed_once);
1596 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1597 }
1598
1599 #[test]
1600 fn test_fix_with_normalized_line_endings() {
1601 let content = "Text\n- Item 1\n- Item 2\nText";
1605 let warnings = lint(content);
1606 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1607
1608 check_warnings_have_fixes(content);
1610
1611 let fixed_content = fix(content);
1612 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1614 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1615 }
1616
1617 #[test]
1618 fn test_fix_preserves_final_newline() {
1619 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1622 let fixed_with_newline = fix(content_with_newline);
1623 assert!(
1624 fixed_with_newline.ends_with('\n'),
1625 "Fix should preserve final newline when present"
1626 );
1627 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1629
1630 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1632 let fixed_without_newline = fix(content_without_newline);
1633 assert!(
1634 !fixed_without_newline.ends_with('\n'),
1635 "Fix should not add final newline when not present"
1636 );
1637 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1639 }
1640
1641 #[test]
1642 fn test_fix_multiline_list_items_no_indent() {
1643 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";
1644
1645 let warnings = lint(content);
1646 assert_eq!(
1648 warnings.len(),
1649 0,
1650 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1651 );
1652
1653 let fixed_content = fix(content);
1654 assert_eq!(
1656 fixed_content, content,
1657 "Should not modify correctly formatted multi-line list items"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_nested_list_with_lazy_continuation() {
1663 let content = r#"# Test
1669
1670- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1671 1. Switch/case dispatcher statements (original Phase 3.2)
1672 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1673`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1674 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1675 references"#;
1676
1677 let warnings = lint(content);
1678 let md032_warnings: Vec<_> = warnings
1681 .iter()
1682 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1683 .collect();
1684 assert_eq!(
1685 md032_warnings.len(),
1686 0,
1687 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1688 );
1689 }
1690
1691 #[test]
1692 fn test_pipes_in_code_spans_not_detected_as_table() {
1693 let content = r#"# Test
1695
1696- Item with `a | b` inline code
1697 - Nested item should work
1698
1699"#;
1700
1701 let warnings = lint(content);
1702 let md032_warnings: Vec<_> = warnings
1703 .iter()
1704 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1705 .collect();
1706 assert_eq!(
1707 md032_warnings.len(),
1708 0,
1709 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_multiple_code_spans_with_pipes() {
1715 let content = r#"# Test
1717
1718- Item with `a | b` and `c || d` operators
1719 - Nested item should work
1720
1721"#;
1722
1723 let warnings = lint(content);
1724 let md032_warnings: Vec<_> = warnings
1725 .iter()
1726 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1727 .collect();
1728 assert_eq!(
1729 md032_warnings.len(),
1730 0,
1731 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1732 );
1733 }
1734
1735 #[test]
1736 fn test_actual_table_breaks_list() {
1737 let content = r#"# Test
1739
1740- Item before table
1741
1742| Col1 | Col2 |
1743|------|------|
1744| A | B |
1745
1746- Item after table
1747
1748"#;
1749
1750 let warnings = lint(content);
1751 let md032_warnings: Vec<_> = warnings
1753 .iter()
1754 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1755 .collect();
1756 assert_eq!(
1757 md032_warnings.len(),
1758 0,
1759 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_thematic_break_not_lazy_continuation() {
1765 let content = r#"- Item 1
1768- Item 2
1769***
1770
1771More text.
1772"#;
1773
1774 let warnings = lint(content);
1775 let md032_warnings: Vec<_> = warnings
1776 .iter()
1777 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1778 .collect();
1779 assert_eq!(
1780 md032_warnings.len(),
1781 1,
1782 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1783 );
1784 assert!(
1785 md032_warnings[0].message.contains("followed by blank line"),
1786 "Warning should be about missing blank after list"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_thematic_break_with_blank_line() {
1792 let content = r#"- Item 1
1794- Item 2
1795
1796***
1797
1798More text.
1799"#;
1800
1801 let warnings = lint(content);
1802 let md032_warnings: Vec<_> = warnings
1803 .iter()
1804 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1805 .collect();
1806 assert_eq!(
1807 md032_warnings.len(),
1808 0,
1809 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_various_thematic_break_styles() {
1815 for hr in ["---", "***", "___"] {
1820 let content = format!(
1821 r#"- Item 1
1822- Item 2
1823{hr}
1824
1825More text.
1826"#
1827 );
1828
1829 let warnings = lint(&content);
1830 let md032_warnings: Vec<_> = warnings
1831 .iter()
1832 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1833 .collect();
1834 assert_eq!(
1835 md032_warnings.len(),
1836 1,
1837 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1838 );
1839 }
1840 }
1841
1842 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1845 let rule = MD032BlanksAroundLists::from_config_struct(config);
1846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1847 rule.check(&ctx).expect("Lint check failed")
1848 }
1849
1850 fn fix_with_config(content: &str, config: MD032Config) -> String {
1851 let rule = MD032BlanksAroundLists::from_config_struct(config);
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 rule.fix(&ctx).expect("Lint fix failed")
1854 }
1855
1856 #[test]
1857 fn test_lazy_continuation_allowed_by_default() {
1858 let content = "# Heading\n\n1. List\nSome text.";
1860 let warnings = lint(content);
1861 assert_eq!(
1862 warnings.len(),
1863 0,
1864 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1865 );
1866 }
1867
1868 #[test]
1869 fn test_lazy_continuation_disallowed() {
1870 let content = "# Heading\n\n1. List\nSome text.";
1872 let config = MD032Config {
1873 allow_lazy_continuation: false,
1874 };
1875 let warnings = lint_with_config(content, config);
1876 assert_eq!(
1877 warnings.len(),
1878 1,
1879 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1880 );
1881 assert!(
1882 warnings[0].message.contains("Lazy continuation"),
1883 "Warning message should mention lazy continuation"
1884 );
1885 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1886 }
1887
1888 #[test]
1889 fn test_lazy_continuation_fix() {
1890 let content = "# Heading\n\n1. List\nSome text.";
1892 let config = MD032Config {
1893 allow_lazy_continuation: false,
1894 };
1895 let fixed = fix_with_config(content, config.clone());
1896 assert_eq!(
1898 fixed, "# Heading\n\n1. List\n Some text.",
1899 "Fix should add proper indentation to lazy continuation"
1900 );
1901
1902 let warnings_after = lint_with_config(&fixed, config);
1904 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1905 }
1906
1907 #[test]
1908 fn test_lazy_continuation_multiple_lines() {
1909 let content = "- Item 1\nLine 2\nLine 3";
1911 let config = MD032Config {
1912 allow_lazy_continuation: false,
1913 };
1914 let warnings = lint_with_config(content, config.clone());
1915 assert_eq!(
1917 warnings.len(),
1918 2,
1919 "Should warn for each lazy continuation line. Got: {warnings:?}"
1920 );
1921
1922 let fixed = fix_with_config(content, config.clone());
1923 assert_eq!(
1925 fixed, "- Item 1\n Line 2\n Line 3",
1926 "Fix should add proper indentation to lazy continuation lines"
1927 );
1928
1929 let warnings_after = lint_with_config(&fixed, config);
1931 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1932 }
1933
1934 #[test]
1935 fn test_lazy_continuation_with_indented_content() {
1936 let content = "- Item 1\n Indented content\nLazy text";
1938 let config = MD032Config {
1939 allow_lazy_continuation: false,
1940 };
1941 let warnings = lint_with_config(content, config);
1942 assert_eq!(
1943 warnings.len(),
1944 1,
1945 "Should warn for lazy text after indented content. Got: {warnings:?}"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_lazy_continuation_properly_separated() {
1951 let content = "- Item 1\n\nSome text.";
1953 let config = MD032Config {
1954 allow_lazy_continuation: false,
1955 };
1956 let warnings = lint_with_config(content, config);
1957 assert_eq!(
1958 warnings.len(),
1959 0,
1960 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1961 );
1962 }
1963
1964 #[test]
1967 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1968 let content = "1) First item\nLazy continuation";
1970 let config = MD032Config {
1971 allow_lazy_continuation: false,
1972 };
1973 let warnings = lint_with_config(content, config.clone());
1974 assert_eq!(
1975 warnings.len(),
1976 1,
1977 "Should warn for lazy continuation with parenthesis marker"
1978 );
1979
1980 let fixed = fix_with_config(content, config);
1981 assert_eq!(fixed, "1) First item\n Lazy continuation");
1983 }
1984
1985 #[test]
1986 fn test_lazy_continuation_followed_by_another_list() {
1987 let content = "- Item 1\nSome text\n- Item 2";
1993 let config = MD032Config {
1994 allow_lazy_continuation: false,
1995 };
1996 let warnings = lint_with_config(content, config);
1997 assert_eq!(
1999 warnings.len(),
2000 1,
2001 "Should warn about lazy continuation within list. Got: {warnings:?}"
2002 );
2003 assert!(
2004 warnings[0].message.contains("Lazy continuation"),
2005 "Warning should be about lazy continuation"
2006 );
2007 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2008 }
2009
2010 #[test]
2011 fn test_lazy_continuation_multiple_in_document() {
2012 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2017 let config = MD032Config {
2018 allow_lazy_continuation: false,
2019 };
2020 let warnings = lint_with_config(content, config.clone());
2021 assert_eq!(
2023 warnings.len(),
2024 2,
2025 "Should warn for both lazy continuations. Got: {warnings:?}"
2026 );
2027
2028 let fixed = fix_with_config(content, config.clone());
2029 assert!(
2031 fixed.contains(" Lazy 1"),
2032 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2033 );
2034 assert!(
2035 fixed.contains(" Lazy 2"),
2036 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2037 );
2038
2039 let warnings_after = lint_with_config(&fixed, config);
2040 assert_eq!(
2042 warnings_after.len(),
2043 0,
2044 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2045 );
2046 }
2047
2048 #[test]
2049 fn test_lazy_continuation_end_of_document_no_newline() {
2050 let content = "- Item\nNo trailing newline";
2052 let config = MD032Config {
2053 allow_lazy_continuation: false,
2054 };
2055 let warnings = lint_with_config(content, config.clone());
2056 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2057
2058 let fixed = fix_with_config(content, config);
2059 assert_eq!(fixed, "- Item\n No trailing newline");
2061 }
2062
2063 #[test]
2064 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2065 let content = "- Item 1\n---";
2068 let config = MD032Config {
2069 allow_lazy_continuation: false,
2070 };
2071 let warnings = lint_with_config(content, config.clone());
2072 assert_eq!(
2074 warnings.len(),
2075 1,
2076 "List should need blank line before thematic break. Got: {warnings:?}"
2077 );
2078
2079 let fixed = fix_with_config(content, config);
2081 assert_eq!(fixed, "- Item 1\n\n---");
2082 }
2083
2084 #[test]
2085 fn test_lazy_continuation_heading_not_flagged() {
2086 let content = "- Item 1\n# Heading";
2089 let config = MD032Config {
2090 allow_lazy_continuation: false,
2091 };
2092 let warnings = lint_with_config(content, config);
2093 assert!(
2096 warnings.iter().all(|w| !w.message.contains("lazy")),
2097 "Heading should not trigger lazy continuation warning"
2098 );
2099 }
2100
2101 #[test]
2102 fn test_lazy_continuation_mixed_list_types() {
2103 let content = "- Unordered\n1. Ordered\nLazy text";
2105 let config = MD032Config {
2106 allow_lazy_continuation: false,
2107 };
2108 let warnings = lint_with_config(content, config.clone());
2109 assert!(!warnings.is_empty(), "Should warn about structure issues");
2110 }
2111
2112 #[test]
2113 fn test_lazy_continuation_deep_nesting() {
2114 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2116 let config = MD032Config {
2117 allow_lazy_continuation: false,
2118 };
2119 let warnings = lint_with_config(content, config.clone());
2120 assert!(
2121 !warnings.is_empty(),
2122 "Should warn about lazy continuation after nested list"
2123 );
2124
2125 let fixed = fix_with_config(content, config.clone());
2126 let warnings_after = lint_with_config(&fixed, config);
2127 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2128 }
2129
2130 #[test]
2131 fn test_lazy_continuation_with_emphasis_in_text() {
2132 let content = "- Item\n*emphasized* continuation";
2134 let config = MD032Config {
2135 allow_lazy_continuation: false,
2136 };
2137 let warnings = lint_with_config(content, config.clone());
2138 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2139
2140 let fixed = fix_with_config(content, config);
2141 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2143 }
2144
2145 #[test]
2146 fn test_lazy_continuation_with_code_span() {
2147 let content = "- Item\n`code` continuation";
2149 let config = MD032Config {
2150 allow_lazy_continuation: false,
2151 };
2152 let warnings = lint_with_config(content, config.clone());
2153 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2154
2155 let fixed = fix_with_config(content, config);
2156 assert_eq!(fixed, "- Item\n `code` continuation");
2158 }
2159
2160 #[test]
2167 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2168 let content = r#"1. Create a new Chat conversation:
2171 - On the sidebar, select **New Chat**.
2172 - In the box, type `/new`.
2173 A new Chat conversation replaces the previous one.
21741. Under the Chat text box, turn off the toggle."#;
2175 let config = MD032Config {
2176 allow_lazy_continuation: false,
2177 };
2178 let warnings = lint_with_config(content, config);
2179 let lazy_warnings: Vec<_> = warnings
2181 .iter()
2182 .filter(|w| w.message.contains("Lazy continuation"))
2183 .collect();
2184 assert!(
2185 !lazy_warnings.is_empty(),
2186 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2187 );
2188 assert!(
2189 lazy_warnings.iter().any(|w| w.line == 4),
2190 "Should warn on line 4. Got: {lazy_warnings:?}"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2196 let content = r#"- `field`: Is the specific key:
2199 - `password`: Accesses the password.
2200 - `api_key`: Accesses the api_key.
2201 `token`: Specifies which ID token to use.
2202- `version_id`: Is the unique identifier."#;
2203 let config = MD032Config {
2204 allow_lazy_continuation: false,
2205 };
2206 let warnings = lint_with_config(content, config);
2207 let lazy_warnings: Vec<_> = warnings
2209 .iter()
2210 .filter(|w| w.message.contains("Lazy continuation"))
2211 .collect();
2212 assert!(
2213 !lazy_warnings.is_empty(),
2214 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2215 );
2216 assert!(
2217 lazy_warnings.iter().any(|w| w.line == 4),
2218 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2219 );
2220 }
2221
2222 #[test]
2223 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2224 let content = r#"- Check out the branch, and test locally.
2226 - If the MR requires significant modifications:
2227 - **Skip local testing** and review instead.
2228 - **Request verification** from the author.
2229 - **Identify the minimal change** needed.
2230 Your testing might result in opportunities.
2231- If you don't understand, _say so_."#;
2232 let config = MD032Config {
2233 allow_lazy_continuation: false,
2234 };
2235 let warnings = lint_with_config(content, config);
2236 let lazy_warnings: Vec<_> = warnings
2238 .iter()
2239 .filter(|w| w.message.contains("Lazy continuation"))
2240 .collect();
2241 assert!(
2242 !lazy_warnings.is_empty(),
2243 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2244 );
2245 assert!(
2246 lazy_warnings.iter().any(|w| w.line == 6),
2247 "Should warn on line 6. Got: {lazy_warnings:?}"
2248 );
2249 }
2250
2251 #[test]
2252 fn test_issue295_ordered_list_nested_bullets_continuation() {
2253 let content = r#"# Test
2256
22571. First item.
2258 - Nested A.
2259 - Nested B.
2260 Continuation at outer level.
22611. Second item."#;
2262 let config = MD032Config {
2263 allow_lazy_continuation: false,
2264 };
2265 let warnings = lint_with_config(content, config);
2266 let lazy_warnings: Vec<_> = warnings
2268 .iter()
2269 .filter(|w| w.message.contains("Lazy continuation"))
2270 .collect();
2271 assert!(
2272 !lazy_warnings.is_empty(),
2273 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2274 );
2275 assert!(
2277 lazy_warnings.iter().any(|w| w.line == 6),
2278 "Should warn on line 6. Got: {lazy_warnings:?}"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_issue295_multiple_lazy_lines_after_nested() {
2284 let content = r#"1. The device client receives a response.
2286 - Those defined by OAuth Framework.
2287 - Those specific to device authorization.
2288 Those error responses are described below.
2289 For more information on each response,
2290 see the documentation.
22911. Next step in the process."#;
2292 let config = MD032Config {
2293 allow_lazy_continuation: false,
2294 };
2295 let warnings = lint_with_config(content, config);
2296 let lazy_warnings: Vec<_> = warnings
2298 .iter()
2299 .filter(|w| w.message.contains("Lazy continuation"))
2300 .collect();
2301 assert!(
2302 lazy_warnings.len() >= 3,
2303 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2304 lazy_warnings.len()
2305 );
2306 }
2307
2308 #[test]
2309 fn test_issue295_properly_indented_not_lazy() {
2310 let content = r#"1. First item.
2312 - Nested A.
2313 - Nested B.
2314
2315 Properly indented continuation.
23161. Second item."#;
2317 let config = MD032Config {
2318 allow_lazy_continuation: false,
2319 };
2320 let warnings = lint_with_config(content, config);
2321 let lazy_warnings: Vec<_> = warnings
2323 .iter()
2324 .filter(|w| w.message.contains("Lazy continuation"))
2325 .collect();
2326 assert_eq!(
2327 lazy_warnings.len(),
2328 0,
2329 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2330 );
2331 }
2332
2333 #[test]
2340 fn test_html_comment_before_list_with_preceding_blank() {
2341 let content = "Some text.\n\n<!-- comment -->\n- List item";
2344 let warnings = lint(content);
2345 assert_eq!(
2346 warnings.len(),
2347 0,
2348 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_html_comment_after_list_with_following_blank() {
2354 let content = "- List item\n<!-- comment -->\n\nSome text.";
2356 let warnings = lint(content);
2357 assert_eq!(
2358 warnings.len(),
2359 0,
2360 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2361 );
2362 }
2363
2364 #[test]
2365 fn test_list_inside_html_comment_ignored() {
2366 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2368 let warnings = lint(content);
2369 assert_eq!(
2370 warnings.len(),
2371 0,
2372 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_multiline_html_comment_before_list() {
2378 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2380 let warnings = lint(content);
2381 assert_eq!(
2382 warnings.len(),
2383 0,
2384 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_no_blank_before_html_comment_still_warns() {
2390 let content = "Some text.\n<!-- comment -->\n- List item";
2392 let warnings = lint(content);
2393 assert_eq!(
2394 warnings.len(),
2395 1,
2396 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2397 );
2398 assert!(
2399 warnings[0].message.contains("preceded by blank line"),
2400 "Should be 'preceded by blank line' warning"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2406 let content = "- List item\n<!-- comment -->\nSome text.";
2409 let warnings = lint(content);
2410 assert_eq!(
2411 warnings.len(),
2412 0,
2413 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2414 );
2415 }
2416
2417 #[test]
2418 fn test_list_followed_by_heading_through_comment_should_warn() {
2419 let content = "- List item\n<!-- comment -->\n# Heading";
2421 let warnings = lint(content);
2422 assert!(
2425 warnings.len() <= 1,
2426 "Should handle heading after comment gracefully. Got: {warnings:?}"
2427 );
2428 }
2429
2430 #[test]
2431 fn test_html_comment_between_list_and_text_both_directions() {
2432 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2434 let warnings = lint(content);
2435 assert_eq!(
2436 warnings.len(),
2437 0,
2438 "Should not warn with proper separation through comments. Got: {warnings:?}"
2439 );
2440 }
2441
2442 #[test]
2443 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2444 let content = "Text.\n\n<!-- comment -->\n- Item";
2446 let fixed = fix(content);
2447 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2448 }
2449
2450 #[test]
2451 fn test_html_comment_fix_adds_blank_when_needed() {
2452 let content = "Text.\n<!-- comment -->\n- Item";
2455 let fixed = fix(content);
2456 assert!(
2457 fixed.contains("<!-- comment -->\n\n- Item"),
2458 "Fix should add blank line before list. Got: {fixed}"
2459 );
2460 }
2461
2462 #[test]
2463 fn test_ordered_list_inside_html_comment() {
2464 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2466 let warnings = lint(content);
2467 assert_eq!(
2468 warnings.len(),
2469 0,
2470 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2471 );
2472 }
2473
2474 #[test]
2481 fn test_blockquote_list_exit_no_warning() {
2482 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2484 let warnings = lint(content);
2485 assert_eq!(
2486 warnings.len(),
2487 0,
2488 "Should not warn when exiting blockquote. Got: {warnings:?}"
2489 );
2490 }
2491
2492 #[test]
2493 fn test_nested_blockquote_list_exit() {
2494 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2496 let warnings = lint(content);
2497 assert_eq!(
2498 warnings.len(),
2499 0,
2500 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2501 );
2502 }
2503
2504 #[test]
2505 fn test_blockquote_same_level_no_warning() {
2506 let content = "> - item 1\n> - item 2\n> Text after";
2509 let warnings = lint(content);
2510 assert_eq!(
2511 warnings.len(),
2512 0,
2513 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2514 );
2515 }
2516
2517 #[test]
2518 fn test_blockquote_list_with_special_chars() {
2519 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2521 let warnings = lint(content);
2522 assert_eq!(
2523 warnings.len(),
2524 0,
2525 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2526 );
2527 }
2528
2529 #[test]
2530 fn test_lazy_continuation_whitespace_only_line() {
2531 let content = "- Item\n \nText after whitespace-only line";
2534 let config = MD032Config {
2535 allow_lazy_continuation: false,
2536 };
2537 let warnings = lint_with_config(content, config);
2538 assert_eq!(
2540 warnings.len(),
2541 0,
2542 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_lazy_continuation_blockquote_context() {
2548 let content = "> - Item\n> Lazy in quote";
2550 let config = MD032Config {
2551 allow_lazy_continuation: false,
2552 };
2553 let warnings = lint_with_config(content, config);
2554 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2557 }
2558
2559 #[test]
2560 fn test_lazy_continuation_fix_preserves_content() {
2561 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2563 let config = MD032Config {
2564 allow_lazy_continuation: false,
2565 };
2566 let fixed = fix_with_config(content, config);
2567 assert!(fixed.contains("<>&"), "Should preserve special chars");
2568 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2569 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2571 }
2572
2573 #[test]
2574 fn test_lazy_continuation_fix_idempotent() {
2575 let content = "- Item\nLazy";
2577 let config = MD032Config {
2578 allow_lazy_continuation: false,
2579 };
2580 let fixed_once = fix_with_config(content, config.clone());
2581 let fixed_twice = fix_with_config(&fixed_once, config);
2582 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2583 }
2584
2585 #[test]
2586 fn test_lazy_continuation_config_default_allows() {
2587 let content = "- Item\nLazy text that continues";
2589 let default_config = MD032Config::default();
2590 assert!(
2591 default_config.allow_lazy_continuation,
2592 "Default should allow lazy continuation"
2593 );
2594 let warnings = lint_with_config(content, default_config);
2595 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2596 }
2597
2598 #[test]
2599 fn test_lazy_continuation_after_multi_line_item() {
2600 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2602 let config = MD032Config {
2603 allow_lazy_continuation: false,
2604 };
2605 let warnings = lint_with_config(content, config.clone());
2606 assert_eq!(
2607 warnings.len(),
2608 1,
2609 "Should warn only for the lazy line, not the indented line"
2610 );
2611 }
2612
2613 #[test]
2615 fn test_blockquote_list_with_continuation_and_nested() {
2616 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2619 let warnings = lint(content);
2620 assert_eq!(
2621 warnings.len(),
2622 0,
2623 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2624 );
2625 }
2626
2627 #[test]
2628 fn test_blockquote_list_simple() {
2629 let content = "> - item 1\n> - item 2";
2631 let warnings = lint(content);
2632 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2633 }
2634
2635 #[test]
2636 fn test_blockquote_list_with_continuation_only() {
2637 let content = "> - item 1\n> continuation\n> - item 2";
2639 let warnings = lint(content);
2640 assert_eq!(
2641 warnings.len(),
2642 0,
2643 "Blockquoted list with continuation should have no warnings"
2644 );
2645 }
2646
2647 #[test]
2648 fn test_blockquote_list_with_lazy_continuation() {
2649 let content = "> - item 1\n> lazy continuation\n> - item 2";
2651 let warnings = lint(content);
2652 assert_eq!(
2653 warnings.len(),
2654 0,
2655 "Blockquoted list with lazy continuation should have no warnings"
2656 );
2657 }
2658
2659 #[test]
2660 fn test_nested_blockquote_list() {
2661 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2663 let warnings = lint(content);
2664 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2665 }
2666
2667 #[test]
2668 fn test_blockquote_list_needs_preceding_blank() {
2669 let content = "> Text before\n> - item 1\n> - item 2";
2671 let warnings = lint(content);
2672 assert_eq!(
2673 warnings.len(),
2674 1,
2675 "Should warn for missing blank before blockquoted list"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_blockquote_list_properly_separated() {
2681 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2683 let warnings = lint(content);
2684 assert_eq!(
2685 warnings.len(),
2686 0,
2687 "Properly separated blockquoted list should have no warnings"
2688 );
2689 }
2690
2691 #[test]
2692 fn test_blockquote_ordered_list() {
2693 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2695 let warnings = lint(content);
2696 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2697 }
2698
2699 #[test]
2700 fn test_blockquote_list_with_empty_blockquote_line() {
2701 let content = "> - item 1\n>\n> - item 2";
2703 let warnings = lint(content);
2704 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2705 }
2706
2707 #[test]
2709 fn test_blockquote_list_multi_paragraph_items() {
2710 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2713 let warnings = lint(content);
2714 assert_eq!(
2715 warnings.len(),
2716 0,
2717 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2718 );
2719 }
2720
2721 #[test]
2723 fn test_blockquote_ordered_list_multi_paragraph_items() {
2724 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2725 let warnings = lint(content);
2726 assert_eq!(
2727 warnings.len(),
2728 0,
2729 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2730 );
2731 }
2732
2733 #[test]
2735 fn test_blockquote_list_multiple_continuations() {
2736 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2737 let warnings = lint(content);
2738 assert_eq!(
2739 warnings.len(),
2740 0,
2741 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2742 );
2743 }
2744
2745 #[test]
2747 fn test_nested_blockquote_multi_paragraph_list() {
2748 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2749 let warnings = lint(content);
2750 assert_eq!(
2751 warnings.len(),
2752 0,
2753 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2754 );
2755 }
2756
2757 #[test]
2759 fn test_triple_nested_blockquote_multi_paragraph_list() {
2760 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2761 let warnings = lint(content);
2762 assert_eq!(
2763 warnings.len(),
2764 0,
2765 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2766 );
2767 }
2768
2769 #[test]
2771 fn test_blockquote_list_last_item_continuation() {
2772 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2773 let warnings = lint(content);
2774 assert_eq!(
2775 warnings.len(),
2776 0,
2777 "Last item with continuation should have no warnings. Got: {warnings:?}"
2778 );
2779 }
2780
2781 #[test]
2783 fn test_blockquote_list_first_item_only_continuation() {
2784 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2785 let warnings = lint(content);
2786 assert_eq!(
2787 warnings.len(),
2788 0,
2789 "Single item with continuation should have no warnings. Got: {warnings:?}"
2790 );
2791 }
2792
2793 #[test]
2797 fn test_blockquote_level_change_breaks_list() {
2798 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2800 let warnings = lint(content);
2801 assert!(
2805 warnings.len() <= 2,
2806 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2807 );
2808 }
2809
2810 #[test]
2812 fn test_exit_blockquote_needs_blank_before_list() {
2813 let content = "> Blockquote text\n\n- List outside blockquote\n";
2815 let warnings = lint(content);
2816 assert_eq!(
2817 warnings.len(),
2818 0,
2819 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2820 );
2821
2822 let content2 = "> Blockquote text\n- List outside blockquote\n";
2826 let warnings2 = lint(content2);
2827 assert!(
2829 warnings2.len() <= 1,
2830 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2831 );
2832 }
2833
2834 #[test]
2836 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2837 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2839 let warnings = lint(content_dash);
2840 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2841
2842 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2844 let warnings = lint(content_asterisk);
2845 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2846
2847 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2849 let warnings = lint(content_plus);
2850 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2851 }
2852
2853 #[test]
2855 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2856 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2857 let warnings = lint(content);
2858 assert_eq!(
2859 warnings.len(),
2860 0,
2861 "Parenthesis ordered markers should work. Got: {warnings:?}"
2862 );
2863 }
2864
2865 #[test]
2867 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2868 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2870 let warnings = lint(content);
2871 assert_eq!(
2872 warnings.len(),
2873 0,
2874 "Multi-digit ordered list should work. Got: {warnings:?}"
2875 );
2876 }
2877
2878 #[test]
2880 fn test_blockquote_multi_paragraph_with_formatting() {
2881 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2882 let warnings = lint(content);
2883 assert_eq!(
2884 warnings.len(),
2885 0,
2886 "Continuation with inline formatting should work. Got: {warnings:?}"
2887 );
2888 }
2889
2890 #[test]
2892 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2893 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2894 let warnings = lint(content);
2895 assert_eq!(
2896 warnings.len(),
2897 0,
2898 "All items with continuations should work. Got: {warnings:?}"
2899 );
2900 }
2901
2902 #[test]
2904 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2905 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2906 let warnings = lint(content);
2907 assert_eq!(
2908 warnings.len(),
2909 0,
2910 "Lowercase continuation should work. Got: {warnings:?}"
2911 );
2912 }
2913
2914 #[test]
2916 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2917 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2918 let warnings = lint(content);
2919 assert_eq!(
2920 warnings.len(),
2921 0,
2922 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2923 );
2924 }
2925
2926 #[test]
2928 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2929 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2931 let warnings = lint(content);
2932 assert!(
2934 warnings.len() <= 1,
2935 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2936 );
2937 }
2938
2939 #[test]
2941 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2942 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2944 let warnings = lint(content);
2945 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2946 }
2947
2948 #[test]
2949 fn test_blockquote_list_varying_spaces_after_marker() {
2950 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2952 let warnings = lint(content);
2953 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2954 }
2955
2956 #[test]
2957 fn test_deeply_nested_blockquote_list() {
2958 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2960 let warnings = lint(content);
2961 assert_eq!(
2962 warnings.len(),
2963 0,
2964 "Deeply nested blockquote list should have no warnings"
2965 );
2966 }
2967
2968 #[test]
2969 fn test_blockquote_level_change_in_list() {
2970 let content = "> - item 1\n>> - deeper item\n> - item 2";
2972 let warnings = lint(content);
2975 assert!(
2976 !warnings.is_empty(),
2977 "Blockquote level change should break list and trigger warnings"
2978 );
2979 }
2980
2981 #[test]
2982 fn test_blockquote_list_with_code_span() {
2983 let content = "> - item with `code`\n> continuation\n> - item 2";
2985 let warnings = lint(content);
2986 assert_eq!(
2987 warnings.len(),
2988 0,
2989 "Blockquote list with code span should have no warnings"
2990 );
2991 }
2992
2993 #[test]
2994 fn test_blockquote_list_at_document_end() {
2995 let content = "> Some text\n>\n> - item 1\n> - item 2";
2997 let warnings = lint(content);
2998 assert_eq!(
2999 warnings.len(),
3000 0,
3001 "Blockquote list at document end should have no warnings"
3002 );
3003 }
3004
3005 #[test]
3006 fn test_fix_preserves_blockquote_prefix_before_list() {
3007 let content = "> Text before
3009> - Item 1
3010> - Item 2";
3011 let fixed = fix(content);
3012
3013 let expected = "> Text before
3015>
3016> - Item 1
3017> - Item 2";
3018 assert_eq!(
3019 fixed, expected,
3020 "Fix should insert '>' blank line, not plain blank line"
3021 );
3022 }
3023
3024 #[test]
3025 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3026 let content = ">>> Triple nested
3029>>> - Item 1
3030>>> - Item 2
3031>>> More text";
3032 let fixed = fix(content);
3033
3034 let expected = ">>> Triple nested
3036>>>
3037>>> - Item 1
3038>>> - Item 2
3039>>> More text";
3040 assert_eq!(
3041 fixed, expected,
3042 "Fix should preserve triple-nested blockquote prefix '>>>'"
3043 );
3044 }
3045
3046 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3049 let rule = MD032BlanksAroundLists::default();
3050 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3051 rule.check(&ctx).unwrap()
3052 }
3053
3054 #[test]
3055 fn test_quarto_list_after_div_open() {
3056 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3058 let warnings = lint_quarto(content);
3059 assert!(
3061 warnings.is_empty(),
3062 "Quarto div marker should be transparent before list: {warnings:?}"
3063 );
3064 }
3065
3066 #[test]
3067 fn test_quarto_list_before_div_close() {
3068 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3070 let warnings = lint_quarto(content);
3071 assert!(
3073 warnings.is_empty(),
3074 "Quarto div marker should be transparent after list: {warnings:?}"
3075 );
3076 }
3077
3078 #[test]
3079 fn test_quarto_list_needs_blank_without_div() {
3080 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3082 let warnings = lint_quarto(content);
3083 assert!(
3086 !warnings.is_empty(),
3087 "Should still require blank when not present: {warnings:?}"
3088 );
3089 }
3090
3091 #[test]
3092 fn test_quarto_list_in_callout_with_content() {
3093 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3095 let warnings = lint_quarto(content);
3096 assert!(
3097 warnings.is_empty(),
3098 "List with proper blanks inside callout should pass: {warnings:?}"
3099 );
3100 }
3101
3102 #[test]
3103 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3104 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3106 let warnings = lint(content); assert!(
3109 !warnings.is_empty(),
3110 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3111 );
3112 }
3113
3114 #[test]
3115 fn test_quarto_nested_divs_with_list() {
3116 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3118 let warnings = lint_quarto(content);
3119 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3120 }
3121
3122 #[test]
3123 fn test_issue512_complex_nested_list_with_continuation() {
3124 let content = "\
3127- First level of indentation.
3128 - Second level of indentation.
3129 - Third level of indentation.
3130 - Third level of indentation.
3131
3132 Second level list continuation.
3133
3134 First level list continuation.
3135- First level of indentation.
3136";
3137 let warnings = lint(content);
3138 assert!(
3139 warnings.is_empty(),
3140 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3141 );
3142 }
3143
3144 #[test]
3145 fn test_issue512_continuation_at_root_level() {
3146 let content = "\
3150- First level.
3151 - Second level.
3152
3153 First level continuation.
3154
3155Root level lazy continuation.
3156- Another first level item.
3157";
3158 let warnings = lint(content);
3159 assert_eq!(
3160 warnings.len(),
3161 1,
3162 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3163 );
3164 assert_eq!(warnings[0].line, 7);
3165 }
3166
3167 #[test]
3168 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3169 let content = "\
3171- Level 1 item.
3172 - Level 2 item.
3173 - Level 3 item.
3174
3175 Level 3 continuation.
3176
3177 Level 2 continuation.
3178
3179 Level 1 continuation (indented under marker).
3180- Another level 1 item.
3181";
3182 let warnings = lint(content);
3183 assert!(
3184 warnings.is_empty(),
3185 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3186 );
3187 }
3188
3189 #[test]
3190 fn test_pandoc_list_after_div_open() {
3191 let rule = MD032BlanksAroundLists::default();
3194 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Pandoc, None);
3196 let warnings = rule.check(&ctx).unwrap();
3197 assert!(
3198 warnings.is_empty(),
3199 "MD032 should treat Pandoc div marker as transparent before list: {warnings:?}"
3200 );
3201 }
3202}