1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::quarto_divs;
4use crate::utils::range_utils::{LineIndex, calculate_line_range};
5use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
6use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
7use regex::Regex;
8use std::collections::HashSet;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub 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 ElementCache::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 detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
178 let mut lazy_lines = HashSet::new();
179 let parser = Parser::new_ext(ctx.content, Options::all());
180
181 let mut item_stack: Vec<usize> = vec![];
183 let mut after_soft_break = false;
184
185 for (event, range) in parser.into_offset_iter() {
186 match event {
187 Event::Start(Tag::Item) => {
188 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
190 let content_col = ctx
191 .lines
192 .get(line_num.saturating_sub(1))
193 .and_then(|li| li.list_item.as_ref())
194 .map(|item| item.content_column)
195 .unwrap_or(0);
196 item_stack.push(content_col);
197 after_soft_break = false;
198 }
199 Event::End(TagEnd::Item) => {
200 item_stack.pop();
201 after_soft_break = false;
202 }
203 Event::SoftBreak if !item_stack.is_empty() => {
204 after_soft_break = true;
205 }
206 Event::Text(_) | Event::Code(_) if after_soft_break => {
209 if let Some(&expected_col) = item_stack.last() {
210 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
211 let actual_indent = ctx
212 .lines
213 .get(line_num.saturating_sub(1))
214 .map(|li| li.indent)
215 .unwrap_or(0);
216
217 if actual_indent < expected_col {
219 lazy_lines.insert(line_num);
220 }
221 }
222 after_soft_break = false;
223 }
224 _ => {
225 after_soft_break = false;
226 }
227 }
228 }
229
230 lazy_lines
231 }
232
233 fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
235 match line_offsets.binary_search(&byte_offset) {
236 Ok(idx) => idx + 1,
237 Err(idx) => idx.max(1),
238 }
239 }
240
241 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
249 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
250 for line_num in (1..before_line).rev() {
251 let idx = line_num - 1;
252 if let Some(info) = ctx.lines.get(idx) {
253 if info.in_html_comment {
255 continue;
256 }
257 if is_quarto {
259 let trimmed = info.content(ctx.content).trim();
260 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
261 continue;
262 }
263 }
264 return (line_num, info.is_blank);
265 }
266 }
267 (0, true)
269 }
270
271 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
278 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
279 let num_lines = ctx.lines.len();
280 for line_num in (after_line + 1)..=num_lines {
281 let idx = line_num - 1;
282 if let Some(info) = ctx.lines.get(idx) {
283 if info.in_html_comment {
285 continue;
286 }
287 if is_quarto {
289 let trimmed = info.content(ctx.content).trim();
290 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
291 continue;
292 }
293 }
294 return (line_num, info.is_blank);
295 }
296 }
297 (0, true)
299 }
300
301 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
303 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
304
305 for block in &ctx.list_blocks {
306 let mut segments: Vec<(usize, usize)> = Vec::new();
312 let mut current_start = block.start_line;
313 let mut prev_item_line = 0;
314
315 let get_blockquote_level = |line_num: usize| -> usize {
317 if line_num == 0 || line_num > ctx.lines.len() {
318 return 0;
319 }
320 let line_content = ctx.lines[line_num - 1].content(ctx.content);
321 BLOCKQUOTE_PREFIX_RE
322 .find(line_content)
323 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
324 .unwrap_or(0)
325 };
326
327 let mut prev_bq_level = 0;
328
329 for &item_line in &block.item_lines {
330 let current_bq_level = get_blockquote_level(item_line);
331
332 if prev_item_line > 0 {
333 let blockquote_level_changed = prev_bq_level != current_bq_level;
335
336 let mut has_standalone_code_fence = false;
339
340 let min_indent_for_content = if block.is_ordered {
342 3 } else {
346 2 };
349
350 for check_line in (prev_item_line + 1)..item_line {
351 if check_line - 1 < ctx.lines.len() {
352 let line = &ctx.lines[check_line - 1];
353 let line_content = line.content(ctx.content);
354 if line.in_code_block
355 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
356 {
357 if line.indent < min_indent_for_content {
360 has_standalone_code_fence = true;
361 break;
362 }
363 }
364 }
365 }
366
367 if has_standalone_code_fence || blockquote_level_changed {
368 segments.push((current_start, prev_item_line));
370 current_start = item_line;
371 }
372 }
373 prev_item_line = item_line;
374 prev_bq_level = current_bq_level;
375 }
376
377 if prev_item_line > 0 {
380 segments.push((current_start, prev_item_line));
381 }
382
383 let has_code_fence_splits = segments.len() > 1 && {
385 let mut found_fence = false;
387 for i in 0..segments.len() - 1 {
388 let seg_end = segments[i].1;
389 let next_start = segments[i + 1].0;
390 for check_line in (seg_end + 1)..next_start {
392 if check_line - 1 < ctx.lines.len() {
393 let line = &ctx.lines[check_line - 1];
394 let line_content = line.content(ctx.content);
395 if line.in_code_block
396 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
397 {
398 found_fence = true;
399 break;
400 }
401 }
402 }
403 if found_fence {
404 break;
405 }
406 }
407 found_fence
408 };
409
410 for (start, end) in segments.iter() {
412 let mut actual_end = *end;
414
415 if !has_code_fence_splits && *end < block.end_line {
418 let min_continuation_indent = ctx
421 .lines
422 .get(*end - 1)
423 .and_then(|line_info| line_info.list_item.as_ref())
424 .map(|item| item.content_column)
425 .unwrap_or(2);
426
427 for check_line in (*end + 1)..=block.end_line {
428 if check_line - 1 < ctx.lines.len() {
429 let line = &ctx.lines[check_line - 1];
430 let line_content = line.content(ctx.content);
431 if block.item_lines.contains(&check_line) || line.heading.is_some() {
433 break;
434 }
435 if line.in_code_block {
437 break;
438 }
439 if line.indent >= min_continuation_indent {
441 actual_end = check_line;
442 }
443 else if self.config.allow_lazy_continuation
448 && !line.is_blank
449 && line.heading.is_none()
450 && !block.item_lines.contains(&check_line)
451 && !is_thematic_break(line_content)
452 {
453 actual_end = check_line;
456 } else if !line.is_blank {
457 break;
459 }
460 }
461 }
462 }
463
464 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
465 }
466 }
467
468 blocks.retain(|(start, end, _)| {
470 let all_in_comment =
472 (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
473 !all_in_comment
474 });
475
476 blocks
477 }
478
479 fn perform_checks(
480 &self,
481 ctx: &crate::lint_context::LintContext,
482 lines: &[&str],
483 list_blocks: &[(usize, usize, String)],
484 line_index: &LineIndex,
485 ) -> LintResult {
486 let mut warnings = Vec::new();
487 let num_lines = lines.len();
488
489 for (line_idx, line) in lines.iter().enumerate() {
492 let line_num = line_idx + 1;
493
494 let is_in_list = list_blocks
496 .iter()
497 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
498 if is_in_list {
499 continue;
500 }
501
502 if ctx
504 .line_info(line_num)
505 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
506 {
507 continue;
508 }
509
510 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
512 if line_idx > 0 {
514 let prev_line = lines[line_idx - 1];
515 let prev_is_blank = is_blank_in_context(prev_line);
516 let prev_excluded = ctx
517 .line_info(line_idx)
518 .is_some_and(|info| info.in_code_block || info.in_front_matter);
519
520 let prev_trimmed = prev_line.trim();
525 let is_sentence_continuation = !prev_is_blank
526 && !prev_trimmed.is_empty()
527 && !prev_trimmed.ends_with('.')
528 && !prev_trimmed.ends_with('!')
529 && !prev_trimmed.ends_with('?')
530 && !prev_trimmed.ends_with(':')
531 && !prev_trimmed.ends_with(';')
532 && !prev_trimmed.ends_with('>')
533 && !prev_trimmed.ends_with('-')
534 && !prev_trimmed.ends_with('*');
535
536 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
537 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
539
540 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
541 warnings.push(LintWarning {
542 line: start_line,
543 column: start_col,
544 end_line,
545 end_column: end_col,
546 severity: Severity::Warning,
547 rule_name: Some(self.name().to_string()),
548 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
549 fix: Some(Fix {
550 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
551 replacement: format!("{bq_prefix}\n"),
552 }),
553 });
554 }
555
556 if line_idx + 1 < num_lines {
559 let next_line = lines[line_idx + 1];
560 let next_is_blank = is_blank_in_context(next_line);
561 let next_excluded = ctx
562 .line_info(line_idx + 2)
563 .is_some_and(|info| info.in_code_block || info.in_front_matter);
564
565 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
566 let next_is_list_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
568 || next_line.trim_start().starts_with("- ")
569 || next_line.trim_start().starts_with("* ")
570 || next_line.trim_start().starts_with("+ ")
571 || next_line.starts_with("1. ")
572 || (next_line.len() > next_line.trim_start().len()); if !next_is_list_content {
575 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
576 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
577 warnings.push(LintWarning {
578 line: start_line,
579 column: start_col,
580 end_line,
581 end_column: end_col,
582 severity: Severity::Warning,
583 rule_name: Some(self.name().to_string()),
584 message: "List should be followed by blank line".to_string(),
585 fix: Some(Fix {
586 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
587 replacement: format!("{bq_prefix}\n"),
588 }),
589 });
590 }
591 }
592 }
593 }
594 }
595 }
596
597 for &(start_line, end_line, ref prefix) in list_blocks {
598 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
600 continue;
601 }
602
603 if start_line > 1 {
604 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
606
607 if !has_blank_separation && content_line > 0 {
609 let prev_line_str = lines[content_line - 1];
610 let is_prev_excluded = ctx
611 .line_info(content_line)
612 .is_some_and(|info| info.in_code_block || info.in_front_matter);
613 let prev_prefix = BLOCKQUOTE_PREFIX_RE
614 .find(prev_line_str)
615 .map_or(String::new(), |m| m.as_str().to_string());
616 let prefixes_match = prev_prefix.trim() == prefix.trim();
617
618 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
621 if !is_prev_excluded && prefixes_match && should_require {
622 let (start_line, start_col, end_line, end_col) =
624 calculate_line_range(start_line, lines[start_line - 1]);
625
626 warnings.push(LintWarning {
627 line: start_line,
628 column: start_col,
629 end_line,
630 end_column: end_col,
631 severity: Severity::Warning,
632 rule_name: Some(self.name().to_string()),
633 message: "List should be preceded by blank line".to_string(),
634 fix: Some(Fix {
635 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
636 replacement: format!("{prefix}\n"),
637 }),
638 });
639 }
640 }
641 }
642
643 if end_line < num_lines {
644 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
646
647 if !has_blank_separation && content_line > 0 {
649 let next_line_str = lines[content_line - 1];
650 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
653 || (content_line <= ctx.lines.len()
654 && ctx.lines[content_line - 1].in_code_block
655 && ctx.lines[content_line - 1].indent >= 2);
656 let next_prefix = BLOCKQUOTE_PREFIX_RE
657 .find(next_line_str)
658 .map_or(String::new(), |m| m.as_str().to_string());
659
660 let end_line_str = lines[end_line - 1];
665 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
666 .find(end_line_str)
667 .map_or(String::new(), |m| m.as_str().to_string());
668 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
669 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
670 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
671
672 let prefixes_match = next_prefix.trim() == prefix.trim();
673
674 if !is_next_excluded && prefixes_match && !exits_blockquote {
677 let (start_line_last, start_col_last, end_line_last, end_col_last) =
679 calculate_line_range(end_line, lines[end_line - 1]);
680
681 warnings.push(LintWarning {
682 line: start_line_last,
683 column: start_col_last,
684 end_line: end_line_last,
685 end_column: end_col_last,
686 severity: Severity::Warning,
687 rule_name: Some(self.name().to_string()),
688 message: "List should be followed by blank line".to_string(),
689 fix: Some(Fix {
690 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
691 replacement: format!("{prefix}\n"),
692 }),
693 });
694 }
695 }
696 }
697 }
698 Ok(warnings)
699 }
700}
701
702impl Rule for MD032BlanksAroundLists {
703 fn name(&self) -> &'static str {
704 "MD032"
705 }
706
707 fn description(&self) -> &'static str {
708 "Lists should be surrounded by blank lines"
709 }
710
711 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
712 let content = ctx.content;
713 let lines: Vec<&str> = content.lines().collect();
714 let line_index = &ctx.line_index;
715
716 if lines.is_empty() {
718 return Ok(Vec::new());
719 }
720
721 let list_blocks = self.convert_list_blocks(ctx);
722
723 if list_blocks.is_empty() {
724 return Ok(Vec::new());
725 }
726
727 let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
728
729 if !self.config.allow_lazy_continuation {
734 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
735
736 for line_num in lazy_lines {
737 let is_within_block = list_blocks
741 .iter()
742 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
743
744 if !is_within_block {
745 continue;
746 }
747
748 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
750 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
751
752 warnings.push(LintWarning {
756 line: start_line,
757 column: start_col,
758 end_line,
759 end_column: end_col,
760 severity: Severity::Warning,
761 rule_name: Some(self.name().to_string()),
762 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
763 fix: None,
764 });
765 }
766 }
767
768 Ok(warnings)
769 }
770
771 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
772 self.fix_with_structure_impl(ctx)
773 }
774
775 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
776 if ctx.content.is_empty() || !ctx.likely_has_lists() {
778 return true;
779 }
780 ctx.list_blocks.is_empty()
782 }
783
784 fn category(&self) -> RuleCategory {
785 RuleCategory::List
786 }
787
788 fn as_any(&self) -> &dyn std::any::Any {
789 self
790 }
791
792 fn default_config_section(&self) -> Option<(String, toml::Value)> {
793 use crate::rule_config_serde::RuleConfig;
794 let default_config = MD032Config::default();
795 let json_value = serde_json::to_value(&default_config).ok()?;
796 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
797
798 if let toml::Value::Table(table) = toml_value {
799 if !table.is_empty() {
800 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
801 } else {
802 None
803 }
804 } else {
805 None
806 }
807 }
808
809 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
810 where
811 Self: Sized,
812 {
813 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
814 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
815 }
816}
817
818impl MD032BlanksAroundLists {
819 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
821 let lines: Vec<&str> = ctx.content.lines().collect();
822 let num_lines = lines.len();
823 if num_lines == 0 {
824 return Ok(String::new());
825 }
826
827 let list_blocks = self.convert_list_blocks(ctx);
828 if list_blocks.is_empty() {
829 return Ok(ctx.content.to_string());
830 }
831
832 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
833
834 for &(start_line, end_line, ref prefix) in &list_blocks {
836 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
838 continue;
839 }
840
841 if start_line > 1 {
843 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
845
846 if !has_blank_separation && content_line > 0 {
848 let prev_line_str = lines[content_line - 1];
849 let is_prev_excluded = ctx
850 .line_info(content_line)
851 .is_some_and(|info| info.in_code_block || info.in_front_matter);
852 let prev_prefix = BLOCKQUOTE_PREFIX_RE
853 .find(prev_line_str)
854 .map_or(String::new(), |m| m.as_str().to_string());
855
856 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
857 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
859 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
861 insertions.insert(start_line, bq_prefix);
862 }
863 }
864 }
865
866 if end_line < num_lines {
868 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
870
871 if !has_blank_separation && content_line > 0 {
873 let next_line_str = lines[content_line - 1];
874 let is_next_excluded = ctx
876 .line_info(content_line)
877 .is_some_and(|info| info.in_code_block || info.in_front_matter)
878 || (content_line <= ctx.lines.len()
879 && ctx.lines[content_line - 1].in_code_block
880 && ctx.lines[content_line - 1].indent >= 2
881 && (ctx.lines[content_line - 1]
882 .content(ctx.content)
883 .trim()
884 .starts_with("```")
885 || ctx.lines[content_line - 1]
886 .content(ctx.content)
887 .trim()
888 .starts_with("~~~")));
889 let next_prefix = BLOCKQUOTE_PREFIX_RE
890 .find(next_line_str)
891 .map_or(String::new(), |m| m.as_str().to_string());
892
893 let end_line_str = lines[end_line - 1];
895 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
896 .find(end_line_str)
897 .map_or(String::new(), |m| m.as_str().to_string());
898 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
899 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
900 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
901
902 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
905 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
907 insertions.insert(end_line + 1, bq_prefix);
908 }
909 }
910 }
911 }
912
913 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
915 for (i, line) in lines.iter().enumerate() {
916 let current_line_num = i + 1;
917 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
918 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
919 {
920 result_lines.push(prefix_to_insert.clone());
921 }
922 result_lines.push(line.to_string());
923 }
924
925 let mut result = result_lines.join("\n");
927 if ctx.content.ends_with('\n') {
928 result.push('\n');
929 }
930 Ok(result)
931 }
932}
933
934fn is_blank_in_context(line: &str) -> bool {
936 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
939 line[m.end()..].trim().is_empty()
941 } else {
942 line.trim().is_empty()
944 }
945}
946
947#[cfg(test)]
948mod tests {
949 use super::*;
950 use crate::lint_context::LintContext;
951 use crate::rule::Rule;
952
953 fn lint(content: &str) -> Vec<LintWarning> {
954 let rule = MD032BlanksAroundLists::default();
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 rule.check(&ctx).expect("Lint check failed")
957 }
958
959 fn fix(content: &str) -> String {
960 let rule = MD032BlanksAroundLists::default();
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 rule.fix(&ctx).expect("Lint fix failed")
963 }
964
965 fn check_warnings_have_fixes(content: &str) {
967 let warnings = lint(content);
968 for warning in &warnings {
969 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
970 }
971 }
972
973 #[test]
974 fn test_list_at_start() {
975 let content = "- Item 1\n- Item 2\nText";
978 let warnings = lint(content);
979 assert_eq!(
980 warnings.len(),
981 0,
982 "Trailing text is lazy continuation per CommonMark - no warning expected"
983 );
984 }
985
986 #[test]
987 fn test_list_at_end() {
988 let content = "Text\n- Item 1\n- Item 2";
989 let warnings = lint(content);
990 assert_eq!(
991 warnings.len(),
992 1,
993 "Expected 1 warning for list at end without preceding blank line"
994 );
995 assert_eq!(
996 warnings[0].line, 2,
997 "Warning should be on the first line of the list (line 2)"
998 );
999 assert!(warnings[0].message.contains("preceded by blank line"));
1000
1001 check_warnings_have_fixes(content);
1003
1004 let fixed_content = fix(content);
1005 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1006
1007 let warnings_after_fix = lint(&fixed_content);
1009 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1010 }
1011
1012 #[test]
1013 fn test_list_in_middle() {
1014 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1017 let warnings = lint(content);
1018 assert_eq!(
1019 warnings.len(),
1020 1,
1021 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1022 );
1023 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1024 assert!(warnings[0].message.contains("preceded by blank line"));
1025
1026 check_warnings_have_fixes(content);
1028
1029 let fixed_content = fix(content);
1030 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1031
1032 let warnings_after_fix = lint(&fixed_content);
1034 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1035 }
1036
1037 #[test]
1038 fn test_correct_spacing() {
1039 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1040 let warnings = lint(content);
1041 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1042
1043 let fixed_content = fix(content);
1044 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1045 }
1046
1047 #[test]
1048 fn test_list_with_content() {
1049 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1052 let warnings = lint(content);
1053 assert_eq!(
1054 warnings.len(),
1055 1,
1056 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1057 );
1058 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1059 assert!(warnings[0].message.contains("preceded by blank line"));
1060
1061 check_warnings_have_fixes(content);
1063
1064 let fixed_content = fix(content);
1065 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1066 assert_eq!(
1067 fixed_content, expected_fixed,
1068 "Fix did not produce the expected output. Got:\n{fixed_content}"
1069 );
1070
1071 let warnings_after_fix = lint(&fixed_content);
1073 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1074 }
1075
1076 #[test]
1077 fn test_nested_list() {
1078 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1080 let warnings = lint(content);
1081 assert_eq!(
1082 warnings.len(),
1083 1,
1084 "Nested list block needs preceding blank only. Got: {warnings:?}"
1085 );
1086 assert_eq!(warnings[0].line, 2);
1087 assert!(warnings[0].message.contains("preceded by blank line"));
1088
1089 check_warnings_have_fixes(content);
1091
1092 let fixed_content = fix(content);
1093 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1094
1095 let warnings_after_fix = lint(&fixed_content);
1097 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1098 }
1099
1100 #[test]
1101 fn test_list_with_internal_blanks() {
1102 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1104 let warnings = lint(content);
1105 assert_eq!(
1106 warnings.len(),
1107 1,
1108 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1109 );
1110 assert_eq!(warnings[0].line, 2);
1111 assert!(warnings[0].message.contains("preceded by blank line"));
1112
1113 check_warnings_have_fixes(content);
1115
1116 let fixed_content = fix(content);
1117 assert_eq!(
1118 fixed_content,
1119 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1120 );
1121
1122 let warnings_after_fix = lint(&fixed_content);
1124 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1125 }
1126
1127 #[test]
1128 fn test_ignore_code_blocks() {
1129 let content = "```\n- Not a list item\n```\nText";
1130 let warnings = lint(content);
1131 assert_eq!(warnings.len(), 0);
1132 let fixed_content = fix(content);
1133 assert_eq!(fixed_content, content);
1134 }
1135
1136 #[test]
1137 fn test_ignore_front_matter() {
1138 let content = "---\ntitle: Test\n---\n- List Item\nText";
1140 let warnings = lint(content);
1141 assert_eq!(
1142 warnings.len(),
1143 0,
1144 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1145 );
1146
1147 let fixed_content = fix(content);
1149 assert_eq!(fixed_content, content, "No changes when no warnings");
1150 }
1151
1152 #[test]
1153 fn test_multiple_lists() {
1154 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1159 let warnings = lint(content);
1160 assert!(
1162 !warnings.is_empty(),
1163 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1164 );
1165
1166 check_warnings_have_fixes(content);
1168
1169 let fixed_content = fix(content);
1170 let warnings_after_fix = lint(&fixed_content);
1172 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1173 }
1174
1175 #[test]
1176 fn test_adjacent_lists() {
1177 let content = "- List 1\n\n* List 2";
1178 let warnings = lint(content);
1179 assert_eq!(warnings.len(), 0);
1180 let fixed_content = fix(content);
1181 assert_eq!(fixed_content, content);
1182 }
1183
1184 #[test]
1185 fn test_list_in_blockquote() {
1186 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1188 let warnings = lint(content);
1189 assert_eq!(
1190 warnings.len(),
1191 1,
1192 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1193 );
1194 assert_eq!(warnings[0].line, 2);
1195
1196 check_warnings_have_fixes(content);
1198
1199 let fixed_content = fix(content);
1200 assert_eq!(
1202 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1203 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1204 );
1205
1206 let warnings_after_fix = lint(&fixed_content);
1208 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1209 }
1210
1211 #[test]
1212 fn test_ordered_list() {
1213 let content = "Text\n1. Item 1\n2. Item 2\nText";
1215 let warnings = lint(content);
1216 assert_eq!(warnings.len(), 1);
1217
1218 check_warnings_have_fixes(content);
1220
1221 let fixed_content = fix(content);
1222 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1223
1224 let warnings_after_fix = lint(&fixed_content);
1226 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1227 }
1228
1229 #[test]
1230 fn test_no_double_blank_fix() {
1231 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1234 assert_eq!(
1235 warnings.len(),
1236 0,
1237 "Should have no warnings - properly preceded, trailing is lazy"
1238 );
1239
1240 let fixed_content = fix(content);
1241 assert_eq!(
1242 fixed_content, content,
1243 "No fix needed when no warnings. Got:\n{fixed_content}"
1244 );
1245
1246 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1248 assert_eq!(warnings2.len(), 1);
1249 if !warnings2.is_empty() {
1250 assert_eq!(
1251 warnings2[0].line, 2,
1252 "Warning line for missing blank before should be the first line of the block"
1253 );
1254 }
1255
1256 check_warnings_have_fixes(content2);
1258
1259 let fixed_content2 = fix(content2);
1260 assert_eq!(
1261 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1262 "Fix added extra blank before. Got:\n{fixed_content2}"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_empty_input() {
1268 let content = "";
1269 let warnings = lint(content);
1270 assert_eq!(warnings.len(), 0);
1271 let fixed_content = fix(content);
1272 assert_eq!(fixed_content, "");
1273 }
1274
1275 #[test]
1276 fn test_only_list() {
1277 let content = "- Item 1\n- Item 2";
1278 let warnings = lint(content);
1279 assert_eq!(warnings.len(), 0);
1280 let fixed_content = fix(content);
1281 assert_eq!(fixed_content, content);
1282 }
1283
1284 #[test]
1287 fn test_fix_complex_nested_blockquote() {
1288 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1290 let warnings = lint(content);
1291 assert_eq!(
1292 warnings.len(),
1293 1,
1294 "Should warn for missing preceding blank only. Got: {warnings:?}"
1295 );
1296
1297 check_warnings_have_fixes(content);
1299
1300 let fixed_content = fix(content);
1301 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1303 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1304
1305 let warnings_after_fix = lint(&fixed_content);
1306 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1307 }
1308
1309 #[test]
1310 fn test_fix_mixed_list_markers() {
1311 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1314 let warnings = lint(content);
1315 assert!(
1317 !warnings.is_empty(),
1318 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1319 );
1320
1321 check_warnings_have_fixes(content);
1323
1324 let fixed_content = fix(content);
1325 assert!(
1327 fixed_content.contains("Text\n\n-"),
1328 "Fix should add blank line before first list item"
1329 );
1330
1331 let warnings_after_fix = lint(&fixed_content);
1333 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1334 }
1335
1336 #[test]
1337 fn test_fix_ordered_list_with_different_numbers() {
1338 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1340 let warnings = lint(content);
1341 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1342
1343 check_warnings_have_fixes(content);
1345
1346 let fixed_content = fix(content);
1347 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1348 assert_eq!(
1349 fixed_content, expected,
1350 "Fix should handle ordered lists with non-sequential numbers"
1351 );
1352
1353 let warnings_after_fix = lint(&fixed_content);
1355 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1356 }
1357
1358 #[test]
1359 fn test_fix_list_with_code_blocks_inside() {
1360 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1362 let warnings = lint(content);
1363 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1364
1365 check_warnings_have_fixes(content);
1367
1368 let fixed_content = fix(content);
1369 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1370 assert_eq!(
1371 fixed_content, expected,
1372 "Fix should handle lists with internal code blocks"
1373 );
1374
1375 let warnings_after_fix = lint(&fixed_content);
1377 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1378 }
1379
1380 #[test]
1381 fn test_fix_deeply_nested_lists() {
1382 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1384 let warnings = lint(content);
1385 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1386
1387 check_warnings_have_fixes(content);
1389
1390 let fixed_content = fix(content);
1391 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1392 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1393
1394 let warnings_after_fix = lint(&fixed_content);
1396 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1397 }
1398
1399 #[test]
1400 fn test_fix_list_with_multiline_items() {
1401 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1404 let warnings = lint(content);
1405 assert_eq!(
1406 warnings.len(),
1407 1,
1408 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1409 );
1410
1411 check_warnings_have_fixes(content);
1413
1414 let fixed_content = fix(content);
1415 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1416 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1417
1418 let warnings_after_fix = lint(&fixed_content);
1420 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1421 }
1422
1423 #[test]
1424 fn test_fix_list_at_document_boundaries() {
1425 let content1 = "- Item 1\n- Item 2";
1427 let warnings1 = lint(content1);
1428 assert_eq!(
1429 warnings1.len(),
1430 0,
1431 "List at document start should not need blank before"
1432 );
1433 let fixed1 = fix(content1);
1434 assert_eq!(fixed1, content1, "No fix needed for list at start");
1435
1436 let content2 = "Text\n- Item 1\n- Item 2";
1438 let warnings2 = lint(content2);
1439 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1440 check_warnings_have_fixes(content2);
1441 let fixed2 = fix(content2);
1442 assert_eq!(
1443 fixed2, "Text\n\n- Item 1\n- Item 2",
1444 "Should add blank before list at end"
1445 );
1446 }
1447
1448 #[test]
1449 fn test_fix_preserves_existing_blank_lines() {
1450 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1451 let warnings = lint(content);
1452 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1453 let fixed_content = fix(content);
1454 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1455 }
1456
1457 #[test]
1458 fn test_fix_handles_tabs_and_spaces() {
1459 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1462 let warnings = lint(content);
1463 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1465
1466 check_warnings_have_fixes(content);
1468
1469 let fixed_content = fix(content);
1470 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1473 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1474
1475 let warnings_after_fix = lint(&fixed_content);
1477 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1478 }
1479
1480 #[test]
1481 fn test_fix_warning_objects_have_correct_ranges() {
1482 let content = "Text\n- Item 1\n- Item 2\nText";
1484 let warnings = lint(content);
1485 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1486
1487 for warning in &warnings {
1489 assert!(warning.fix.is_some(), "Warning should have fix");
1490 let fix = warning.fix.as_ref().unwrap();
1491 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1492 assert!(
1493 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1494 "Fix should have replacement or be insertion"
1495 );
1496 }
1497 }
1498
1499 #[test]
1500 fn test_fix_idempotent() {
1501 let content = "Text\n- Item 1\n- Item 2\nText";
1503
1504 let fixed_once = fix(content);
1506 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1507
1508 let fixed_twice = fix(&fixed_once);
1510 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1511
1512 let warnings_after_fix = lint(&fixed_once);
1514 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1515 }
1516
1517 #[test]
1518 fn test_fix_with_normalized_line_endings() {
1519 let content = "Text\n- Item 1\n- Item 2\nText";
1523 let warnings = lint(content);
1524 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1525
1526 check_warnings_have_fixes(content);
1528
1529 let fixed_content = fix(content);
1530 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1532 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1533 }
1534
1535 #[test]
1536 fn test_fix_preserves_final_newline() {
1537 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1540 let fixed_with_newline = fix(content_with_newline);
1541 assert!(
1542 fixed_with_newline.ends_with('\n'),
1543 "Fix should preserve final newline when present"
1544 );
1545 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1547
1548 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1550 let fixed_without_newline = fix(content_without_newline);
1551 assert!(
1552 !fixed_without_newline.ends_with('\n'),
1553 "Fix should not add final newline when not present"
1554 );
1555 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1557 }
1558
1559 #[test]
1560 fn test_fix_multiline_list_items_no_indent() {
1561 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";
1562
1563 let warnings = lint(content);
1564 assert_eq!(
1566 warnings.len(),
1567 0,
1568 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1569 );
1570
1571 let fixed_content = fix(content);
1572 assert_eq!(
1574 fixed_content, content,
1575 "Should not modify correctly formatted multi-line list items"
1576 );
1577 }
1578
1579 #[test]
1580 fn test_nested_list_with_lazy_continuation() {
1581 let content = r#"# Test
1587
1588- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1589 1. Switch/case dispatcher statements (original Phase 3.2)
1590 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1591`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1592 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1593 references"#;
1594
1595 let warnings = lint(content);
1596 let md032_warnings: Vec<_> = warnings
1599 .iter()
1600 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1601 .collect();
1602 assert_eq!(
1603 md032_warnings.len(),
1604 0,
1605 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_pipes_in_code_spans_not_detected_as_table() {
1611 let content = r#"# Test
1613
1614- Item with `a | b` inline code
1615 - Nested item should work
1616
1617"#;
1618
1619 let warnings = lint(content);
1620 let md032_warnings: Vec<_> = warnings
1621 .iter()
1622 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1623 .collect();
1624 assert_eq!(
1625 md032_warnings.len(),
1626 0,
1627 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1628 );
1629 }
1630
1631 #[test]
1632 fn test_multiple_code_spans_with_pipes() {
1633 let content = r#"# Test
1635
1636- Item with `a | b` and `c || d` operators
1637 - Nested item should work
1638
1639"#;
1640
1641 let warnings = lint(content);
1642 let md032_warnings: Vec<_> = warnings
1643 .iter()
1644 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1645 .collect();
1646 assert_eq!(
1647 md032_warnings.len(),
1648 0,
1649 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1650 );
1651 }
1652
1653 #[test]
1654 fn test_actual_table_breaks_list() {
1655 let content = r#"# Test
1657
1658- Item before table
1659
1660| Col1 | Col2 |
1661|------|------|
1662| A | B |
1663
1664- Item after table
1665
1666"#;
1667
1668 let warnings = lint(content);
1669 let md032_warnings: Vec<_> = warnings
1671 .iter()
1672 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1673 .collect();
1674 assert_eq!(
1675 md032_warnings.len(),
1676 0,
1677 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_thematic_break_not_lazy_continuation() {
1683 let content = r#"- Item 1
1686- Item 2
1687***
1688
1689More text.
1690"#;
1691
1692 let warnings = lint(content);
1693 let md032_warnings: Vec<_> = warnings
1694 .iter()
1695 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1696 .collect();
1697 assert_eq!(
1698 md032_warnings.len(),
1699 1,
1700 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1701 );
1702 assert!(
1703 md032_warnings[0].message.contains("followed by blank line"),
1704 "Warning should be about missing blank after list"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_thematic_break_with_blank_line() {
1710 let content = r#"- Item 1
1712- Item 2
1713
1714***
1715
1716More text.
1717"#;
1718
1719 let warnings = lint(content);
1720 let md032_warnings: Vec<_> = warnings
1721 .iter()
1722 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1723 .collect();
1724 assert_eq!(
1725 md032_warnings.len(),
1726 0,
1727 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_various_thematic_break_styles() {
1733 for hr in ["---", "***", "___"] {
1738 let content = format!(
1739 r#"- Item 1
1740- Item 2
1741{hr}
1742
1743More text.
1744"#
1745 );
1746
1747 let warnings = lint(&content);
1748 let md032_warnings: Vec<_> = warnings
1749 .iter()
1750 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1751 .collect();
1752 assert_eq!(
1753 md032_warnings.len(),
1754 1,
1755 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1756 );
1757 }
1758 }
1759
1760 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1763 let rule = MD032BlanksAroundLists::from_config_struct(config);
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 rule.check(&ctx).expect("Lint check failed")
1766 }
1767
1768 fn fix_with_config(content: &str, config: MD032Config) -> String {
1769 let rule = MD032BlanksAroundLists::from_config_struct(config);
1770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1771 rule.fix(&ctx).expect("Lint fix failed")
1772 }
1773
1774 #[test]
1775 fn test_lazy_continuation_allowed_by_default() {
1776 let content = "# Heading\n\n1. List\nSome text.";
1778 let warnings = lint(content);
1779 assert_eq!(
1780 warnings.len(),
1781 0,
1782 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1783 );
1784 }
1785
1786 #[test]
1787 fn test_lazy_continuation_disallowed() {
1788 let content = "# Heading\n\n1. List\nSome text.";
1790 let config = MD032Config {
1791 allow_lazy_continuation: false,
1792 };
1793 let warnings = lint_with_config(content, config);
1794 assert_eq!(
1795 warnings.len(),
1796 1,
1797 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1798 );
1799 assert!(
1800 warnings[0].message.contains("followed by blank line"),
1801 "Warning message should mention blank line"
1802 );
1803 }
1804
1805 #[test]
1806 fn test_lazy_continuation_fix() {
1807 let content = "# Heading\n\n1. List\nSome text.";
1809 let config = MD032Config {
1810 allow_lazy_continuation: false,
1811 };
1812 let fixed = fix_with_config(content, config.clone());
1813 assert_eq!(
1814 fixed, "# Heading\n\n1. List\n\nSome text.",
1815 "Fix should insert blank line before lazy continuation"
1816 );
1817
1818 let warnings_after = lint_with_config(&fixed, config);
1820 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1821 }
1822
1823 #[test]
1824 fn test_lazy_continuation_multiple_lines() {
1825 let content = "- Item 1\nLine 2\nLine 3";
1827 let config = MD032Config {
1828 allow_lazy_continuation: false,
1829 };
1830 let warnings = lint_with_config(content, config.clone());
1831 assert_eq!(
1832 warnings.len(),
1833 1,
1834 "Should warn for lazy continuation. Got: {warnings:?}"
1835 );
1836
1837 let fixed = fix_with_config(content, config.clone());
1838 assert_eq!(
1839 fixed, "- Item 1\n\nLine 2\nLine 3",
1840 "Fix should insert blank line after list"
1841 );
1842
1843 let warnings_after = lint_with_config(&fixed, config);
1845 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1846 }
1847
1848 #[test]
1849 fn test_lazy_continuation_with_indented_content() {
1850 let content = "- Item 1\n Indented content\nLazy text";
1852 let config = MD032Config {
1853 allow_lazy_continuation: false,
1854 };
1855 let warnings = lint_with_config(content, config);
1856 assert_eq!(
1857 warnings.len(),
1858 1,
1859 "Should warn for lazy text after indented content. Got: {warnings:?}"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_lazy_continuation_properly_separated() {
1865 let content = "- Item 1\n\nSome text.";
1867 let config = MD032Config {
1868 allow_lazy_continuation: false,
1869 };
1870 let warnings = lint_with_config(content, config);
1871 assert_eq!(
1872 warnings.len(),
1873 0,
1874 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1875 );
1876 }
1877
1878 #[test]
1881 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1882 let content = "1) First item\nLazy continuation";
1884 let config = MD032Config {
1885 allow_lazy_continuation: false,
1886 };
1887 let warnings = lint_with_config(content, config.clone());
1888 assert_eq!(
1889 warnings.len(),
1890 1,
1891 "Should warn for lazy continuation with parenthesis marker"
1892 );
1893
1894 let fixed = fix_with_config(content, config);
1895 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1896 }
1897
1898 #[test]
1899 fn test_lazy_continuation_followed_by_another_list() {
1900 let content = "- Item 1\nSome text\n- Item 2";
1906 let config = MD032Config {
1907 allow_lazy_continuation: false,
1908 };
1909 let warnings = lint_with_config(content, config);
1910 assert_eq!(
1912 warnings.len(),
1913 1,
1914 "Should warn about lazy continuation within list. Got: {warnings:?}"
1915 );
1916 assert!(
1917 warnings[0].message.contains("Lazy continuation"),
1918 "Warning should be about lazy continuation"
1919 );
1920 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1921 }
1922
1923 #[test]
1924 fn test_lazy_continuation_multiple_in_document() {
1925 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1930 let config = MD032Config {
1931 allow_lazy_continuation: false,
1932 };
1933 let warnings = lint_with_config(content, config.clone());
1934 assert_eq!(
1938 warnings.len(),
1939 2,
1940 "Should warn for both lazy continuations and list end. Got: {warnings:?}"
1941 );
1942
1943 let fixed = fix_with_config(content, config.clone());
1944 let warnings_after = lint_with_config(&fixed, config);
1945 assert_eq!(
1948 warnings_after.len(),
1949 1,
1950 "Within-list lazy continuation warning should remain (no auto-fix)"
1951 );
1952 }
1953
1954 #[test]
1955 fn test_lazy_continuation_end_of_document_no_newline() {
1956 let content = "- Item\nNo trailing newline";
1958 let config = MD032Config {
1959 allow_lazy_continuation: false,
1960 };
1961 let warnings = lint_with_config(content, config.clone());
1962 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1963
1964 let fixed = fix_with_config(content, config);
1965 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1966 }
1967
1968 #[test]
1969 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1970 let content = "- Item 1\n---";
1973 let config = MD032Config {
1974 allow_lazy_continuation: false,
1975 };
1976 let warnings = lint_with_config(content, config.clone());
1977 assert_eq!(
1979 warnings.len(),
1980 1,
1981 "List should need blank line before thematic break. Got: {warnings:?}"
1982 );
1983
1984 let fixed = fix_with_config(content, config);
1986 assert_eq!(fixed, "- Item 1\n\n---");
1987 }
1988
1989 #[test]
1990 fn test_lazy_continuation_heading_not_flagged() {
1991 let content = "- Item 1\n# Heading";
1994 let config = MD032Config {
1995 allow_lazy_continuation: false,
1996 };
1997 let warnings = lint_with_config(content, config);
1998 assert!(
2001 warnings.iter().all(|w| !w.message.contains("lazy")),
2002 "Heading should not trigger lazy continuation warning"
2003 );
2004 }
2005
2006 #[test]
2007 fn test_lazy_continuation_mixed_list_types() {
2008 let content = "- Unordered\n1. Ordered\nLazy text";
2010 let config = MD032Config {
2011 allow_lazy_continuation: false,
2012 };
2013 let warnings = lint_with_config(content, config.clone());
2014 assert!(!warnings.is_empty(), "Should warn about structure issues");
2015 }
2016
2017 #[test]
2018 fn test_lazy_continuation_deep_nesting() {
2019 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2021 let config = MD032Config {
2022 allow_lazy_continuation: false,
2023 };
2024 let warnings = lint_with_config(content, config.clone());
2025 assert!(
2026 !warnings.is_empty(),
2027 "Should warn about lazy continuation after nested list"
2028 );
2029
2030 let fixed = fix_with_config(content, config.clone());
2031 let warnings_after = lint_with_config(&fixed, config);
2032 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2033 }
2034
2035 #[test]
2036 fn test_lazy_continuation_with_emphasis_in_text() {
2037 let content = "- Item\n*emphasized* continuation";
2039 let config = MD032Config {
2040 allow_lazy_continuation: false,
2041 };
2042 let warnings = lint_with_config(content, config.clone());
2043 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2044
2045 let fixed = fix_with_config(content, config);
2046 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
2047 }
2048
2049 #[test]
2050 fn test_lazy_continuation_with_code_span() {
2051 let content = "- Item\n`code` continuation";
2053 let config = MD032Config {
2054 allow_lazy_continuation: false,
2055 };
2056 let warnings = lint_with_config(content, config.clone());
2057 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2058
2059 let fixed = fix_with_config(content, config);
2060 assert_eq!(fixed, "- Item\n\n`code` continuation");
2061 }
2062
2063 #[test]
2070 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2071 let content = r#"1. Create a new Chat conversation:
2074 - On the sidebar, select **New Chat**.
2075 - In the box, type `/new`.
2076 A new Chat conversation replaces the previous one.
20771. Under the Chat text box, turn off the toggle."#;
2078 let config = MD032Config {
2079 allow_lazy_continuation: false,
2080 };
2081 let warnings = lint_with_config(content, config);
2082 let lazy_warnings: Vec<_> = warnings
2084 .iter()
2085 .filter(|w| w.message.contains("Lazy continuation"))
2086 .collect();
2087 assert!(
2088 !lazy_warnings.is_empty(),
2089 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2090 );
2091 assert!(
2092 lazy_warnings.iter().any(|w| w.line == 4),
2093 "Should warn on line 4. Got: {lazy_warnings:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2099 let content = r#"- `field`: Is the specific key:
2102 - `password`: Accesses the password.
2103 - `api_key`: Accesses the api_key.
2104 `token`: Specifies which ID token to use.
2105- `version_id`: Is the unique identifier."#;
2106 let config = MD032Config {
2107 allow_lazy_continuation: false,
2108 };
2109 let warnings = lint_with_config(content, config);
2110 let lazy_warnings: Vec<_> = warnings
2112 .iter()
2113 .filter(|w| w.message.contains("Lazy continuation"))
2114 .collect();
2115 assert!(
2116 !lazy_warnings.is_empty(),
2117 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2118 );
2119 assert!(
2120 lazy_warnings.iter().any(|w| w.line == 4),
2121 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2122 );
2123 }
2124
2125 #[test]
2126 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2127 let content = r#"- Check out the branch, and test locally.
2129 - If the MR requires significant modifications:
2130 - **Skip local testing** and review instead.
2131 - **Request verification** from the author.
2132 - **Identify the minimal change** needed.
2133 Your testing might result in opportunities.
2134- If you don't understand, _say so_."#;
2135 let config = MD032Config {
2136 allow_lazy_continuation: false,
2137 };
2138 let warnings = lint_with_config(content, config);
2139 let lazy_warnings: Vec<_> = warnings
2141 .iter()
2142 .filter(|w| w.message.contains("Lazy continuation"))
2143 .collect();
2144 assert!(
2145 !lazy_warnings.is_empty(),
2146 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2147 );
2148 assert!(
2149 lazy_warnings.iter().any(|w| w.line == 6),
2150 "Should warn on line 6. Got: {lazy_warnings:?}"
2151 );
2152 }
2153
2154 #[test]
2155 fn test_issue295_ordered_list_nested_bullets_continuation() {
2156 let content = r#"# Test
2159
21601. First item.
2161 - Nested A.
2162 - Nested B.
2163 Continuation at outer level.
21641. Second item."#;
2165 let config = MD032Config {
2166 allow_lazy_continuation: false,
2167 };
2168 let warnings = lint_with_config(content, config);
2169 let lazy_warnings: Vec<_> = warnings
2171 .iter()
2172 .filter(|w| w.message.contains("Lazy continuation"))
2173 .collect();
2174 assert!(
2175 !lazy_warnings.is_empty(),
2176 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2177 );
2178 assert!(
2180 lazy_warnings.iter().any(|w| w.line == 6),
2181 "Should warn on line 6. Got: {lazy_warnings:?}"
2182 );
2183 }
2184
2185 #[test]
2186 fn test_issue295_multiple_lazy_lines_after_nested() {
2187 let content = r#"1. The device client receives a response.
2189 - Those defined by OAuth Framework.
2190 - Those specific to device authorization.
2191 Those error responses are described below.
2192 For more information on each response,
2193 see the documentation.
21941. Next step in the process."#;
2195 let config = MD032Config {
2196 allow_lazy_continuation: false,
2197 };
2198 let warnings = lint_with_config(content, config);
2199 let lazy_warnings: Vec<_> = warnings
2201 .iter()
2202 .filter(|w| w.message.contains("Lazy continuation"))
2203 .collect();
2204 assert!(
2205 lazy_warnings.len() >= 3,
2206 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2207 lazy_warnings.len()
2208 );
2209 }
2210
2211 #[test]
2212 fn test_issue295_properly_indented_not_lazy() {
2213 let content = r#"1. First item.
2215 - Nested A.
2216 - Nested B.
2217
2218 Properly indented continuation.
22191. Second item."#;
2220 let config = MD032Config {
2221 allow_lazy_continuation: false,
2222 };
2223 let warnings = lint_with_config(content, config);
2224 let lazy_warnings: Vec<_> = warnings
2226 .iter()
2227 .filter(|w| w.message.contains("Lazy continuation"))
2228 .collect();
2229 assert_eq!(
2230 lazy_warnings.len(),
2231 0,
2232 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2233 );
2234 }
2235
2236 #[test]
2243 fn test_html_comment_before_list_with_preceding_blank() {
2244 let content = "Some text.\n\n<!-- comment -->\n- List item";
2247 let warnings = lint(content);
2248 assert_eq!(
2249 warnings.len(),
2250 0,
2251 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2252 );
2253 }
2254
2255 #[test]
2256 fn test_html_comment_after_list_with_following_blank() {
2257 let content = "- List item\n<!-- comment -->\n\nSome text.";
2259 let warnings = lint(content);
2260 assert_eq!(
2261 warnings.len(),
2262 0,
2263 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2264 );
2265 }
2266
2267 #[test]
2268 fn test_list_inside_html_comment_ignored() {
2269 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2271 let warnings = lint(content);
2272 assert_eq!(
2273 warnings.len(),
2274 0,
2275 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_multiline_html_comment_before_list() {
2281 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2283 let warnings = lint(content);
2284 assert_eq!(
2285 warnings.len(),
2286 0,
2287 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_no_blank_before_html_comment_still_warns() {
2293 let content = "Some text.\n<!-- comment -->\n- List item";
2295 let warnings = lint(content);
2296 assert_eq!(
2297 warnings.len(),
2298 1,
2299 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2300 );
2301 assert!(
2302 warnings[0].message.contains("preceded by blank line"),
2303 "Should be 'preceded by blank line' warning"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2309 let content = "- List item\n<!-- comment -->\nSome text.";
2312 let warnings = lint(content);
2313 assert_eq!(
2314 warnings.len(),
2315 0,
2316 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2317 );
2318 }
2319
2320 #[test]
2321 fn test_list_followed_by_heading_through_comment_should_warn() {
2322 let content = "- List item\n<!-- comment -->\n# Heading";
2324 let warnings = lint(content);
2325 assert!(
2328 warnings.len() <= 1,
2329 "Should handle heading after comment gracefully. Got: {warnings:?}"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_html_comment_between_list_and_text_both_directions() {
2335 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2337 let warnings = lint(content);
2338 assert_eq!(
2339 warnings.len(),
2340 0,
2341 "Should not warn with proper separation through comments. Got: {warnings:?}"
2342 );
2343 }
2344
2345 #[test]
2346 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2347 let content = "Text.\n\n<!-- comment -->\n- Item";
2349 let fixed = fix(content);
2350 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2351 }
2352
2353 #[test]
2354 fn test_html_comment_fix_adds_blank_when_needed() {
2355 let content = "Text.\n<!-- comment -->\n- Item";
2358 let fixed = fix(content);
2359 assert!(
2360 fixed.contains("<!-- comment -->\n\n- Item"),
2361 "Fix should add blank line before list. Got: {fixed}"
2362 );
2363 }
2364
2365 #[test]
2366 fn test_ordered_list_inside_html_comment() {
2367 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2369 let warnings = lint(content);
2370 assert_eq!(
2371 warnings.len(),
2372 0,
2373 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2374 );
2375 }
2376
2377 #[test]
2384 fn test_blockquote_list_exit_no_warning() {
2385 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2387 let warnings = lint(content);
2388 assert_eq!(
2389 warnings.len(),
2390 0,
2391 "Should not warn when exiting blockquote. Got: {warnings:?}"
2392 );
2393 }
2394
2395 #[test]
2396 fn test_nested_blockquote_list_exit() {
2397 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2399 let warnings = lint(content);
2400 assert_eq!(
2401 warnings.len(),
2402 0,
2403 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_blockquote_same_level_no_warning() {
2409 let content = "> - item 1\n> - item 2\n> Text after";
2412 let warnings = lint(content);
2413 assert_eq!(
2414 warnings.len(),
2415 0,
2416 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2417 );
2418 }
2419
2420 #[test]
2421 fn test_blockquote_list_with_special_chars() {
2422 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2424 let warnings = lint(content);
2425 assert_eq!(
2426 warnings.len(),
2427 0,
2428 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2429 );
2430 }
2431
2432 #[test]
2433 fn test_lazy_continuation_whitespace_only_line() {
2434 let content = "- Item\n \nText after whitespace-only line";
2437 let config = MD032Config {
2438 allow_lazy_continuation: false,
2439 };
2440 let warnings = lint_with_config(content, config.clone());
2441 assert_eq!(
2443 warnings.len(),
2444 1,
2445 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
2446 );
2447
2448 let fixed = fix_with_config(content, config);
2450 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
2451 }
2452
2453 #[test]
2454 fn test_lazy_continuation_blockquote_context() {
2455 let content = "> - Item\n> Lazy in quote";
2457 let config = MD032Config {
2458 allow_lazy_continuation: false,
2459 };
2460 let warnings = lint_with_config(content, config);
2461 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2464 }
2465
2466 #[test]
2467 fn test_lazy_continuation_fix_preserves_content() {
2468 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2470 let config = MD032Config {
2471 allow_lazy_continuation: false,
2472 };
2473 let fixed = fix_with_config(content, config);
2474 assert!(fixed.contains("<>&"), "Should preserve special chars");
2475 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2476 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
2477 }
2478
2479 #[test]
2480 fn test_lazy_continuation_fix_idempotent() {
2481 let content = "- Item\nLazy";
2483 let config = MD032Config {
2484 allow_lazy_continuation: false,
2485 };
2486 let fixed_once = fix_with_config(content, config.clone());
2487 let fixed_twice = fix_with_config(&fixed_once, config);
2488 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2489 }
2490
2491 #[test]
2492 fn test_lazy_continuation_config_default_allows() {
2493 let content = "- Item\nLazy text that continues";
2495 let default_config = MD032Config::default();
2496 assert!(
2497 default_config.allow_lazy_continuation,
2498 "Default should allow lazy continuation"
2499 );
2500 let warnings = lint_with_config(content, default_config);
2501 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2502 }
2503
2504 #[test]
2505 fn test_lazy_continuation_after_multi_line_item() {
2506 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2508 let config = MD032Config {
2509 allow_lazy_continuation: false,
2510 };
2511 let warnings = lint_with_config(content, config.clone());
2512 assert_eq!(
2513 warnings.len(),
2514 1,
2515 "Should warn only for the lazy line, not the indented line"
2516 );
2517 }
2518
2519 #[test]
2521 fn test_blockquote_list_with_continuation_and_nested() {
2522 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2525 let warnings = lint(content);
2526 assert_eq!(
2527 warnings.len(),
2528 0,
2529 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2530 );
2531 }
2532
2533 #[test]
2534 fn test_blockquote_list_simple() {
2535 let content = "> - item 1\n> - item 2";
2537 let warnings = lint(content);
2538 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2539 }
2540
2541 #[test]
2542 fn test_blockquote_list_with_continuation_only() {
2543 let content = "> - item 1\n> continuation\n> - item 2";
2545 let warnings = lint(content);
2546 assert_eq!(
2547 warnings.len(),
2548 0,
2549 "Blockquoted list with continuation should have no warnings"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_blockquote_list_with_lazy_continuation() {
2555 let content = "> - item 1\n> lazy continuation\n> - item 2";
2557 let warnings = lint(content);
2558 assert_eq!(
2559 warnings.len(),
2560 0,
2561 "Blockquoted list with lazy continuation should have no warnings"
2562 );
2563 }
2564
2565 #[test]
2566 fn test_nested_blockquote_list() {
2567 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2569 let warnings = lint(content);
2570 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2571 }
2572
2573 #[test]
2574 fn test_blockquote_list_needs_preceding_blank() {
2575 let content = "> Text before\n> - item 1\n> - item 2";
2577 let warnings = lint(content);
2578 assert_eq!(
2579 warnings.len(),
2580 1,
2581 "Should warn for missing blank before blockquoted list"
2582 );
2583 }
2584
2585 #[test]
2586 fn test_blockquote_list_properly_separated() {
2587 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2589 let warnings = lint(content);
2590 assert_eq!(
2591 warnings.len(),
2592 0,
2593 "Properly separated blockquoted list should have no warnings"
2594 );
2595 }
2596
2597 #[test]
2598 fn test_blockquote_ordered_list() {
2599 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2601 let warnings = lint(content);
2602 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2603 }
2604
2605 #[test]
2606 fn test_blockquote_list_with_empty_blockquote_line() {
2607 let content = "> - item 1\n>\n> - item 2";
2609 let warnings = lint(content);
2610 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2611 }
2612
2613 #[test]
2615 fn test_blockquote_list_multi_paragraph_items() {
2616 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2619 let warnings = lint(content);
2620 assert_eq!(
2621 warnings.len(),
2622 0,
2623 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2624 );
2625 }
2626
2627 #[test]
2629 fn test_blockquote_ordered_list_multi_paragraph_items() {
2630 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2631 let warnings = lint(content);
2632 assert_eq!(
2633 warnings.len(),
2634 0,
2635 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2636 );
2637 }
2638
2639 #[test]
2641 fn test_blockquote_list_multiple_continuations() {
2642 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2643 let warnings = lint(content);
2644 assert_eq!(
2645 warnings.len(),
2646 0,
2647 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2648 );
2649 }
2650
2651 #[test]
2653 fn test_nested_blockquote_multi_paragraph_list() {
2654 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2655 let warnings = lint(content);
2656 assert_eq!(
2657 warnings.len(),
2658 0,
2659 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2660 );
2661 }
2662
2663 #[test]
2665 fn test_triple_nested_blockquote_multi_paragraph_list() {
2666 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2667 let warnings = lint(content);
2668 assert_eq!(
2669 warnings.len(),
2670 0,
2671 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2672 );
2673 }
2674
2675 #[test]
2677 fn test_blockquote_list_last_item_continuation() {
2678 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2679 let warnings = lint(content);
2680 assert_eq!(
2681 warnings.len(),
2682 0,
2683 "Last item with continuation should have no warnings. Got: {warnings:?}"
2684 );
2685 }
2686
2687 #[test]
2689 fn test_blockquote_list_first_item_only_continuation() {
2690 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2691 let warnings = lint(content);
2692 assert_eq!(
2693 warnings.len(),
2694 0,
2695 "Single item with continuation should have no warnings. Got: {warnings:?}"
2696 );
2697 }
2698
2699 #[test]
2703 fn test_blockquote_level_change_breaks_list() {
2704 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2706 let warnings = lint(content);
2707 assert!(
2711 warnings.len() <= 2,
2712 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2713 );
2714 }
2715
2716 #[test]
2718 fn test_exit_blockquote_needs_blank_before_list() {
2719 let content = "> Blockquote text\n\n- List outside blockquote\n";
2721 let warnings = lint(content);
2722 assert_eq!(
2723 warnings.len(),
2724 0,
2725 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2726 );
2727
2728 let content2 = "> Blockquote text\n- List outside blockquote\n";
2732 let warnings2 = lint(content2);
2733 assert!(
2735 warnings2.len() <= 1,
2736 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2737 );
2738 }
2739
2740 #[test]
2742 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2743 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2745 let warnings = lint(content_dash);
2746 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2747
2748 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2750 let warnings = lint(content_asterisk);
2751 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2752
2753 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2755 let warnings = lint(content_plus);
2756 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2757 }
2758
2759 #[test]
2761 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2762 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2763 let warnings = lint(content);
2764 assert_eq!(
2765 warnings.len(),
2766 0,
2767 "Parenthesis ordered markers should work. Got: {warnings:?}"
2768 );
2769 }
2770
2771 #[test]
2773 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2774 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2776 let warnings = lint(content);
2777 assert_eq!(
2778 warnings.len(),
2779 0,
2780 "Multi-digit ordered list should work. Got: {warnings:?}"
2781 );
2782 }
2783
2784 #[test]
2786 fn test_blockquote_multi_paragraph_with_formatting() {
2787 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2788 let warnings = lint(content);
2789 assert_eq!(
2790 warnings.len(),
2791 0,
2792 "Continuation with inline formatting should work. Got: {warnings:?}"
2793 );
2794 }
2795
2796 #[test]
2798 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2799 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2800 let warnings = lint(content);
2801 assert_eq!(
2802 warnings.len(),
2803 0,
2804 "All items with continuations should work. Got: {warnings:?}"
2805 );
2806 }
2807
2808 #[test]
2810 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2811 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2812 let warnings = lint(content);
2813 assert_eq!(
2814 warnings.len(),
2815 0,
2816 "Lowercase continuation should work. Got: {warnings:?}"
2817 );
2818 }
2819
2820 #[test]
2822 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2823 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2824 let warnings = lint(content);
2825 assert_eq!(
2826 warnings.len(),
2827 0,
2828 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2829 );
2830 }
2831
2832 #[test]
2834 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2835 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2837 let warnings = lint(content);
2838 assert!(
2840 warnings.len() <= 1,
2841 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2842 );
2843 }
2844
2845 #[test]
2847 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2848 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2850 let warnings = lint(content);
2851 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2852 }
2853
2854 #[test]
2855 fn test_blockquote_list_varying_spaces_after_marker() {
2856 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2858 let warnings = lint(content);
2859 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2860 }
2861
2862 #[test]
2863 fn test_deeply_nested_blockquote_list() {
2864 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2866 let warnings = lint(content);
2867 assert_eq!(
2868 warnings.len(),
2869 0,
2870 "Deeply nested blockquote list should have no warnings"
2871 );
2872 }
2873
2874 #[test]
2875 fn test_blockquote_level_change_in_list() {
2876 let content = "> - item 1\n>> - deeper item\n> - item 2";
2878 let warnings = lint(content);
2881 assert!(
2882 !warnings.is_empty(),
2883 "Blockquote level change should break list and trigger warnings"
2884 );
2885 }
2886
2887 #[test]
2888 fn test_blockquote_list_with_code_span() {
2889 let content = "> - item with `code`\n> continuation\n> - item 2";
2891 let warnings = lint(content);
2892 assert_eq!(
2893 warnings.len(),
2894 0,
2895 "Blockquote list with code span should have no warnings"
2896 );
2897 }
2898
2899 #[test]
2900 fn test_blockquote_list_at_document_end() {
2901 let content = "> Some text\n>\n> - item 1\n> - item 2";
2903 let warnings = lint(content);
2904 assert_eq!(
2905 warnings.len(),
2906 0,
2907 "Blockquote list at document end should have no warnings"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_fix_preserves_blockquote_prefix_before_list() {
2913 let content = "> Text before
2915> - Item 1
2916> - Item 2";
2917 let fixed = fix(content);
2918
2919 let expected = "> Text before
2921>
2922> - Item 1
2923> - Item 2";
2924 assert_eq!(
2925 fixed, expected,
2926 "Fix should insert '>' blank line, not plain blank line"
2927 );
2928 }
2929
2930 #[test]
2931 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2932 let content = ">>> Triple nested
2935>>> - Item 1
2936>>> - Item 2
2937>>> More text";
2938 let fixed = fix(content);
2939
2940 let expected = ">>> Triple nested
2942>>>
2943>>> - Item 1
2944>>> - Item 2
2945>>> More text";
2946 assert_eq!(
2947 fixed, expected,
2948 "Fix should preserve triple-nested blockquote prefix '>>>'"
2949 );
2950 }
2951
2952 fn lint_quarto(content: &str) -> Vec<LintWarning> {
2955 let rule = MD032BlanksAroundLists::default();
2956 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2957 rule.check(&ctx).unwrap()
2958 }
2959
2960 #[test]
2961 fn test_quarto_list_after_div_open() {
2962 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
2964 let warnings = lint_quarto(content);
2965 assert!(
2967 warnings.is_empty(),
2968 "Quarto div marker should be transparent before list: {warnings:?}"
2969 );
2970 }
2971
2972 #[test]
2973 fn test_quarto_list_before_div_close() {
2974 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
2976 let warnings = lint_quarto(content);
2977 assert!(
2979 warnings.is_empty(),
2980 "Quarto div marker should be transparent after list: {warnings:?}"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_quarto_list_needs_blank_without_div() {
2986 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
2988 let warnings = lint_quarto(content);
2989 assert!(
2992 !warnings.is_empty(),
2993 "Should still require blank when not present: {warnings:?}"
2994 );
2995 }
2996
2997 #[test]
2998 fn test_quarto_list_in_callout_with_content() {
2999 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3001 let warnings = lint_quarto(content);
3002 assert!(
3003 warnings.is_empty(),
3004 "List with proper blanks inside callout should pass: {warnings:?}"
3005 );
3006 }
3007
3008 #[test]
3009 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3010 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3012 let warnings = lint(content); assert!(
3015 !warnings.is_empty(),
3016 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3017 );
3018 }
3019
3020 #[test]
3021 fn test_quarto_nested_divs_with_list() {
3022 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3024 let warnings = lint_quarto(content);
3025 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3026 }
3027}