1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::blockquote::effective_indent_in_blockquote;
3use crate::utils::element_cache::ElementCache;
4use crate::utils::quarto_divs;
5use crate::utils::range_utils::{LineIndex, calculate_line_range};
6use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
7use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
8use regex::Regex;
9use std::collections::HashSet;
10use std::sync::LazyLock;
11
12mod md032_config;
13pub use md032_config::MD032Config;
14
15static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
17
18fn is_thematic_break(line: &str) -> bool {
21 if ElementCache::calculate_indentation_width_default(line) > 3 {
23 return false;
24 }
25
26 let trimmed = line.trim();
27 if trimmed.len() < 3 {
28 return false;
29 }
30
31 let chars: Vec<char> = trimmed.chars().collect();
32 let first_non_space = chars.iter().find(|&&c| c != ' ');
33
34 if let Some(&marker) = first_non_space {
35 if marker != '-' && marker != '*' && marker != '_' {
36 return false;
37 }
38 let marker_count = chars.iter().filter(|&&c| c == marker).count();
39 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
40 marker_count >= 3 && other_count == 0
41 } else {
42 false
43 }
44}
45
46#[derive(Debug, Clone, Default)]
116pub struct MD032BlanksAroundLists {
117 config: MD032Config,
118}
119
120impl MD032BlanksAroundLists {
121 pub fn from_config_struct(config: MD032Config) -> Self {
122 Self { config }
123 }
124}
125
126impl MD032BlanksAroundLists {
127 fn should_require_blank_line_before(
129 ctx: &crate::lint_context::LintContext,
130 prev_line_num: usize,
131 current_line_num: usize,
132 ) -> bool {
133 if ctx
135 .line_info(prev_line_num)
136 .is_some_and(|info| info.in_code_block || info.in_front_matter)
137 {
138 return true;
139 }
140
141 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
143 return false;
144 }
145
146 true
148 }
149
150 fn is_nested_list(
152 ctx: &crate::lint_context::LintContext,
153 prev_line_num: usize, current_line_num: usize, ) -> bool {
156 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
158 let current_line = &ctx.lines[current_line_num - 1];
159 if current_line.indent >= 2 {
160 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
162 let prev_line = &ctx.lines[prev_line_num - 1];
163 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
165 return true;
166 }
167 }
168 }
169 }
170 false
171 }
172
173 fn detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
179 let mut lazy_lines = HashSet::new();
180 let parser = Parser::new_ext(ctx.content, Options::all());
181
182 let mut item_stack: Vec<(usize, usize)> = vec![];
184 let mut after_soft_break = false;
185
186 for (event, range) in parser.into_offset_iter() {
187 match event {
188 Event::Start(Tag::Item) => {
189 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
190 let line_info = ctx.lines.get(line_num.saturating_sub(1));
191 let line_content = line_info.map(|li| li.content(ctx.content)).unwrap_or("");
192
193 let bq_level = line_content
195 .chars()
196 .take_while(|c| *c == '>' || c.is_whitespace())
197 .filter(|&c| c == '>')
198 .count();
199
200 let expected_indent = if bq_level > 0 {
202 line_info
204 .and_then(|li| li.list_item.as_ref())
205 .map(|item| if item.is_ordered { 3 } else { 2 })
206 .unwrap_or(2)
207 } else {
208 line_info
209 .and_then(|li| li.list_item.as_ref())
210 .map(|item| item.content_column)
211 .unwrap_or(0)
212 };
213
214 item_stack.push((expected_indent, bq_level));
215 after_soft_break = false;
216 }
217 Event::End(TagEnd::Item) => {
218 item_stack.pop();
219 after_soft_break = false;
220 }
221 Event::SoftBreak if !item_stack.is_empty() => {
222 after_soft_break = true;
223 }
224 Event::Text(_) | Event::Code(_) if after_soft_break => {
225 if let Some(&(expected_indent, expected_bq_level)) = item_stack.last() {
226 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
227 let line_info = ctx.lines.get(line_num.saturating_sub(1));
228 let line_content = line_info.map(|li| li.content(ctx.content)).unwrap_or("");
229 let fallback_indent = line_info.map(|li| li.indent).unwrap_or(0);
230
231 let actual_indent =
232 effective_indent_in_blockquote(line_content, expected_bq_level, fallback_indent);
233
234 if actual_indent < expected_indent {
235 lazy_lines.insert(line_num);
236 }
237 }
238 after_soft_break = false;
239 }
240 _ => {
241 after_soft_break = false;
242 }
243 }
244 }
245
246 lazy_lines
247 }
248
249 fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
251 match line_offsets.binary_search(&byte_offset) {
252 Ok(idx) => idx + 1,
253 Err(idx) => idx.max(1),
254 }
255 }
256
257 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
265 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
266 for line_num in (1..before_line).rev() {
267 let idx = line_num - 1;
268 if let Some(info) = ctx.lines.get(idx) {
269 if info.in_html_comment {
271 continue;
272 }
273 if is_quarto {
275 let trimmed = info.content(ctx.content).trim();
276 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
277 continue;
278 }
279 }
280 return (line_num, info.is_blank);
281 }
282 }
283 (0, true)
285 }
286
287 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
294 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
295 let num_lines = ctx.lines.len();
296 for line_num in (after_line + 1)..=num_lines {
297 let idx = line_num - 1;
298 if let Some(info) = ctx.lines.get(idx) {
299 if info.in_html_comment {
301 continue;
302 }
303 if is_quarto {
305 let trimmed = info.content(ctx.content).trim();
306 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
307 continue;
308 }
309 }
310 return (line_num, info.is_blank);
311 }
312 }
313 (0, true)
315 }
316
317 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
319 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
320
321 for block in &ctx.list_blocks {
322 let mut segments: Vec<(usize, usize)> = Vec::new();
328 let mut current_start = block.start_line;
329 let mut prev_item_line = 0;
330
331 let get_blockquote_level = |line_num: usize| -> usize {
333 if line_num == 0 || line_num > ctx.lines.len() {
334 return 0;
335 }
336 let line_content = ctx.lines[line_num - 1].content(ctx.content);
337 BLOCKQUOTE_PREFIX_RE
338 .find(line_content)
339 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
340 .unwrap_or(0)
341 };
342
343 let mut prev_bq_level = 0;
344
345 for &item_line in &block.item_lines {
346 let current_bq_level = get_blockquote_level(item_line);
347
348 if prev_item_line > 0 {
349 let blockquote_level_changed = prev_bq_level != current_bq_level;
351
352 let mut has_standalone_code_fence = false;
355
356 let min_indent_for_content = if block.is_ordered {
358 3 } else {
362 2 };
365
366 for check_line in (prev_item_line + 1)..item_line {
367 if check_line - 1 < ctx.lines.len() {
368 let line = &ctx.lines[check_line - 1];
369 let line_content = line.content(ctx.content);
370 if line.in_code_block
371 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
372 {
373 if line.indent < min_indent_for_content {
376 has_standalone_code_fence = true;
377 break;
378 }
379 }
380 }
381 }
382
383 if has_standalone_code_fence || blockquote_level_changed {
384 segments.push((current_start, prev_item_line));
386 current_start = item_line;
387 }
388 }
389 prev_item_line = item_line;
390 prev_bq_level = current_bq_level;
391 }
392
393 if prev_item_line > 0 {
396 segments.push((current_start, prev_item_line));
397 }
398
399 let has_code_fence_splits = segments.len() > 1 && {
401 let mut found_fence = false;
403 for i in 0..segments.len() - 1 {
404 let seg_end = segments[i].1;
405 let next_start = segments[i + 1].0;
406 for check_line in (seg_end + 1)..next_start {
408 if check_line - 1 < ctx.lines.len() {
409 let line = &ctx.lines[check_line - 1];
410 let line_content = line.content(ctx.content);
411 if line.in_code_block
412 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
413 {
414 found_fence = true;
415 break;
416 }
417 }
418 }
419 if found_fence {
420 break;
421 }
422 }
423 found_fence
424 };
425
426 for (start, end) in segments.iter() {
428 let mut actual_end = *end;
430
431 if !has_code_fence_splits && *end < block.end_line {
434 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
436
437 let min_continuation_indent = if block_bq_level > 0 {
440 if block.is_ordered {
442 block.max_marker_width
443 } else {
444 2 }
446 } else {
447 ctx.lines
448 .get(*end - 1)
449 .and_then(|line_info| line_info.list_item.as_ref())
450 .map(|item| item.content_column)
451 .unwrap_or(2)
452 };
453
454 for check_line in (*end + 1)..=block.end_line {
455 if check_line - 1 < ctx.lines.len() {
456 let line = &ctx.lines[check_line - 1];
457 let line_content = line.content(ctx.content);
458 if block.item_lines.contains(&check_line) || line.heading.is_some() {
460 break;
461 }
462 if line.in_code_block {
464 break;
465 }
466
467 let effective_indent =
469 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
470
471 if effective_indent >= min_continuation_indent {
473 actual_end = check_line;
474 }
475 else if self.config.allow_lazy_continuation
480 && !line.is_blank
481 && line.heading.is_none()
482 && !block.item_lines.contains(&check_line)
483 && !is_thematic_break(line_content)
484 {
485 actual_end = check_line;
488 } else if !line.is_blank {
489 break;
491 }
492 }
493 }
494 }
495
496 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
497 }
498 }
499
500 blocks.retain(|(start, end, _)| {
502 let all_in_comment =
504 (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
505 !all_in_comment
506 });
507
508 blocks
509 }
510
511 fn perform_checks(
512 &self,
513 ctx: &crate::lint_context::LintContext,
514 lines: &[&str],
515 list_blocks: &[(usize, usize, String)],
516 line_index: &LineIndex,
517 ) -> LintResult {
518 let mut warnings = Vec::new();
519 let num_lines = lines.len();
520
521 for (line_idx, line) in lines.iter().enumerate() {
524 let line_num = line_idx + 1;
525
526 let is_in_list = list_blocks
528 .iter()
529 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
530 if is_in_list {
531 continue;
532 }
533
534 if ctx
536 .line_info(line_num)
537 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
538 {
539 continue;
540 }
541
542 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
544 if line_idx > 0 {
546 let prev_line = lines[line_idx - 1];
547 let prev_is_blank = is_blank_in_context(prev_line);
548 let prev_excluded = ctx
549 .line_info(line_idx)
550 .is_some_and(|info| info.in_code_block || info.in_front_matter);
551
552 let prev_trimmed = prev_line.trim();
557 let is_sentence_continuation = !prev_is_blank
558 && !prev_trimmed.is_empty()
559 && !prev_trimmed.ends_with('.')
560 && !prev_trimmed.ends_with('!')
561 && !prev_trimmed.ends_with('?')
562 && !prev_trimmed.ends_with(':')
563 && !prev_trimmed.ends_with(';')
564 && !prev_trimmed.ends_with('>')
565 && !prev_trimmed.ends_with('-')
566 && !prev_trimmed.ends_with('*');
567
568 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
569 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
571
572 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
573 warnings.push(LintWarning {
574 line: start_line,
575 column: start_col,
576 end_line,
577 end_column: end_col,
578 severity: Severity::Warning,
579 rule_name: Some(self.name().to_string()),
580 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
581 fix: Some(Fix {
582 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
583 replacement: format!("{bq_prefix}\n"),
584 }),
585 });
586 }
587
588 if line_idx + 1 < num_lines {
591 let next_line = lines[line_idx + 1];
592 let next_is_blank = is_blank_in_context(next_line);
593 let next_excluded = ctx
594 .line_info(line_idx + 2)
595 .is_some_and(|info| info.in_code_block || info.in_front_matter);
596
597 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
598 let next_is_list_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
600 || next_line.trim_start().starts_with("- ")
601 || next_line.trim_start().starts_with("* ")
602 || next_line.trim_start().starts_with("+ ")
603 || next_line.starts_with("1. ")
604 || (next_line.len() > next_line.trim_start().len()); if !next_is_list_content {
607 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
608 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
609 warnings.push(LintWarning {
610 line: start_line,
611 column: start_col,
612 end_line,
613 end_column: end_col,
614 severity: Severity::Warning,
615 rule_name: Some(self.name().to_string()),
616 message: "List should be followed by blank line".to_string(),
617 fix: Some(Fix {
618 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
619 replacement: format!("{bq_prefix}\n"),
620 }),
621 });
622 }
623 }
624 }
625 }
626 }
627 }
628
629 for &(start_line, end_line, ref prefix) in list_blocks {
630 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
632 continue;
633 }
634
635 if start_line > 1 {
636 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
638
639 if !has_blank_separation && content_line > 0 {
641 let prev_line_str = lines[content_line - 1];
642 let is_prev_excluded = ctx
643 .line_info(content_line)
644 .is_some_and(|info| info.in_code_block || info.in_front_matter);
645 let prev_prefix = BLOCKQUOTE_PREFIX_RE
646 .find(prev_line_str)
647 .map_or(String::new(), |m| m.as_str().to_string());
648 let prefixes_match = prev_prefix.trim() == prefix.trim();
649
650 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
653 if !is_prev_excluded && prefixes_match && should_require {
654 let (start_line, start_col, end_line, end_col) =
656 calculate_line_range(start_line, lines[start_line - 1]);
657
658 warnings.push(LintWarning {
659 line: start_line,
660 column: start_col,
661 end_line,
662 end_column: end_col,
663 severity: Severity::Warning,
664 rule_name: Some(self.name().to_string()),
665 message: "List should be preceded by blank line".to_string(),
666 fix: Some(Fix {
667 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
668 replacement: format!("{prefix}\n"),
669 }),
670 });
671 }
672 }
673 }
674
675 if end_line < num_lines {
676 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
678
679 if !has_blank_separation && content_line > 0 {
681 let next_line_str = lines[content_line - 1];
682 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
685 || (content_line <= ctx.lines.len()
686 && ctx.lines[content_line - 1].in_code_block
687 && ctx.lines[content_line - 1].indent >= 2);
688 let next_prefix = BLOCKQUOTE_PREFIX_RE
689 .find(next_line_str)
690 .map_or(String::new(), |m| m.as_str().to_string());
691
692 let end_line_str = lines[end_line - 1];
697 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
698 .find(end_line_str)
699 .map_or(String::new(), |m| m.as_str().to_string());
700 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
701 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
702 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
703
704 let prefixes_match = next_prefix.trim() == prefix.trim();
705
706 if !is_next_excluded && prefixes_match && !exits_blockquote {
709 let (start_line_last, start_col_last, end_line_last, end_col_last) =
711 calculate_line_range(end_line, lines[end_line - 1]);
712
713 warnings.push(LintWarning {
714 line: start_line_last,
715 column: start_col_last,
716 end_line: end_line_last,
717 end_column: end_col_last,
718 severity: Severity::Warning,
719 rule_name: Some(self.name().to_string()),
720 message: "List should be followed by blank line".to_string(),
721 fix: Some(Fix {
722 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
723 replacement: format!("{prefix}\n"),
724 }),
725 });
726 }
727 }
728 }
729 }
730 Ok(warnings)
731 }
732}
733
734impl Rule for MD032BlanksAroundLists {
735 fn name(&self) -> &'static str {
736 "MD032"
737 }
738
739 fn description(&self) -> &'static str {
740 "Lists should be surrounded by blank lines"
741 }
742
743 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
744 let content = ctx.content;
745 let lines: Vec<&str> = content.lines().collect();
746 let line_index = &ctx.line_index;
747
748 if lines.is_empty() {
750 return Ok(Vec::new());
751 }
752
753 let list_blocks = self.convert_list_blocks(ctx);
754
755 if list_blocks.is_empty() {
756 return Ok(Vec::new());
757 }
758
759 let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
760
761 if !self.config.allow_lazy_continuation {
766 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
767
768 for line_num in lazy_lines {
769 let is_within_block = list_blocks
773 .iter()
774 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
775
776 if !is_within_block {
777 continue;
778 }
779
780 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
782 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
783
784 warnings.push(LintWarning {
788 line: start_line,
789 column: start_col,
790 end_line,
791 end_column: end_col,
792 severity: Severity::Warning,
793 rule_name: Some(self.name().to_string()),
794 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
795 fix: None,
796 });
797 }
798 }
799
800 Ok(warnings)
801 }
802
803 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
804 self.fix_with_structure_impl(ctx)
805 }
806
807 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
808 if ctx.content.is_empty() || !ctx.likely_has_lists() {
810 return true;
811 }
812 ctx.list_blocks.is_empty()
814 }
815
816 fn category(&self) -> RuleCategory {
817 RuleCategory::List
818 }
819
820 fn as_any(&self) -> &dyn std::any::Any {
821 self
822 }
823
824 fn default_config_section(&self) -> Option<(String, toml::Value)> {
825 use crate::rule_config_serde::RuleConfig;
826 let default_config = MD032Config::default();
827 let json_value = serde_json::to_value(&default_config).ok()?;
828 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
829
830 if let toml::Value::Table(table) = toml_value {
831 if !table.is_empty() {
832 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
833 } else {
834 None
835 }
836 } else {
837 None
838 }
839 }
840
841 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
842 where
843 Self: Sized,
844 {
845 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
846 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
847 }
848}
849
850impl MD032BlanksAroundLists {
851 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
853 let lines: Vec<&str> = ctx.content.lines().collect();
854 let num_lines = lines.len();
855 if num_lines == 0 {
856 return Ok(String::new());
857 }
858
859 let list_blocks = self.convert_list_blocks(ctx);
860 if list_blocks.is_empty() {
861 return Ok(ctx.content.to_string());
862 }
863
864 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
865
866 for &(start_line, end_line, ref prefix) in &list_blocks {
868 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
870 continue;
871 }
872
873 if start_line > 1 {
875 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
877
878 if !has_blank_separation && content_line > 0 {
880 let prev_line_str = lines[content_line - 1];
881 let is_prev_excluded = ctx
882 .line_info(content_line)
883 .is_some_and(|info| info.in_code_block || info.in_front_matter);
884 let prev_prefix = BLOCKQUOTE_PREFIX_RE
885 .find(prev_line_str)
886 .map_or(String::new(), |m| m.as_str().to_string());
887
888 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
889 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
891 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
893 insertions.insert(start_line, bq_prefix);
894 }
895 }
896 }
897
898 if end_line < num_lines {
900 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
902
903 if !has_blank_separation && content_line > 0 {
905 let next_line_str = lines[content_line - 1];
906 let is_next_excluded = ctx
908 .line_info(content_line)
909 .is_some_and(|info| info.in_code_block || info.in_front_matter)
910 || (content_line <= ctx.lines.len()
911 && ctx.lines[content_line - 1].in_code_block
912 && ctx.lines[content_line - 1].indent >= 2
913 && (ctx.lines[content_line - 1]
914 .content(ctx.content)
915 .trim()
916 .starts_with("```")
917 || ctx.lines[content_line - 1]
918 .content(ctx.content)
919 .trim()
920 .starts_with("~~~")));
921 let next_prefix = BLOCKQUOTE_PREFIX_RE
922 .find(next_line_str)
923 .map_or(String::new(), |m| m.as_str().to_string());
924
925 let end_line_str = lines[end_line - 1];
927 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
928 .find(end_line_str)
929 .map_or(String::new(), |m| m.as_str().to_string());
930 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
931 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
932 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
933
934 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
937 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
939 insertions.insert(end_line + 1, bq_prefix);
940 }
941 }
942 }
943 }
944
945 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
947 for (i, line) in lines.iter().enumerate() {
948 let current_line_num = i + 1;
949 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
950 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
951 {
952 result_lines.push(prefix_to_insert.clone());
953 }
954 result_lines.push(line.to_string());
955 }
956
957 let mut result = result_lines.join("\n");
959 if ctx.content.ends_with('\n') {
960 result.push('\n');
961 }
962 Ok(result)
963 }
964}
965
966fn is_blank_in_context(line: &str) -> bool {
968 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
971 line[m.end()..].trim().is_empty()
973 } else {
974 line.trim().is_empty()
976 }
977}
978
979#[cfg(test)]
980mod tests {
981 use super::*;
982 use crate::lint_context::LintContext;
983 use crate::rule::Rule;
984
985 fn lint(content: &str) -> Vec<LintWarning> {
986 let rule = MD032BlanksAroundLists::default();
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 rule.check(&ctx).expect("Lint check failed")
989 }
990
991 fn fix(content: &str) -> String {
992 let rule = MD032BlanksAroundLists::default();
993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994 rule.fix(&ctx).expect("Lint fix failed")
995 }
996
997 fn check_warnings_have_fixes(content: &str) {
999 let warnings = lint(content);
1000 for warning in &warnings {
1001 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1002 }
1003 }
1004
1005 #[test]
1006 fn test_list_at_start() {
1007 let content = "- Item 1\n- Item 2\nText";
1010 let warnings = lint(content);
1011 assert_eq!(
1012 warnings.len(),
1013 0,
1014 "Trailing text is lazy continuation per CommonMark - no warning expected"
1015 );
1016 }
1017
1018 #[test]
1019 fn test_list_at_end() {
1020 let content = "Text\n- Item 1\n- Item 2";
1021 let warnings = lint(content);
1022 assert_eq!(
1023 warnings.len(),
1024 1,
1025 "Expected 1 warning for list at end without preceding blank line"
1026 );
1027 assert_eq!(
1028 warnings[0].line, 2,
1029 "Warning should be on the first line of the list (line 2)"
1030 );
1031 assert!(warnings[0].message.contains("preceded by blank line"));
1032
1033 check_warnings_have_fixes(content);
1035
1036 let fixed_content = fix(content);
1037 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1038
1039 let warnings_after_fix = lint(&fixed_content);
1041 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1042 }
1043
1044 #[test]
1045 fn test_list_in_middle() {
1046 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1049 let warnings = lint(content);
1050 assert_eq!(
1051 warnings.len(),
1052 1,
1053 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1054 );
1055 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1056 assert!(warnings[0].message.contains("preceded by blank line"));
1057
1058 check_warnings_have_fixes(content);
1060
1061 let fixed_content = fix(content);
1062 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1063
1064 let warnings_after_fix = lint(&fixed_content);
1066 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1067 }
1068
1069 #[test]
1070 fn test_correct_spacing() {
1071 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1072 let warnings = lint(content);
1073 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1074
1075 let fixed_content = fix(content);
1076 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1077 }
1078
1079 #[test]
1080 fn test_list_with_content() {
1081 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1084 let warnings = lint(content);
1085 assert_eq!(
1086 warnings.len(),
1087 1,
1088 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1089 );
1090 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1091 assert!(warnings[0].message.contains("preceded by blank line"));
1092
1093 check_warnings_have_fixes(content);
1095
1096 let fixed_content = fix(content);
1097 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1098 assert_eq!(
1099 fixed_content, expected_fixed,
1100 "Fix did not produce the expected output. Got:\n{fixed_content}"
1101 );
1102
1103 let warnings_after_fix = lint(&fixed_content);
1105 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1106 }
1107
1108 #[test]
1109 fn test_nested_list() {
1110 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1112 let warnings = lint(content);
1113 assert_eq!(
1114 warnings.len(),
1115 1,
1116 "Nested list block needs preceding blank only. Got: {warnings:?}"
1117 );
1118 assert_eq!(warnings[0].line, 2);
1119 assert!(warnings[0].message.contains("preceded by blank line"));
1120
1121 check_warnings_have_fixes(content);
1123
1124 let fixed_content = fix(content);
1125 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1126
1127 let warnings_after_fix = lint(&fixed_content);
1129 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1130 }
1131
1132 #[test]
1133 fn test_list_with_internal_blanks() {
1134 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1136 let warnings = lint(content);
1137 assert_eq!(
1138 warnings.len(),
1139 1,
1140 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1141 );
1142 assert_eq!(warnings[0].line, 2);
1143 assert!(warnings[0].message.contains("preceded by blank line"));
1144
1145 check_warnings_have_fixes(content);
1147
1148 let fixed_content = fix(content);
1149 assert_eq!(
1150 fixed_content,
1151 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1152 );
1153
1154 let warnings_after_fix = lint(&fixed_content);
1156 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1157 }
1158
1159 #[test]
1160 fn test_ignore_code_blocks() {
1161 let content = "```\n- Not a list item\n```\nText";
1162 let warnings = lint(content);
1163 assert_eq!(warnings.len(), 0);
1164 let fixed_content = fix(content);
1165 assert_eq!(fixed_content, content);
1166 }
1167
1168 #[test]
1169 fn test_ignore_front_matter() {
1170 let content = "---\ntitle: Test\n---\n- List Item\nText";
1172 let warnings = lint(content);
1173 assert_eq!(
1174 warnings.len(),
1175 0,
1176 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1177 );
1178
1179 let fixed_content = fix(content);
1181 assert_eq!(fixed_content, content, "No changes when no warnings");
1182 }
1183
1184 #[test]
1185 fn test_multiple_lists() {
1186 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1191 let warnings = lint(content);
1192 assert!(
1194 !warnings.is_empty(),
1195 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1196 );
1197
1198 check_warnings_have_fixes(content);
1200
1201 let fixed_content = fix(content);
1202 let warnings_after_fix = lint(&fixed_content);
1204 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1205 }
1206
1207 #[test]
1208 fn test_adjacent_lists() {
1209 let content = "- List 1\n\n* List 2";
1210 let warnings = lint(content);
1211 assert_eq!(warnings.len(), 0);
1212 let fixed_content = fix(content);
1213 assert_eq!(fixed_content, content);
1214 }
1215
1216 #[test]
1217 fn test_list_in_blockquote() {
1218 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1220 let warnings = lint(content);
1221 assert_eq!(
1222 warnings.len(),
1223 1,
1224 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1225 );
1226 assert_eq!(warnings[0].line, 2);
1227
1228 check_warnings_have_fixes(content);
1230
1231 let fixed_content = fix(content);
1232 assert_eq!(
1234 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1235 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1236 );
1237
1238 let warnings_after_fix = lint(&fixed_content);
1240 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1241 }
1242
1243 #[test]
1244 fn test_ordered_list() {
1245 let content = "Text\n1. Item 1\n2. Item 2\nText";
1247 let warnings = lint(content);
1248 assert_eq!(warnings.len(), 1);
1249
1250 check_warnings_have_fixes(content);
1252
1253 let fixed_content = fix(content);
1254 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1255
1256 let warnings_after_fix = lint(&fixed_content);
1258 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1259 }
1260
1261 #[test]
1262 fn test_no_double_blank_fix() {
1263 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1266 assert_eq!(
1267 warnings.len(),
1268 0,
1269 "Should have no warnings - properly preceded, trailing is lazy"
1270 );
1271
1272 let fixed_content = fix(content);
1273 assert_eq!(
1274 fixed_content, content,
1275 "No fix needed when no warnings. Got:\n{fixed_content}"
1276 );
1277
1278 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1280 assert_eq!(warnings2.len(), 1);
1281 if !warnings2.is_empty() {
1282 assert_eq!(
1283 warnings2[0].line, 2,
1284 "Warning line for missing blank before should be the first line of the block"
1285 );
1286 }
1287
1288 check_warnings_have_fixes(content2);
1290
1291 let fixed_content2 = fix(content2);
1292 assert_eq!(
1293 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1294 "Fix added extra blank before. Got:\n{fixed_content2}"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_empty_input() {
1300 let content = "";
1301 let warnings = lint(content);
1302 assert_eq!(warnings.len(), 0);
1303 let fixed_content = fix(content);
1304 assert_eq!(fixed_content, "");
1305 }
1306
1307 #[test]
1308 fn test_only_list() {
1309 let content = "- Item 1\n- Item 2";
1310 let warnings = lint(content);
1311 assert_eq!(warnings.len(), 0);
1312 let fixed_content = fix(content);
1313 assert_eq!(fixed_content, content);
1314 }
1315
1316 #[test]
1319 fn test_fix_complex_nested_blockquote() {
1320 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1322 let warnings = lint(content);
1323 assert_eq!(
1324 warnings.len(),
1325 1,
1326 "Should warn for missing preceding blank only. Got: {warnings:?}"
1327 );
1328
1329 check_warnings_have_fixes(content);
1331
1332 let fixed_content = fix(content);
1333 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1335 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1336
1337 let warnings_after_fix = lint(&fixed_content);
1338 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1339 }
1340
1341 #[test]
1342 fn test_fix_mixed_list_markers() {
1343 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1346 let warnings = lint(content);
1347 assert!(
1349 !warnings.is_empty(),
1350 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1351 );
1352
1353 check_warnings_have_fixes(content);
1355
1356 let fixed_content = fix(content);
1357 assert!(
1359 fixed_content.contains("Text\n\n-"),
1360 "Fix should add blank line before first list item"
1361 );
1362
1363 let warnings_after_fix = lint(&fixed_content);
1365 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1366 }
1367
1368 #[test]
1369 fn test_fix_ordered_list_with_different_numbers() {
1370 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1372 let warnings = lint(content);
1373 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1374
1375 check_warnings_have_fixes(content);
1377
1378 let fixed_content = fix(content);
1379 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1380 assert_eq!(
1381 fixed_content, expected,
1382 "Fix should handle ordered lists with non-sequential numbers"
1383 );
1384
1385 let warnings_after_fix = lint(&fixed_content);
1387 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1388 }
1389
1390 #[test]
1391 fn test_fix_list_with_code_blocks_inside() {
1392 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1394 let warnings = lint(content);
1395 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1396
1397 check_warnings_have_fixes(content);
1399
1400 let fixed_content = fix(content);
1401 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1402 assert_eq!(
1403 fixed_content, expected,
1404 "Fix should handle lists with internal code blocks"
1405 );
1406
1407 let warnings_after_fix = lint(&fixed_content);
1409 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1410 }
1411
1412 #[test]
1413 fn test_fix_deeply_nested_lists() {
1414 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1416 let warnings = lint(content);
1417 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1418
1419 check_warnings_have_fixes(content);
1421
1422 let fixed_content = fix(content);
1423 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1424 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1425
1426 let warnings_after_fix = lint(&fixed_content);
1428 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1429 }
1430
1431 #[test]
1432 fn test_fix_list_with_multiline_items() {
1433 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1436 let warnings = lint(content);
1437 assert_eq!(
1438 warnings.len(),
1439 1,
1440 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1441 );
1442
1443 check_warnings_have_fixes(content);
1445
1446 let fixed_content = fix(content);
1447 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1448 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1449
1450 let warnings_after_fix = lint(&fixed_content);
1452 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1453 }
1454
1455 #[test]
1456 fn test_fix_list_at_document_boundaries() {
1457 let content1 = "- Item 1\n- Item 2";
1459 let warnings1 = lint(content1);
1460 assert_eq!(
1461 warnings1.len(),
1462 0,
1463 "List at document start should not need blank before"
1464 );
1465 let fixed1 = fix(content1);
1466 assert_eq!(fixed1, content1, "No fix needed for list at start");
1467
1468 let content2 = "Text\n- Item 1\n- Item 2";
1470 let warnings2 = lint(content2);
1471 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1472 check_warnings_have_fixes(content2);
1473 let fixed2 = fix(content2);
1474 assert_eq!(
1475 fixed2, "Text\n\n- Item 1\n- Item 2",
1476 "Should add blank before list at end"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_fix_preserves_existing_blank_lines() {
1482 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1483 let warnings = lint(content);
1484 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1485 let fixed_content = fix(content);
1486 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1487 }
1488
1489 #[test]
1490 fn test_fix_handles_tabs_and_spaces() {
1491 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1494 let warnings = lint(content);
1495 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1497
1498 check_warnings_have_fixes(content);
1500
1501 let fixed_content = fix(content);
1502 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1505 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1506
1507 let warnings_after_fix = lint(&fixed_content);
1509 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1510 }
1511
1512 #[test]
1513 fn test_fix_warning_objects_have_correct_ranges() {
1514 let content = "Text\n- Item 1\n- Item 2\nText";
1516 let warnings = lint(content);
1517 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1518
1519 for warning in &warnings {
1521 assert!(warning.fix.is_some(), "Warning should have fix");
1522 let fix = warning.fix.as_ref().unwrap();
1523 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1524 assert!(
1525 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1526 "Fix should have replacement or be insertion"
1527 );
1528 }
1529 }
1530
1531 #[test]
1532 fn test_fix_idempotent() {
1533 let content = "Text\n- Item 1\n- Item 2\nText";
1535
1536 let fixed_once = fix(content);
1538 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1539
1540 let fixed_twice = fix(&fixed_once);
1542 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1543
1544 let warnings_after_fix = lint(&fixed_once);
1546 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1547 }
1548
1549 #[test]
1550 fn test_fix_with_normalized_line_endings() {
1551 let content = "Text\n- Item 1\n- Item 2\nText";
1555 let warnings = lint(content);
1556 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1557
1558 check_warnings_have_fixes(content);
1560
1561 let fixed_content = fix(content);
1562 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1564 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1565 }
1566
1567 #[test]
1568 fn test_fix_preserves_final_newline() {
1569 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1572 let fixed_with_newline = fix(content_with_newline);
1573 assert!(
1574 fixed_with_newline.ends_with('\n'),
1575 "Fix should preserve final newline when present"
1576 );
1577 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1579
1580 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1582 let fixed_without_newline = fix(content_without_newline);
1583 assert!(
1584 !fixed_without_newline.ends_with('\n'),
1585 "Fix should not add final newline when not present"
1586 );
1587 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1589 }
1590
1591 #[test]
1592 fn test_fix_multiline_list_items_no_indent() {
1593 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";
1594
1595 let warnings = lint(content);
1596 assert_eq!(
1598 warnings.len(),
1599 0,
1600 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1601 );
1602
1603 let fixed_content = fix(content);
1604 assert_eq!(
1606 fixed_content, content,
1607 "Should not modify correctly formatted multi-line list items"
1608 );
1609 }
1610
1611 #[test]
1612 fn test_nested_list_with_lazy_continuation() {
1613 let content = r#"# Test
1619
1620- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1621 1. Switch/case dispatcher statements (original Phase 3.2)
1622 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1623`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1624 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1625 references"#;
1626
1627 let warnings = lint(content);
1628 let md032_warnings: Vec<_> = warnings
1631 .iter()
1632 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1633 .collect();
1634 assert_eq!(
1635 md032_warnings.len(),
1636 0,
1637 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_pipes_in_code_spans_not_detected_as_table() {
1643 let content = r#"# Test
1645
1646- Item with `a | b` inline code
1647 - Nested item should work
1648
1649"#;
1650
1651 let warnings = lint(content);
1652 let md032_warnings: Vec<_> = warnings
1653 .iter()
1654 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1655 .collect();
1656 assert_eq!(
1657 md032_warnings.len(),
1658 0,
1659 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_multiple_code_spans_with_pipes() {
1665 let content = r#"# Test
1667
1668- Item with `a | b` and `c || d` operators
1669 - Nested item should work
1670
1671"#;
1672
1673 let warnings = lint(content);
1674 let md032_warnings: Vec<_> = warnings
1675 .iter()
1676 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1677 .collect();
1678 assert_eq!(
1679 md032_warnings.len(),
1680 0,
1681 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_actual_table_breaks_list() {
1687 let content = r#"# Test
1689
1690- Item before table
1691
1692| Col1 | Col2 |
1693|------|------|
1694| A | B |
1695
1696- Item after table
1697
1698"#;
1699
1700 let warnings = lint(content);
1701 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 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_thematic_break_not_lazy_continuation() {
1715 let content = r#"- Item 1
1718- Item 2
1719***
1720
1721More text.
1722"#;
1723
1724 let warnings = lint(content);
1725 let md032_warnings: Vec<_> = warnings
1726 .iter()
1727 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1728 .collect();
1729 assert_eq!(
1730 md032_warnings.len(),
1731 1,
1732 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1733 );
1734 assert!(
1735 md032_warnings[0].message.contains("followed by blank line"),
1736 "Warning should be about missing blank after list"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_thematic_break_with_blank_line() {
1742 let content = r#"- Item 1
1744- Item 2
1745
1746***
1747
1748More text.
1749"#;
1750
1751 let warnings = lint(content);
1752 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 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1760 );
1761 }
1762
1763 #[test]
1764 fn test_various_thematic_break_styles() {
1765 for hr in ["---", "***", "___"] {
1770 let content = format!(
1771 r#"- Item 1
1772- Item 2
1773{hr}
1774
1775More text.
1776"#
1777 );
1778
1779 let warnings = lint(&content);
1780 let md032_warnings: Vec<_> = warnings
1781 .iter()
1782 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1783 .collect();
1784 assert_eq!(
1785 md032_warnings.len(),
1786 1,
1787 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1788 );
1789 }
1790 }
1791
1792 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1795 let rule = MD032BlanksAroundLists::from_config_struct(config);
1796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797 rule.check(&ctx).expect("Lint check failed")
1798 }
1799
1800 fn fix_with_config(content: &str, config: MD032Config) -> String {
1801 let rule = MD032BlanksAroundLists::from_config_struct(config);
1802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1803 rule.fix(&ctx).expect("Lint fix failed")
1804 }
1805
1806 #[test]
1807 fn test_lazy_continuation_allowed_by_default() {
1808 let content = "# Heading\n\n1. List\nSome text.";
1810 let warnings = lint(content);
1811 assert_eq!(
1812 warnings.len(),
1813 0,
1814 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1815 );
1816 }
1817
1818 #[test]
1819 fn test_lazy_continuation_disallowed() {
1820 let content = "# Heading\n\n1. List\nSome text.";
1822 let config = MD032Config {
1823 allow_lazy_continuation: false,
1824 };
1825 let warnings = lint_with_config(content, config);
1826 assert_eq!(
1827 warnings.len(),
1828 1,
1829 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1830 );
1831 assert!(
1832 warnings[0].message.contains("followed by blank line"),
1833 "Warning message should mention blank line"
1834 );
1835 }
1836
1837 #[test]
1838 fn test_lazy_continuation_fix() {
1839 let content = "# Heading\n\n1. List\nSome text.";
1841 let config = MD032Config {
1842 allow_lazy_continuation: false,
1843 };
1844 let fixed = fix_with_config(content, config.clone());
1845 assert_eq!(
1846 fixed, "# Heading\n\n1. List\n\nSome text.",
1847 "Fix should insert blank line before lazy continuation"
1848 );
1849
1850 let warnings_after = lint_with_config(&fixed, config);
1852 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1853 }
1854
1855 #[test]
1856 fn test_lazy_continuation_multiple_lines() {
1857 let content = "- Item 1\nLine 2\nLine 3";
1859 let config = MD032Config {
1860 allow_lazy_continuation: false,
1861 };
1862 let warnings = lint_with_config(content, config.clone());
1863 assert_eq!(
1864 warnings.len(),
1865 1,
1866 "Should warn for lazy continuation. Got: {warnings:?}"
1867 );
1868
1869 let fixed = fix_with_config(content, config.clone());
1870 assert_eq!(
1871 fixed, "- Item 1\n\nLine 2\nLine 3",
1872 "Fix should insert blank line after list"
1873 );
1874
1875 let warnings_after = lint_with_config(&fixed, config);
1877 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1878 }
1879
1880 #[test]
1881 fn test_lazy_continuation_with_indented_content() {
1882 let content = "- Item 1\n Indented content\nLazy text";
1884 let config = MD032Config {
1885 allow_lazy_continuation: false,
1886 };
1887 let warnings = lint_with_config(content, config);
1888 assert_eq!(
1889 warnings.len(),
1890 1,
1891 "Should warn for lazy text after indented content. Got: {warnings:?}"
1892 );
1893 }
1894
1895 #[test]
1896 fn test_lazy_continuation_properly_separated() {
1897 let content = "- Item 1\n\nSome text.";
1899 let config = MD032Config {
1900 allow_lazy_continuation: false,
1901 };
1902 let warnings = lint_with_config(content, config);
1903 assert_eq!(
1904 warnings.len(),
1905 0,
1906 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1907 );
1908 }
1909
1910 #[test]
1913 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1914 let content = "1) First item\nLazy continuation";
1916 let config = MD032Config {
1917 allow_lazy_continuation: false,
1918 };
1919 let warnings = lint_with_config(content, config.clone());
1920 assert_eq!(
1921 warnings.len(),
1922 1,
1923 "Should warn for lazy continuation with parenthesis marker"
1924 );
1925
1926 let fixed = fix_with_config(content, config);
1927 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1928 }
1929
1930 #[test]
1931 fn test_lazy_continuation_followed_by_another_list() {
1932 let content = "- Item 1\nSome text\n- Item 2";
1938 let config = MD032Config {
1939 allow_lazy_continuation: false,
1940 };
1941 let warnings = lint_with_config(content, config);
1942 assert_eq!(
1944 warnings.len(),
1945 1,
1946 "Should warn about lazy continuation within list. Got: {warnings:?}"
1947 );
1948 assert!(
1949 warnings[0].message.contains("Lazy continuation"),
1950 "Warning should be about lazy continuation"
1951 );
1952 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1953 }
1954
1955 #[test]
1956 fn test_lazy_continuation_multiple_in_document() {
1957 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1962 let config = MD032Config {
1963 allow_lazy_continuation: false,
1964 };
1965 let warnings = lint_with_config(content, config.clone());
1966 assert_eq!(
1970 warnings.len(),
1971 2,
1972 "Should warn for both lazy continuations and list end. Got: {warnings:?}"
1973 );
1974
1975 let fixed = fix_with_config(content, config.clone());
1976 let warnings_after = lint_with_config(&fixed, config);
1977 assert_eq!(
1980 warnings_after.len(),
1981 1,
1982 "Within-list lazy continuation warning should remain (no auto-fix)"
1983 );
1984 }
1985
1986 #[test]
1987 fn test_lazy_continuation_end_of_document_no_newline() {
1988 let content = "- Item\nNo trailing newline";
1990 let config = MD032Config {
1991 allow_lazy_continuation: false,
1992 };
1993 let warnings = lint_with_config(content, config.clone());
1994 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1995
1996 let fixed = fix_with_config(content, config);
1997 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1998 }
1999
2000 #[test]
2001 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2002 let content = "- Item 1\n---";
2005 let config = MD032Config {
2006 allow_lazy_continuation: false,
2007 };
2008 let warnings = lint_with_config(content, config.clone());
2009 assert_eq!(
2011 warnings.len(),
2012 1,
2013 "List should need blank line before thematic break. Got: {warnings:?}"
2014 );
2015
2016 let fixed = fix_with_config(content, config);
2018 assert_eq!(fixed, "- Item 1\n\n---");
2019 }
2020
2021 #[test]
2022 fn test_lazy_continuation_heading_not_flagged() {
2023 let content = "- Item 1\n# Heading";
2026 let config = MD032Config {
2027 allow_lazy_continuation: false,
2028 };
2029 let warnings = lint_with_config(content, config);
2030 assert!(
2033 warnings.iter().all(|w| !w.message.contains("lazy")),
2034 "Heading should not trigger lazy continuation warning"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_lazy_continuation_mixed_list_types() {
2040 let content = "- Unordered\n1. Ordered\nLazy text";
2042 let config = MD032Config {
2043 allow_lazy_continuation: false,
2044 };
2045 let warnings = lint_with_config(content, config.clone());
2046 assert!(!warnings.is_empty(), "Should warn about structure issues");
2047 }
2048
2049 #[test]
2050 fn test_lazy_continuation_deep_nesting() {
2051 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2053 let config = MD032Config {
2054 allow_lazy_continuation: false,
2055 };
2056 let warnings = lint_with_config(content, config.clone());
2057 assert!(
2058 !warnings.is_empty(),
2059 "Should warn about lazy continuation after nested list"
2060 );
2061
2062 let fixed = fix_with_config(content, config.clone());
2063 let warnings_after = lint_with_config(&fixed, config);
2064 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2065 }
2066
2067 #[test]
2068 fn test_lazy_continuation_with_emphasis_in_text() {
2069 let content = "- Item\n*emphasized* continuation";
2071 let config = MD032Config {
2072 allow_lazy_continuation: false,
2073 };
2074 let warnings = lint_with_config(content, config.clone());
2075 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2076
2077 let fixed = fix_with_config(content, config);
2078 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
2079 }
2080
2081 #[test]
2082 fn test_lazy_continuation_with_code_span() {
2083 let content = "- Item\n`code` continuation";
2085 let config = MD032Config {
2086 allow_lazy_continuation: false,
2087 };
2088 let warnings = lint_with_config(content, config.clone());
2089 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2090
2091 let fixed = fix_with_config(content, config);
2092 assert_eq!(fixed, "- Item\n\n`code` continuation");
2093 }
2094
2095 #[test]
2102 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2103 let content = r#"1. Create a new Chat conversation:
2106 - On the sidebar, select **New Chat**.
2107 - In the box, type `/new`.
2108 A new Chat conversation replaces the previous one.
21091. Under the Chat text box, turn off the toggle."#;
2110 let config = MD032Config {
2111 allow_lazy_continuation: false,
2112 };
2113 let warnings = lint_with_config(content, config);
2114 let lazy_warnings: Vec<_> = warnings
2116 .iter()
2117 .filter(|w| w.message.contains("Lazy continuation"))
2118 .collect();
2119 assert!(
2120 !lazy_warnings.is_empty(),
2121 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2122 );
2123 assert!(
2124 lazy_warnings.iter().any(|w| w.line == 4),
2125 "Should warn on line 4. Got: {lazy_warnings:?}"
2126 );
2127 }
2128
2129 #[test]
2130 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2131 let content = r#"- `field`: Is the specific key:
2134 - `password`: Accesses the password.
2135 - `api_key`: Accesses the api_key.
2136 `token`: Specifies which ID token to use.
2137- `version_id`: Is the unique identifier."#;
2138 let config = MD032Config {
2139 allow_lazy_continuation: false,
2140 };
2141 let warnings = lint_with_config(content, config);
2142 let lazy_warnings: Vec<_> = warnings
2144 .iter()
2145 .filter(|w| w.message.contains("Lazy continuation"))
2146 .collect();
2147 assert!(
2148 !lazy_warnings.is_empty(),
2149 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2150 );
2151 assert!(
2152 lazy_warnings.iter().any(|w| w.line == 4),
2153 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2154 );
2155 }
2156
2157 #[test]
2158 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2159 let content = r#"- Check out the branch, and test locally.
2161 - If the MR requires significant modifications:
2162 - **Skip local testing** and review instead.
2163 - **Request verification** from the author.
2164 - **Identify the minimal change** needed.
2165 Your testing might result in opportunities.
2166- If you don't understand, _say so_."#;
2167 let config = MD032Config {
2168 allow_lazy_continuation: false,
2169 };
2170 let warnings = lint_with_config(content, config);
2171 let lazy_warnings: Vec<_> = warnings
2173 .iter()
2174 .filter(|w| w.message.contains("Lazy continuation"))
2175 .collect();
2176 assert!(
2177 !lazy_warnings.is_empty(),
2178 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2179 );
2180 assert!(
2181 lazy_warnings.iter().any(|w| w.line == 6),
2182 "Should warn on line 6. Got: {lazy_warnings:?}"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_issue295_ordered_list_nested_bullets_continuation() {
2188 let content = r#"# Test
2191
21921. First item.
2193 - Nested A.
2194 - Nested B.
2195 Continuation at outer level.
21961. Second item."#;
2197 let config = MD032Config {
2198 allow_lazy_continuation: false,
2199 };
2200 let warnings = lint_with_config(content, config);
2201 let lazy_warnings: Vec<_> = warnings
2203 .iter()
2204 .filter(|w| w.message.contains("Lazy continuation"))
2205 .collect();
2206 assert!(
2207 !lazy_warnings.is_empty(),
2208 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2209 );
2210 assert!(
2212 lazy_warnings.iter().any(|w| w.line == 6),
2213 "Should warn on line 6. Got: {lazy_warnings:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_issue295_multiple_lazy_lines_after_nested() {
2219 let content = r#"1. The device client receives a response.
2221 - Those defined by OAuth Framework.
2222 - Those specific to device authorization.
2223 Those error responses are described below.
2224 For more information on each response,
2225 see the documentation.
22261. Next step in the process."#;
2227 let config = MD032Config {
2228 allow_lazy_continuation: false,
2229 };
2230 let warnings = lint_with_config(content, config);
2231 let lazy_warnings: Vec<_> = warnings
2233 .iter()
2234 .filter(|w| w.message.contains("Lazy continuation"))
2235 .collect();
2236 assert!(
2237 lazy_warnings.len() >= 3,
2238 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2239 lazy_warnings.len()
2240 );
2241 }
2242
2243 #[test]
2244 fn test_issue295_properly_indented_not_lazy() {
2245 let content = r#"1. First item.
2247 - Nested A.
2248 - Nested B.
2249
2250 Properly indented continuation.
22511. Second item."#;
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_eq!(
2262 lazy_warnings.len(),
2263 0,
2264 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2265 );
2266 }
2267
2268 #[test]
2275 fn test_html_comment_before_list_with_preceding_blank() {
2276 let content = "Some text.\n\n<!-- comment -->\n- List item";
2279 let warnings = lint(content);
2280 assert_eq!(
2281 warnings.len(),
2282 0,
2283 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_html_comment_after_list_with_following_blank() {
2289 let content = "- List item\n<!-- comment -->\n\nSome text.";
2291 let warnings = lint(content);
2292 assert_eq!(
2293 warnings.len(),
2294 0,
2295 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2296 );
2297 }
2298
2299 #[test]
2300 fn test_list_inside_html_comment_ignored() {
2301 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2303 let warnings = lint(content);
2304 assert_eq!(
2305 warnings.len(),
2306 0,
2307 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_multiline_html_comment_before_list() {
2313 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2315 let warnings = lint(content);
2316 assert_eq!(
2317 warnings.len(),
2318 0,
2319 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2320 );
2321 }
2322
2323 #[test]
2324 fn test_no_blank_before_html_comment_still_warns() {
2325 let content = "Some text.\n<!-- comment -->\n- List item";
2327 let warnings = lint(content);
2328 assert_eq!(
2329 warnings.len(),
2330 1,
2331 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2332 );
2333 assert!(
2334 warnings[0].message.contains("preceded by blank line"),
2335 "Should be 'preceded by blank line' warning"
2336 );
2337 }
2338
2339 #[test]
2340 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2341 let content = "- List item\n<!-- comment -->\nSome text.";
2344 let warnings = lint(content);
2345 assert_eq!(
2346 warnings.len(),
2347 0,
2348 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2349 );
2350 }
2351
2352 #[test]
2353 fn test_list_followed_by_heading_through_comment_should_warn() {
2354 let content = "- List item\n<!-- comment -->\n# Heading";
2356 let warnings = lint(content);
2357 assert!(
2360 warnings.len() <= 1,
2361 "Should handle heading after comment gracefully. Got: {warnings:?}"
2362 );
2363 }
2364
2365 #[test]
2366 fn test_html_comment_between_list_and_text_both_directions() {
2367 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2369 let warnings = lint(content);
2370 assert_eq!(
2371 warnings.len(),
2372 0,
2373 "Should not warn with proper separation through comments. Got: {warnings:?}"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2379 let content = "Text.\n\n<!-- comment -->\n- Item";
2381 let fixed = fix(content);
2382 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2383 }
2384
2385 #[test]
2386 fn test_html_comment_fix_adds_blank_when_needed() {
2387 let content = "Text.\n<!-- comment -->\n- Item";
2390 let fixed = fix(content);
2391 assert!(
2392 fixed.contains("<!-- comment -->\n\n- Item"),
2393 "Fix should add blank line before list. Got: {fixed}"
2394 );
2395 }
2396
2397 #[test]
2398 fn test_ordered_list_inside_html_comment() {
2399 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2401 let warnings = lint(content);
2402 assert_eq!(
2403 warnings.len(),
2404 0,
2405 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2406 );
2407 }
2408
2409 #[test]
2416 fn test_blockquote_list_exit_no_warning() {
2417 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2419 let warnings = lint(content);
2420 assert_eq!(
2421 warnings.len(),
2422 0,
2423 "Should not warn when exiting blockquote. Got: {warnings:?}"
2424 );
2425 }
2426
2427 #[test]
2428 fn test_nested_blockquote_list_exit() {
2429 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2431 let warnings = lint(content);
2432 assert_eq!(
2433 warnings.len(),
2434 0,
2435 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2436 );
2437 }
2438
2439 #[test]
2440 fn test_blockquote_same_level_no_warning() {
2441 let content = "> - item 1\n> - item 2\n> Text after";
2444 let warnings = lint(content);
2445 assert_eq!(
2446 warnings.len(),
2447 0,
2448 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_blockquote_list_with_special_chars() {
2454 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2456 let warnings = lint(content);
2457 assert_eq!(
2458 warnings.len(),
2459 0,
2460 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_lazy_continuation_whitespace_only_line() {
2466 let content = "- Item\n \nText after whitespace-only line";
2469 let config = MD032Config {
2470 allow_lazy_continuation: false,
2471 };
2472 let warnings = lint_with_config(content, config.clone());
2473 assert_eq!(
2475 warnings.len(),
2476 1,
2477 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
2478 );
2479
2480 let fixed = fix_with_config(content, config);
2482 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
2483 }
2484
2485 #[test]
2486 fn test_lazy_continuation_blockquote_context() {
2487 let content = "> - Item\n> Lazy in quote";
2489 let config = MD032Config {
2490 allow_lazy_continuation: false,
2491 };
2492 let warnings = lint_with_config(content, config);
2493 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2496 }
2497
2498 #[test]
2499 fn test_lazy_continuation_fix_preserves_content() {
2500 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2502 let config = MD032Config {
2503 allow_lazy_continuation: false,
2504 };
2505 let fixed = fix_with_config(content, config);
2506 assert!(fixed.contains("<>&"), "Should preserve special chars");
2507 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2508 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
2509 }
2510
2511 #[test]
2512 fn test_lazy_continuation_fix_idempotent() {
2513 let content = "- Item\nLazy";
2515 let config = MD032Config {
2516 allow_lazy_continuation: false,
2517 };
2518 let fixed_once = fix_with_config(content, config.clone());
2519 let fixed_twice = fix_with_config(&fixed_once, config);
2520 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2521 }
2522
2523 #[test]
2524 fn test_lazy_continuation_config_default_allows() {
2525 let content = "- Item\nLazy text that continues";
2527 let default_config = MD032Config::default();
2528 assert!(
2529 default_config.allow_lazy_continuation,
2530 "Default should allow lazy continuation"
2531 );
2532 let warnings = lint_with_config(content, default_config);
2533 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2534 }
2535
2536 #[test]
2537 fn test_lazy_continuation_after_multi_line_item() {
2538 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2540 let config = MD032Config {
2541 allow_lazy_continuation: false,
2542 };
2543 let warnings = lint_with_config(content, config.clone());
2544 assert_eq!(
2545 warnings.len(),
2546 1,
2547 "Should warn only for the lazy line, not the indented line"
2548 );
2549 }
2550
2551 #[test]
2553 fn test_blockquote_list_with_continuation_and_nested() {
2554 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2557 let warnings = lint(content);
2558 assert_eq!(
2559 warnings.len(),
2560 0,
2561 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2562 );
2563 }
2564
2565 #[test]
2566 fn test_blockquote_list_simple() {
2567 let content = "> - item 1\n> - item 2";
2569 let warnings = lint(content);
2570 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2571 }
2572
2573 #[test]
2574 fn test_blockquote_list_with_continuation_only() {
2575 let content = "> - item 1\n> continuation\n> - item 2";
2577 let warnings = lint(content);
2578 assert_eq!(
2579 warnings.len(),
2580 0,
2581 "Blockquoted list with continuation should have no warnings"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_blockquote_list_with_lazy_continuation() {
2587 let content = "> - item 1\n> lazy continuation\n> - item 2";
2589 let warnings = lint(content);
2590 assert_eq!(
2591 warnings.len(),
2592 0,
2593 "Blockquoted list with lazy continuation should have no warnings"
2594 );
2595 }
2596
2597 #[test]
2598 fn test_nested_blockquote_list() {
2599 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2601 let warnings = lint(content);
2602 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2603 }
2604
2605 #[test]
2606 fn test_blockquote_list_needs_preceding_blank() {
2607 let content = "> Text before\n> - item 1\n> - item 2";
2609 let warnings = lint(content);
2610 assert_eq!(
2611 warnings.len(),
2612 1,
2613 "Should warn for missing blank before blockquoted list"
2614 );
2615 }
2616
2617 #[test]
2618 fn test_blockquote_list_properly_separated() {
2619 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2621 let warnings = lint(content);
2622 assert_eq!(
2623 warnings.len(),
2624 0,
2625 "Properly separated blockquoted list should have no warnings"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_blockquote_ordered_list() {
2631 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2633 let warnings = lint(content);
2634 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2635 }
2636
2637 #[test]
2638 fn test_blockquote_list_with_empty_blockquote_line() {
2639 let content = "> - item 1\n>\n> - item 2";
2641 let warnings = lint(content);
2642 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2643 }
2644
2645 #[test]
2647 fn test_blockquote_list_multi_paragraph_items() {
2648 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2651 let warnings = lint(content);
2652 assert_eq!(
2653 warnings.len(),
2654 0,
2655 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2656 );
2657 }
2658
2659 #[test]
2661 fn test_blockquote_ordered_list_multi_paragraph_items() {
2662 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2663 let warnings = lint(content);
2664 assert_eq!(
2665 warnings.len(),
2666 0,
2667 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2668 );
2669 }
2670
2671 #[test]
2673 fn test_blockquote_list_multiple_continuations() {
2674 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2675 let warnings = lint(content);
2676 assert_eq!(
2677 warnings.len(),
2678 0,
2679 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2680 );
2681 }
2682
2683 #[test]
2685 fn test_nested_blockquote_multi_paragraph_list() {
2686 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2687 let warnings = lint(content);
2688 assert_eq!(
2689 warnings.len(),
2690 0,
2691 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2692 );
2693 }
2694
2695 #[test]
2697 fn test_triple_nested_blockquote_multi_paragraph_list() {
2698 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2699 let warnings = lint(content);
2700 assert_eq!(
2701 warnings.len(),
2702 0,
2703 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2704 );
2705 }
2706
2707 #[test]
2709 fn test_blockquote_list_last_item_continuation() {
2710 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2711 let warnings = lint(content);
2712 assert_eq!(
2713 warnings.len(),
2714 0,
2715 "Last item with continuation should have no warnings. Got: {warnings:?}"
2716 );
2717 }
2718
2719 #[test]
2721 fn test_blockquote_list_first_item_only_continuation() {
2722 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2723 let warnings = lint(content);
2724 assert_eq!(
2725 warnings.len(),
2726 0,
2727 "Single item with continuation should have no warnings. Got: {warnings:?}"
2728 );
2729 }
2730
2731 #[test]
2735 fn test_blockquote_level_change_breaks_list() {
2736 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2738 let warnings = lint(content);
2739 assert!(
2743 warnings.len() <= 2,
2744 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2745 );
2746 }
2747
2748 #[test]
2750 fn test_exit_blockquote_needs_blank_before_list() {
2751 let content = "> Blockquote text\n\n- List outside blockquote\n";
2753 let warnings = lint(content);
2754 assert_eq!(
2755 warnings.len(),
2756 0,
2757 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2758 );
2759
2760 let content2 = "> Blockquote text\n- List outside blockquote\n";
2764 let warnings2 = lint(content2);
2765 assert!(
2767 warnings2.len() <= 1,
2768 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2769 );
2770 }
2771
2772 #[test]
2774 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2775 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2777 let warnings = lint(content_dash);
2778 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2779
2780 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2782 let warnings = lint(content_asterisk);
2783 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2784
2785 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2787 let warnings = lint(content_plus);
2788 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2789 }
2790
2791 #[test]
2793 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2794 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2795 let warnings = lint(content);
2796 assert_eq!(
2797 warnings.len(),
2798 0,
2799 "Parenthesis ordered markers should work. Got: {warnings:?}"
2800 );
2801 }
2802
2803 #[test]
2805 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2806 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2808 let warnings = lint(content);
2809 assert_eq!(
2810 warnings.len(),
2811 0,
2812 "Multi-digit ordered list should work. Got: {warnings:?}"
2813 );
2814 }
2815
2816 #[test]
2818 fn test_blockquote_multi_paragraph_with_formatting() {
2819 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2820 let warnings = lint(content);
2821 assert_eq!(
2822 warnings.len(),
2823 0,
2824 "Continuation with inline formatting should work. Got: {warnings:?}"
2825 );
2826 }
2827
2828 #[test]
2830 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2831 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2832 let warnings = lint(content);
2833 assert_eq!(
2834 warnings.len(),
2835 0,
2836 "All items with continuations should work. Got: {warnings:?}"
2837 );
2838 }
2839
2840 #[test]
2842 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2843 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2844 let warnings = lint(content);
2845 assert_eq!(
2846 warnings.len(),
2847 0,
2848 "Lowercase continuation should work. Got: {warnings:?}"
2849 );
2850 }
2851
2852 #[test]
2854 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2855 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2856 let warnings = lint(content);
2857 assert_eq!(
2858 warnings.len(),
2859 0,
2860 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2861 );
2862 }
2863
2864 #[test]
2866 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2867 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2869 let warnings = lint(content);
2870 assert!(
2872 warnings.len() <= 1,
2873 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2874 );
2875 }
2876
2877 #[test]
2879 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2880 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2882 let warnings = lint(content);
2883 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2884 }
2885
2886 #[test]
2887 fn test_blockquote_list_varying_spaces_after_marker() {
2888 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2890 let warnings = lint(content);
2891 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2892 }
2893
2894 #[test]
2895 fn test_deeply_nested_blockquote_list() {
2896 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2898 let warnings = lint(content);
2899 assert_eq!(
2900 warnings.len(),
2901 0,
2902 "Deeply nested blockquote list should have no warnings"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_blockquote_level_change_in_list() {
2908 let content = "> - item 1\n>> - deeper item\n> - item 2";
2910 let warnings = lint(content);
2913 assert!(
2914 !warnings.is_empty(),
2915 "Blockquote level change should break list and trigger warnings"
2916 );
2917 }
2918
2919 #[test]
2920 fn test_blockquote_list_with_code_span() {
2921 let content = "> - item with `code`\n> continuation\n> - item 2";
2923 let warnings = lint(content);
2924 assert_eq!(
2925 warnings.len(),
2926 0,
2927 "Blockquote list with code span should have no warnings"
2928 );
2929 }
2930
2931 #[test]
2932 fn test_blockquote_list_at_document_end() {
2933 let content = "> Some text\n>\n> - item 1\n> - item 2";
2935 let warnings = lint(content);
2936 assert_eq!(
2937 warnings.len(),
2938 0,
2939 "Blockquote list at document end should have no warnings"
2940 );
2941 }
2942
2943 #[test]
2944 fn test_fix_preserves_blockquote_prefix_before_list() {
2945 let content = "> Text before
2947> - Item 1
2948> - Item 2";
2949 let fixed = fix(content);
2950
2951 let expected = "> Text before
2953>
2954> - Item 1
2955> - Item 2";
2956 assert_eq!(
2957 fixed, expected,
2958 "Fix should insert '>' blank line, not plain blank line"
2959 );
2960 }
2961
2962 #[test]
2963 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2964 let content = ">>> Triple nested
2967>>> - Item 1
2968>>> - Item 2
2969>>> More text";
2970 let fixed = fix(content);
2971
2972 let expected = ">>> Triple nested
2974>>>
2975>>> - Item 1
2976>>> - Item 2
2977>>> More text";
2978 assert_eq!(
2979 fixed, expected,
2980 "Fix should preserve triple-nested blockquote prefix '>>>'"
2981 );
2982 }
2983
2984 fn lint_quarto(content: &str) -> Vec<LintWarning> {
2987 let rule = MD032BlanksAroundLists::default();
2988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2989 rule.check(&ctx).unwrap()
2990 }
2991
2992 #[test]
2993 fn test_quarto_list_after_div_open() {
2994 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
2996 let warnings = lint_quarto(content);
2997 assert!(
2999 warnings.is_empty(),
3000 "Quarto div marker should be transparent before list: {warnings:?}"
3001 );
3002 }
3003
3004 #[test]
3005 fn test_quarto_list_before_div_close() {
3006 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3008 let warnings = lint_quarto(content);
3009 assert!(
3011 warnings.is_empty(),
3012 "Quarto div marker should be transparent after list: {warnings:?}"
3013 );
3014 }
3015
3016 #[test]
3017 fn test_quarto_list_needs_blank_without_div() {
3018 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3020 let warnings = lint_quarto(content);
3021 assert!(
3024 !warnings.is_empty(),
3025 "Should still require blank when not present: {warnings:?}"
3026 );
3027 }
3028
3029 #[test]
3030 fn test_quarto_list_in_callout_with_content() {
3031 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3033 let warnings = lint_quarto(content);
3034 assert!(
3035 warnings.is_empty(),
3036 "List with proper blanks inside callout should pass: {warnings:?}"
3037 );
3038 }
3039
3040 #[test]
3041 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3042 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3044 let warnings = lint(content); assert!(
3047 !warnings.is_empty(),
3048 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3049 );
3050 }
3051
3052 #[test]
3053 fn test_quarto_nested_divs_with_list() {
3054 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3056 let warnings = lint_quarto(content);
3057 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3058 }
3059}