1use crate::lint_context::LazyContLine;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::utils::blockquote::{content_after_blockquote, effective_indent_in_blockquote};
4use crate::utils::calculate_indentation_width_default;
5use crate::utils::quarto_divs;
6use crate::utils::range_utils::{LineIndex, calculate_line_range};
7use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
8use regex::Regex;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub(super) use md032_config::MD032Config;
13
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17fn is_thematic_break(line: &str) -> bool {
20 if calculate_indentation_width_default(line) > 3 {
22 return false;
23 }
24
25 let trimmed = line.trim();
26 if trimmed.len() < 3 {
27 return false;
28 }
29
30 let chars: Vec<char> = trimmed.chars().collect();
31 let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33 if let Some(&marker) = first_non_space {
34 if marker != '-' && marker != '*' && marker != '_' {
35 return false;
36 }
37 let marker_count = chars.iter().filter(|&&c| c == marker).count();
38 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39 marker_count >= 3 && other_count == 0
40 } else {
41 false
42 }
43}
44
45#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116 config: MD032Config,
117}
118
119impl MD032BlanksAroundLists {
120 pub fn from_config_struct(config: MD032Config) -> Self {
121 Self { config }
122 }
123}
124
125impl MD032BlanksAroundLists {
126 fn should_require_blank_line_before(
128 ctx: &crate::lint_context::LintContext,
129 prev_line_num: usize,
130 current_line_num: usize,
131 ) -> bool {
132 if ctx
134 .line_info(prev_line_num)
135 .is_some_and(|info| info.in_code_block || info.in_front_matter)
136 {
137 return true;
138 }
139
140 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
142 return false;
143 }
144
145 true
147 }
148
149 fn is_nested_list(
151 ctx: &crate::lint_context::LintContext,
152 prev_line_num: usize, current_line_num: usize, ) -> bool {
155 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
157 let current_line = &ctx.lines[current_line_num - 1];
158 if current_line.indent >= 2 {
159 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
161 let prev_line = &ctx.lines[prev_line_num - 1];
162 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
164 return true;
165 }
166 }
167 }
168 }
169 false
170 }
171
172 fn should_apply_lazy_fix(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
175 ctx.lines
176 .get(line_num.saturating_sub(1))
177 .is_some_and(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
178 }
179
180 fn calculate_lazy_continuation_fix(
183 ctx: &crate::lint_context::LintContext,
184 line_num: usize,
185 lazy_info: &LazyContLine,
186 ) -> Option<Fix> {
187 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
188 let line_content = line_info.content(ctx.content);
189
190 if lazy_info.blockquote_level == 0 {
191 let start_byte = line_info.byte_offset;
193 let end_byte = start_byte + lazy_info.current_indent;
194 let replacement = " ".repeat(lazy_info.expected_indent);
195
196 Some(Fix {
197 range: start_byte..end_byte,
198 replacement,
199 })
200 } else {
201 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
203 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
204 if prefix_byte_len == 0 {
205 return None;
206 }
207
208 let current_indent = after_bq.len() - after_bq.trim_start().len();
209 let start_byte = line_info.byte_offset + prefix_byte_len;
210 let end_byte = start_byte + current_indent;
211 let replacement = " ".repeat(lazy_info.expected_indent);
212
213 Some(Fix {
214 range: start_byte..end_byte,
215 replacement,
216 })
217 }
218 }
219
220 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
223 if lazy_info.blockquote_level == 0 {
224 let content = line.trim_start();
226 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
227 } else {
228 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
230 let prefix_len = line.len().saturating_sub(after_bq.len());
231 if prefix_len == 0 {
232 return line.to_string();
233 }
234
235 let prefix = &line[..prefix_len];
236 let rest = after_bq.trim_start();
237 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
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 || info.in_mdx_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 || info.in_mdx_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 if ctx
308 .line_info(block.start_line)
309 .is_some_and(|info| info.in_footnote_definition)
310 {
311 continue;
312 }
313
314 let mut segments: Vec<(usize, usize)> = Vec::new();
320 let mut current_start = block.start_line;
321 let mut prev_item_line = 0;
322
323 let get_blockquote_level = |line_num: usize| -> usize {
325 if line_num == 0 || line_num > ctx.lines.len() {
326 return 0;
327 }
328 let line_content = ctx.lines[line_num - 1].content(ctx.content);
329 BLOCKQUOTE_PREFIX_RE
330 .find(line_content)
331 .map_or(0, |m| m.as_str().chars().filter(|&c| c == '>').count())
332 };
333
334 let mut prev_bq_level = 0;
335
336 for &item_line in &block.item_lines {
337 let current_bq_level = get_blockquote_level(item_line);
338
339 if prev_item_line > 0 {
340 let blockquote_level_changed = prev_bq_level != current_bq_level;
342
343 let mut has_standalone_code_fence = false;
346
347 let min_indent_for_content = if block.is_ordered {
349 3 } else {
353 2 };
356
357 for check_line in (prev_item_line + 1)..item_line {
358 if check_line - 1 < ctx.lines.len() {
359 let line = &ctx.lines[check_line - 1];
360 let line_content = line.content(ctx.content);
361 if line.in_code_block
362 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
363 {
364 if line.indent < min_indent_for_content {
367 has_standalone_code_fence = true;
368 break;
369 }
370 }
371 }
372 }
373
374 if has_standalone_code_fence || blockquote_level_changed {
375 segments.push((current_start, prev_item_line));
377 current_start = item_line;
378 }
379 }
380 prev_item_line = item_line;
381 prev_bq_level = current_bq_level;
382 }
383
384 if prev_item_line > 0 {
387 segments.push((current_start, prev_item_line));
388 }
389
390 let has_code_fence_splits = segments.len() > 1 && {
392 let mut found_fence = false;
394 for i in 0..segments.len() - 1 {
395 let seg_end = segments[i].1;
396 let next_start = segments[i + 1].0;
397 for check_line in (seg_end + 1)..next_start {
399 if check_line - 1 < ctx.lines.len() {
400 let line = &ctx.lines[check_line - 1];
401 let line_content = line.content(ctx.content);
402 if line.in_code_block
403 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
404 {
405 found_fence = true;
406 break;
407 }
408 }
409 }
410 if found_fence {
411 break;
412 }
413 }
414 found_fence
415 };
416
417 for (start, end) in &segments {
419 let mut actual_end = *end;
421
422 if !has_code_fence_splits && *end < block.end_line {
425 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
427
428 let min_continuation_indent = if block_bq_level > 0 {
431 if block.is_ordered {
433 block.max_marker_width
434 } else {
435 2 }
437 } else {
438 ctx.lines
439 .get(*end - 1)
440 .and_then(|line_info| line_info.list_item.as_ref())
441 .map_or(2, |item| item.content_column)
442 };
443
444 for check_line in (*end + 1)..=block.end_line {
445 if check_line - 1 < ctx.lines.len() {
446 let line = &ctx.lines[check_line - 1];
447 let line_content = line.content(ctx.content);
448 if block.item_lines.contains(&check_line) || line.heading.is_some() {
450 break;
451 }
452 if line.in_code_block {
454 break;
455 }
456
457 let effective_indent =
459 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
460
461 if effective_indent >= min_continuation_indent {
463 actual_end = check_line;
464 }
465 else if !line.is_blank
470 && line.heading.is_none()
471 && !block.item_lines.contains(&check_line)
472 && !is_thematic_break(line_content)
473 {
474 actual_end = check_line;
476 } else if !line.is_blank {
477 break;
479 }
480 }
481 }
482 }
483
484 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
485 }
486 }
487
488 blocks.retain(|(start, end, _)| {
490 let all_in_comment = (*start..=*end).all(|line_num| {
492 ctx.lines
493 .get(line_num - 1)
494 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
495 });
496 !all_in_comment
497 });
498
499 blocks
500 }
501
502 fn perform_checks(
503 &self,
504 ctx: &crate::lint_context::LintContext,
505 lines: &[&str],
506 list_blocks: &[(usize, usize, String)],
507 line_index: &LineIndex,
508 ) -> Vec<LintWarning> {
509 let mut warnings = Vec::new();
510 let num_lines = lines.len();
511
512 for (line_idx, line) in lines.iter().enumerate() {
515 let line_num = line_idx + 1;
516
517 let is_in_list = list_blocks
519 .iter()
520 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
521 if is_in_list {
522 continue;
523 }
524
525 if ctx.line_info(line_num).is_some_and(|info| {
527 info.in_code_block
528 || info.in_front_matter
529 || info.in_html_comment
530 || info.in_mdx_comment
531 || info.in_html_block
532 || info.in_jsx_block
533 }) {
534 continue;
535 }
536
537 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
539 if line_idx > 0 {
541 let prev_line = lines[line_idx - 1];
542 let prev_is_blank = is_blank_in_context(prev_line);
543 let prev_excluded = ctx
544 .line_info(line_idx)
545 .is_some_and(|info| info.in_code_block || info.in_front_matter);
546
547 let prev_trimmed = prev_line.trim();
552 let is_sentence_continuation = !prev_is_blank
553 && !prev_trimmed.is_empty()
554 && !prev_trimmed.ends_with('.')
555 && !prev_trimmed.ends_with('!')
556 && !prev_trimmed.ends_with('?')
557 && !prev_trimmed.ends_with(':')
558 && !prev_trimmed.ends_with(';')
559 && !prev_trimmed.ends_with('>')
560 && !prev_trimmed.ends_with('-')
561 && !prev_trimmed.ends_with('*');
562
563 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
564 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
566
567 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
568 warnings.push(LintWarning {
569 line: start_line,
570 column: start_col,
571 end_line,
572 end_column: end_col,
573 severity: Severity::Warning,
574 rule_name: Some(self.name().to_string()),
575 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
576 fix: Some(Fix {
577 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
578 replacement: format!("{bq_prefix}\n"),
579 }),
580 });
581 }
582
583 if line_idx + 1 < num_lines {
586 let next_line = lines[line_idx + 1];
587 let next_is_blank = is_blank_in_context(next_line);
588 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
589
590 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
591 let next_trimmed = next_line.trim_start();
595 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
596 || next_line.starts_with("1. ")
597 || (next_line.len() > next_trimmed.len()
598 && !next_trimmed.starts_with("- ")
599 && !next_trimmed.starts_with("* ")
600 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
603 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
604 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
605 warnings.push(LintWarning {
606 line: start_line,
607 column: start_col,
608 end_line,
609 end_column: end_col,
610 severity: Severity::Warning,
611 rule_name: Some(self.name().to_string()),
612 message: "List should be followed by blank line".to_string(),
613 fix: Some(Fix {
614 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
615 replacement: format!("{bq_prefix}\n"),
616 }),
617 });
618 }
619 }
620 }
621 }
622 }
623 }
624
625 for &(start_line, end_line, ref prefix) in list_blocks {
626 if ctx
628 .line_info(start_line)
629 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
630 {
631 continue;
632 }
633
634 if start_line > 1 {
635 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
637
638 if !has_blank_separation && content_line > 0 {
640 let prev_line_str = lines[content_line - 1];
641 let is_prev_excluded = ctx
642 .line_info(content_line)
643 .is_some_and(|info| info.in_code_block || info.in_front_matter);
644 let prev_prefix = BLOCKQUOTE_PREFIX_RE
645 .find(prev_line_str)
646 .map_or(String::new(), |m| m.as_str().to_string());
647 let prefixes_match = prev_prefix.trim() == prefix.trim();
648
649 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
652 if !is_prev_excluded && prefixes_match && should_require {
653 let (start_line, start_col, end_line, end_col) =
655 calculate_line_range(start_line, lines[start_line - 1]);
656
657 warnings.push(LintWarning {
658 line: start_line,
659 column: start_col,
660 end_line,
661 end_column: end_col,
662 severity: Severity::Warning,
663 rule_name: Some(self.name().to_string()),
664 message: "List should be preceded by blank line".to_string(),
665 fix: Some(Fix {
666 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
667 replacement: format!("{prefix}\n"),
668 }),
669 });
670 }
671 }
672 }
673
674 if end_line < num_lines {
675 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
677
678 if !has_blank_separation && content_line > 0 {
680 let next_line_str = lines[content_line - 1];
681 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
684 || (content_line <= ctx.lines.len()
685 && ctx.lines[content_line - 1].in_code_block
686 && ctx.lines[content_line - 1].indent >= 2);
687 let next_prefix = BLOCKQUOTE_PREFIX_RE
688 .find(next_line_str)
689 .map_or(String::new(), |m| m.as_str().to_string());
690
691 let end_line_str = lines[end_line - 1];
696 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
697 .find(end_line_str)
698 .map_or(String::new(), |m| m.as_str().to_string());
699 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
700 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
701 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
702
703 let prefixes_match = next_prefix.trim() == prefix.trim();
704
705 if !is_next_excluded && prefixes_match && !exits_blockquote {
708 let (start_line_last, start_col_last, end_line_last, end_col_last) =
710 calculate_line_range(end_line, lines[end_line - 1]);
711
712 warnings.push(LintWarning {
713 line: start_line_last,
714 column: start_col_last,
715 end_line: end_line_last,
716 end_column: end_col_last,
717 severity: Severity::Warning,
718 rule_name: Some(self.name().to_string()),
719 message: "List should be followed by blank line".to_string(),
720 fix: Some(Fix {
721 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
722 replacement: format!("{prefix}\n"),
723 }),
724 });
725 }
726 }
727 }
728 }
729 warnings
730 }
731}
732
733impl Rule for MD032BlanksAroundLists {
734 fn name(&self) -> &'static str {
735 "MD032"
736 }
737
738 fn description(&self) -> &'static str {
739 "Lists should be surrounded by blank lines"
740 }
741
742 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
743 let lines = ctx.raw_lines();
744 let line_index = &ctx.line_index;
745
746 if lines.is_empty() {
748 return Ok(Vec::new());
749 }
750
751 let list_blocks = self.convert_list_blocks(ctx);
752
753 if list_blocks.is_empty() {
754 return Ok(Vec::new());
755 }
756
757 let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index);
758
759 if !self.config.allow_lazy_continuation {
764 let lazy_cont_lines = ctx.lazy_continuation_lines();
765
766 for lazy_info in lazy_cont_lines.iter() {
767 let line_num = lazy_info.line_num;
768
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 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
786 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
787 } else {
788 None
789 };
790
791 warnings.push(LintWarning {
792 line: start_line,
793 column: start_col,
794 end_line,
795 end_column: end_col,
796 severity: Severity::Warning,
797 rule_name: Some(self.name().to_string()),
798 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
799 fix,
800 });
801 }
802 }
803
804 Ok(warnings)
805 }
806
807 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
808 Ok(self.fix_with_structure_impl(ctx))
809 }
810
811 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
812 ctx.content.is_empty() || ctx.list_blocks.is_empty()
815 }
816
817 fn category(&self) -> RuleCategory {
818 RuleCategory::List
819 }
820
821 fn as_any(&self) -> &dyn std::any::Any {
822 self
823 }
824
825 fn default_config_section(&self) -> Option<(String, toml::Value)> {
826 use crate::rule_config_serde::RuleConfig;
827 let default_config = MD032Config::default();
828 let json_value = serde_json::to_value(&default_config).ok()?;
829 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
830
831 if let toml::Value::Table(table) = toml_value {
832 if !table.is_empty() {
833 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
834 } else {
835 None
836 }
837 } else {
838 None
839 }
840 }
841
842 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
843 where
844 Self: Sized,
845 {
846 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
847 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
848 }
849}
850
851impl MD032BlanksAroundLists {
852 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
854 let lines = ctx.raw_lines();
855 let num_lines = lines.len();
856 if num_lines == 0 {
857 return String::new();
858 }
859
860 let list_blocks = self.convert_list_blocks(ctx);
861 if list_blocks.is_empty() {
862 return ctx.content.to_string();
863 }
864
865 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
868 if !self.config.allow_lazy_continuation {
869 let lazy_cont_lines = ctx.lazy_continuation_lines();
870 for lazy_info in lazy_cont_lines.iter() {
871 let line_num = lazy_info.line_num;
872 let is_within_block = list_blocks
874 .iter()
875 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
876 if !is_within_block {
877 continue;
878 }
879 if !Self::should_apply_lazy_fix(ctx, line_num) {
881 continue;
882 }
883 lazy_fixes.insert(line_num, lazy_info.clone());
884 }
885 }
886
887 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
888
889 for &(start_line, end_line, ref prefix) in &list_blocks {
891 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
893 continue;
894 }
895
896 if ctx
898 .line_info(start_line)
899 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
900 {
901 continue;
902 }
903
904 if start_line > 1 {
906 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
908
909 if !has_blank_separation && content_line > 0 {
911 let prev_line_str = lines[content_line - 1];
912 let is_prev_excluded = ctx
913 .line_info(content_line)
914 .is_some_and(|info| info.in_code_block || info.in_front_matter);
915 let prev_prefix = BLOCKQUOTE_PREFIX_RE
916 .find(prev_line_str)
917 .map_or(String::new(), |m| m.as_str().to_string());
918
919 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
920 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
922 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
924 insertions.insert(start_line, bq_prefix);
925 }
926 }
927 }
928
929 if end_line < num_lines {
931 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
933
934 if !has_blank_separation && content_line > 0 {
936 let next_line_str = lines[content_line - 1];
937 let is_next_excluded = ctx
939 .line_info(content_line)
940 .is_some_and(|info| info.in_code_block || info.in_front_matter)
941 || (content_line <= ctx.lines.len()
942 && ctx.lines[content_line - 1].in_code_block
943 && ctx.lines[content_line - 1].indent >= 2
944 && (ctx.lines[content_line - 1]
945 .content(ctx.content)
946 .trim()
947 .starts_with("```")
948 || ctx.lines[content_line - 1]
949 .content(ctx.content)
950 .trim()
951 .starts_with("~~~")));
952 let next_prefix = BLOCKQUOTE_PREFIX_RE
953 .find(next_line_str)
954 .map_or(String::new(), |m| m.as_str().to_string());
955
956 let end_line_str = lines[end_line - 1];
958 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
959 .find(end_line_str)
960 .map_or(String::new(), |m| m.as_str().to_string());
961 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
962 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
963 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
964
965 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
968 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
970 insertions.insert(end_line + 1, bq_prefix);
971 }
972 }
973 }
974 }
975
976 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
978 for (i, line) in lines.iter().enumerate() {
979 let current_line_num = i + 1;
980 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
981 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
982 {
983 result_lines.push(prefix_to_insert.clone());
984 }
985
986 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
988 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
989 {
990 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
991 result_lines.push(fixed_line);
992 } else {
993 result_lines.push(line.to_string());
994 }
995 }
996
997 let mut result = result_lines.join("\n");
999 if ctx.content.ends_with('\n') {
1000 result.push('\n');
1001 }
1002 result
1003 }
1004}
1005
1006fn is_blank_in_context(line: &str) -> bool {
1008 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1011 line[m.end()..].trim().is_empty()
1013 } else {
1014 line.trim().is_empty()
1016 }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use crate::lint_context::LintContext;
1023 use crate::rule::Rule;
1024
1025 fn lint(content: &str) -> Vec<LintWarning> {
1026 let rule = MD032BlanksAroundLists::default();
1027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028 rule.check(&ctx).expect("Lint check failed")
1029 }
1030
1031 fn fix(content: &str) -> String {
1032 let rule = MD032BlanksAroundLists::default();
1033 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 rule.fix(&ctx).expect("Lint fix failed")
1035 }
1036
1037 fn check_warnings_have_fixes(content: &str) {
1039 let warnings = lint(content);
1040 for warning in &warnings {
1041 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1042 }
1043 }
1044
1045 #[test]
1046 fn test_list_at_start() {
1047 let content = "- Item 1\n- Item 2\nText";
1050 let warnings = lint(content);
1051 assert_eq!(
1052 warnings.len(),
1053 0,
1054 "Trailing text is lazy continuation per CommonMark - no warning expected"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_list_at_end() {
1060 let content = "Text\n- Item 1\n- Item 2";
1061 let warnings = lint(content);
1062 assert_eq!(
1063 warnings.len(),
1064 1,
1065 "Expected 1 warning for list at end without preceding blank line"
1066 );
1067 assert_eq!(
1068 warnings[0].line, 2,
1069 "Warning should be on the first line of the list (line 2)"
1070 );
1071 assert!(warnings[0].message.contains("preceded by blank line"));
1072
1073 check_warnings_have_fixes(content);
1075
1076 let fixed_content = fix(content);
1077 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1078
1079 let warnings_after_fix = lint(&fixed_content);
1081 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1082 }
1083
1084 #[test]
1085 fn test_list_in_middle() {
1086 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1089 let warnings = lint(content);
1090 assert_eq!(
1091 warnings.len(),
1092 1,
1093 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1094 );
1095 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1096 assert!(warnings[0].message.contains("preceded by blank line"));
1097
1098 check_warnings_have_fixes(content);
1100
1101 let fixed_content = fix(content);
1102 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1103
1104 let warnings_after_fix = lint(&fixed_content);
1106 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1107 }
1108
1109 #[test]
1110 fn test_correct_spacing() {
1111 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1112 let warnings = lint(content);
1113 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1114
1115 let fixed_content = fix(content);
1116 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1117 }
1118
1119 #[test]
1120 fn test_list_with_content() {
1121 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1124 let warnings = lint(content);
1125 assert_eq!(
1126 warnings.len(),
1127 1,
1128 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1129 );
1130 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1131 assert!(warnings[0].message.contains("preceded by blank line"));
1132
1133 check_warnings_have_fixes(content);
1135
1136 let fixed_content = fix(content);
1137 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1138 assert_eq!(
1139 fixed_content, expected_fixed,
1140 "Fix did not produce the expected output. Got:\n{fixed_content}"
1141 );
1142
1143 let warnings_after_fix = lint(&fixed_content);
1145 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1146 }
1147
1148 #[test]
1149 fn test_nested_list() {
1150 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1152 let warnings = lint(content);
1153 assert_eq!(
1154 warnings.len(),
1155 1,
1156 "Nested list block needs preceding blank only. Got: {warnings:?}"
1157 );
1158 assert_eq!(warnings[0].line, 2);
1159 assert!(warnings[0].message.contains("preceded by blank line"));
1160
1161 check_warnings_have_fixes(content);
1163
1164 let fixed_content = fix(content);
1165 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1166
1167 let warnings_after_fix = lint(&fixed_content);
1169 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1170 }
1171
1172 #[test]
1173 fn test_list_with_internal_blanks() {
1174 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1176 let warnings = lint(content);
1177 assert_eq!(
1178 warnings.len(),
1179 1,
1180 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1181 );
1182 assert_eq!(warnings[0].line, 2);
1183 assert!(warnings[0].message.contains("preceded by blank line"));
1184
1185 check_warnings_have_fixes(content);
1187
1188 let fixed_content = fix(content);
1189 assert_eq!(
1190 fixed_content,
1191 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1192 );
1193
1194 let warnings_after_fix = lint(&fixed_content);
1196 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1197 }
1198
1199 #[test]
1200 fn test_ignore_code_blocks() {
1201 let content = "```\n- Not a list item\n```\nText";
1202 let warnings = lint(content);
1203 assert_eq!(warnings.len(), 0);
1204 let fixed_content = fix(content);
1205 assert_eq!(fixed_content, content);
1206 }
1207
1208 #[test]
1209 fn test_ignore_front_matter() {
1210 let content = "---\ntitle: Test\n---\n- List Item\nText";
1212 let warnings = lint(content);
1213 assert_eq!(
1214 warnings.len(),
1215 0,
1216 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1217 );
1218
1219 let fixed_content = fix(content);
1221 assert_eq!(fixed_content, content, "No changes when no warnings");
1222 }
1223
1224 #[test]
1225 fn test_multiple_lists() {
1226 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1231 let warnings = lint(content);
1232 assert!(
1234 !warnings.is_empty(),
1235 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1236 );
1237
1238 check_warnings_have_fixes(content);
1240
1241 let fixed_content = fix(content);
1242 let warnings_after_fix = lint(&fixed_content);
1244 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1245 }
1246
1247 #[test]
1248 fn test_adjacent_lists() {
1249 let content = "- List 1\n\n* List 2";
1250 let warnings = lint(content);
1251 assert_eq!(warnings.len(), 0);
1252 let fixed_content = fix(content);
1253 assert_eq!(fixed_content, content);
1254 }
1255
1256 #[test]
1257 fn test_list_in_blockquote() {
1258 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1260 let warnings = lint(content);
1261 assert_eq!(
1262 warnings.len(),
1263 1,
1264 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1265 );
1266 assert_eq!(warnings[0].line, 2);
1267
1268 check_warnings_have_fixes(content);
1270
1271 let fixed_content = fix(content);
1272 assert_eq!(
1274 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1275 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1276 );
1277
1278 let warnings_after_fix = lint(&fixed_content);
1280 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1281 }
1282
1283 #[test]
1284 fn test_ordered_list() {
1285 let content = "Text\n1. Item 1\n2. Item 2\nText";
1287 let warnings = lint(content);
1288 assert_eq!(warnings.len(), 1);
1289
1290 check_warnings_have_fixes(content);
1292
1293 let fixed_content = fix(content);
1294 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1295
1296 let warnings_after_fix = lint(&fixed_content);
1298 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1299 }
1300
1301 #[test]
1302 fn test_no_double_blank_fix() {
1303 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1306 assert_eq!(
1307 warnings.len(),
1308 0,
1309 "Should have no warnings - properly preceded, trailing is lazy"
1310 );
1311
1312 let fixed_content = fix(content);
1313 assert_eq!(
1314 fixed_content, content,
1315 "No fix needed when no warnings. Got:\n{fixed_content}"
1316 );
1317
1318 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1320 assert_eq!(warnings2.len(), 1);
1321 if !warnings2.is_empty() {
1322 assert_eq!(
1323 warnings2[0].line, 2,
1324 "Warning line for missing blank before should be the first line of the block"
1325 );
1326 }
1327
1328 check_warnings_have_fixes(content2);
1330
1331 let fixed_content2 = fix(content2);
1332 assert_eq!(
1333 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1334 "Fix added extra blank before. Got:\n{fixed_content2}"
1335 );
1336 }
1337
1338 #[test]
1339 fn test_empty_input() {
1340 let content = "";
1341 let warnings = lint(content);
1342 assert_eq!(warnings.len(), 0);
1343 let fixed_content = fix(content);
1344 assert_eq!(fixed_content, "");
1345 }
1346
1347 #[test]
1348 fn test_only_list() {
1349 let content = "- Item 1\n- Item 2";
1350 let warnings = lint(content);
1351 assert_eq!(warnings.len(), 0);
1352 let fixed_content = fix(content);
1353 assert_eq!(fixed_content, content);
1354 }
1355
1356 #[test]
1359 fn test_fix_complex_nested_blockquote() {
1360 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1362 let warnings = lint(content);
1363 assert_eq!(
1364 warnings.len(),
1365 1,
1366 "Should warn for missing preceding blank only. Got: {warnings:?}"
1367 );
1368
1369 check_warnings_have_fixes(content);
1371
1372 let fixed_content = fix(content);
1373 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1375 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1376
1377 let warnings_after_fix = lint(&fixed_content);
1378 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1379 }
1380
1381 #[test]
1382 fn test_fix_mixed_list_markers() {
1383 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1386 let warnings = lint(content);
1387 assert!(
1389 !warnings.is_empty(),
1390 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1391 );
1392
1393 check_warnings_have_fixes(content);
1395
1396 let fixed_content = fix(content);
1397 assert!(
1399 fixed_content.contains("Text\n\n-"),
1400 "Fix should add blank line before first list item"
1401 );
1402
1403 let warnings_after_fix = lint(&fixed_content);
1405 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1406 }
1407
1408 #[test]
1409 fn test_fix_ordered_list_with_different_numbers() {
1410 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1412 let warnings = lint(content);
1413 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1414
1415 check_warnings_have_fixes(content);
1417
1418 let fixed_content = fix(content);
1419 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1420 assert_eq!(
1421 fixed_content, expected,
1422 "Fix should handle ordered lists with non-sequential numbers"
1423 );
1424
1425 let warnings_after_fix = lint(&fixed_content);
1427 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1428 }
1429
1430 #[test]
1431 fn test_fix_list_with_code_blocks_inside() {
1432 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1434 let warnings = lint(content);
1435 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1436
1437 check_warnings_have_fixes(content);
1439
1440 let fixed_content = fix(content);
1441 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1442 assert_eq!(
1443 fixed_content, expected,
1444 "Fix should handle lists with internal code blocks"
1445 );
1446
1447 let warnings_after_fix = lint(&fixed_content);
1449 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1450 }
1451
1452 #[test]
1453 fn test_fix_deeply_nested_lists() {
1454 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1456 let warnings = lint(content);
1457 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1458
1459 check_warnings_have_fixes(content);
1461
1462 let fixed_content = fix(content);
1463 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1464 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1465
1466 let warnings_after_fix = lint(&fixed_content);
1468 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1469 }
1470
1471 #[test]
1472 fn test_fix_list_with_multiline_items() {
1473 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1476 let warnings = lint(content);
1477 assert_eq!(
1478 warnings.len(),
1479 1,
1480 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1481 );
1482
1483 check_warnings_have_fixes(content);
1485
1486 let fixed_content = fix(content);
1487 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1488 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1489
1490 let warnings_after_fix = lint(&fixed_content);
1492 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1493 }
1494
1495 #[test]
1496 fn test_fix_list_at_document_boundaries() {
1497 let content1 = "- Item 1\n- Item 2";
1499 let warnings1 = lint(content1);
1500 assert_eq!(
1501 warnings1.len(),
1502 0,
1503 "List at document start should not need blank before"
1504 );
1505 let fixed1 = fix(content1);
1506 assert_eq!(fixed1, content1, "No fix needed for list at start");
1507
1508 let content2 = "Text\n- Item 1\n- Item 2";
1510 let warnings2 = lint(content2);
1511 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1512 check_warnings_have_fixes(content2);
1513 let fixed2 = fix(content2);
1514 assert_eq!(
1515 fixed2, "Text\n\n- Item 1\n- Item 2",
1516 "Should add blank before list at end"
1517 );
1518 }
1519
1520 #[test]
1521 fn test_fix_preserves_existing_blank_lines() {
1522 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1523 let warnings = lint(content);
1524 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1525 let fixed_content = fix(content);
1526 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1527 }
1528
1529 #[test]
1530 fn test_fix_handles_tabs_and_spaces() {
1531 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1534 let warnings = lint(content);
1535 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1537
1538 check_warnings_have_fixes(content);
1540
1541 let fixed_content = fix(content);
1542 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1545 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1546
1547 let warnings_after_fix = lint(&fixed_content);
1549 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1550 }
1551
1552 #[test]
1553 fn test_fix_warning_objects_have_correct_ranges() {
1554 let content = "Text\n- Item 1\n- Item 2\nText";
1556 let warnings = lint(content);
1557 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1558
1559 for warning in &warnings {
1561 assert!(warning.fix.is_some(), "Warning should have fix");
1562 let fix = warning.fix.as_ref().unwrap();
1563 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1564 assert!(
1565 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1566 "Fix should have replacement or be insertion"
1567 );
1568 }
1569 }
1570
1571 #[test]
1572 fn test_fix_idempotent() {
1573 let content = "Text\n- Item 1\n- Item 2\nText";
1575
1576 let fixed_once = fix(content);
1578 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1579
1580 let fixed_twice = fix(&fixed_once);
1582 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1583
1584 let warnings_after_fix = lint(&fixed_once);
1586 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1587 }
1588
1589 #[test]
1590 fn test_fix_with_normalized_line_endings() {
1591 let content = "Text\n- Item 1\n- Item 2\nText";
1595 let warnings = lint(content);
1596 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1597
1598 check_warnings_have_fixes(content);
1600
1601 let fixed_content = fix(content);
1602 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1604 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1605 }
1606
1607 #[test]
1608 fn test_fix_preserves_final_newline() {
1609 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1612 let fixed_with_newline = fix(content_with_newline);
1613 assert!(
1614 fixed_with_newline.ends_with('\n'),
1615 "Fix should preserve final newline when present"
1616 );
1617 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1619
1620 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1622 let fixed_without_newline = fix(content_without_newline);
1623 assert!(
1624 !fixed_without_newline.ends_with('\n'),
1625 "Fix should not add final newline when not present"
1626 );
1627 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1629 }
1630
1631 #[test]
1632 fn test_fix_multiline_list_items_no_indent() {
1633 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";
1634
1635 let warnings = lint(content);
1636 assert_eq!(
1638 warnings.len(),
1639 0,
1640 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1641 );
1642
1643 let fixed_content = fix(content);
1644 assert_eq!(
1646 fixed_content, content,
1647 "Should not modify correctly formatted multi-line list items"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_nested_list_with_lazy_continuation() {
1653 let content = r#"# Test
1659
1660- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1661 1. Switch/case dispatcher statements (original Phase 3.2)
1662 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1663`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1664 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1665 references"#;
1666
1667 let warnings = lint(content);
1668 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 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_pipes_in_code_spans_not_detected_as_table() {
1683 let content = r#"# Test
1685
1686- Item with `a | b` inline code
1687 - Nested item should work
1688
1689"#;
1690
1691 let warnings = lint(content);
1692 let md032_warnings: Vec<_> = warnings
1693 .iter()
1694 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1695 .collect();
1696 assert_eq!(
1697 md032_warnings.len(),
1698 0,
1699 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_multiple_code_spans_with_pipes() {
1705 let content = r#"# Test
1707
1708- Item with `a | b` and `c || d` operators
1709 - Nested item should work
1710
1711"#;
1712
1713 let warnings = lint(content);
1714 let md032_warnings: Vec<_> = warnings
1715 .iter()
1716 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1717 .collect();
1718 assert_eq!(
1719 md032_warnings.len(),
1720 0,
1721 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1722 );
1723 }
1724
1725 #[test]
1726 fn test_actual_table_breaks_list() {
1727 let content = r#"# Test
1729
1730- Item before table
1731
1732| Col1 | Col2 |
1733|------|------|
1734| A | B |
1735
1736- Item after table
1737
1738"#;
1739
1740 let warnings = lint(content);
1741 let md032_warnings: Vec<_> = warnings
1743 .iter()
1744 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1745 .collect();
1746 assert_eq!(
1747 md032_warnings.len(),
1748 0,
1749 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_thematic_break_not_lazy_continuation() {
1755 let content = r#"- Item 1
1758- Item 2
1759***
1760
1761More text.
1762"#;
1763
1764 let warnings = lint(content);
1765 let md032_warnings: Vec<_> = warnings
1766 .iter()
1767 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1768 .collect();
1769 assert_eq!(
1770 md032_warnings.len(),
1771 1,
1772 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1773 );
1774 assert!(
1775 md032_warnings[0].message.contains("followed by blank line"),
1776 "Warning should be about missing blank after list"
1777 );
1778 }
1779
1780 #[test]
1781 fn test_thematic_break_with_blank_line() {
1782 let content = r#"- Item 1
1784- Item 2
1785
1786***
1787
1788More text.
1789"#;
1790
1791 let warnings = lint(content);
1792 let md032_warnings: Vec<_> = warnings
1793 .iter()
1794 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1795 .collect();
1796 assert_eq!(
1797 md032_warnings.len(),
1798 0,
1799 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1800 );
1801 }
1802
1803 #[test]
1804 fn test_various_thematic_break_styles() {
1805 for hr in ["---", "***", "___"] {
1810 let content = format!(
1811 r#"- Item 1
1812- Item 2
1813{hr}
1814
1815More text.
1816"#
1817 );
1818
1819 let warnings = lint(&content);
1820 let md032_warnings: Vec<_> = warnings
1821 .iter()
1822 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1823 .collect();
1824 assert_eq!(
1825 md032_warnings.len(),
1826 1,
1827 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1828 );
1829 }
1830 }
1831
1832 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1835 let rule = MD032BlanksAroundLists::from_config_struct(config);
1836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1837 rule.check(&ctx).expect("Lint check failed")
1838 }
1839
1840 fn fix_with_config(content: &str, config: MD032Config) -> String {
1841 let rule = MD032BlanksAroundLists::from_config_struct(config);
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843 rule.fix(&ctx).expect("Lint fix failed")
1844 }
1845
1846 #[test]
1847 fn test_lazy_continuation_allowed_by_default() {
1848 let content = "# Heading\n\n1. List\nSome text.";
1850 let warnings = lint(content);
1851 assert_eq!(
1852 warnings.len(),
1853 0,
1854 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1855 );
1856 }
1857
1858 #[test]
1859 fn test_lazy_continuation_disallowed() {
1860 let content = "# Heading\n\n1. List\nSome text.";
1862 let config = MD032Config {
1863 allow_lazy_continuation: false,
1864 };
1865 let warnings = lint_with_config(content, config);
1866 assert_eq!(
1867 warnings.len(),
1868 1,
1869 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1870 );
1871 assert!(
1872 warnings[0].message.contains("Lazy continuation"),
1873 "Warning message should mention lazy continuation"
1874 );
1875 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1876 }
1877
1878 #[test]
1879 fn test_lazy_continuation_fix() {
1880 let content = "# Heading\n\n1. List\nSome text.";
1882 let config = MD032Config {
1883 allow_lazy_continuation: false,
1884 };
1885 let fixed = fix_with_config(content, config.clone());
1886 assert_eq!(
1888 fixed, "# Heading\n\n1. List\n Some text.",
1889 "Fix should add proper indentation to lazy continuation"
1890 );
1891
1892 let warnings_after = lint_with_config(&fixed, config);
1894 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1895 }
1896
1897 #[test]
1898 fn test_lazy_continuation_multiple_lines() {
1899 let content = "- Item 1\nLine 2\nLine 3";
1901 let config = MD032Config {
1902 allow_lazy_continuation: false,
1903 };
1904 let warnings = lint_with_config(content, config.clone());
1905 assert_eq!(
1907 warnings.len(),
1908 2,
1909 "Should warn for each lazy continuation line. Got: {warnings:?}"
1910 );
1911
1912 let fixed = fix_with_config(content, config.clone());
1913 assert_eq!(
1915 fixed, "- Item 1\n Line 2\n Line 3",
1916 "Fix should add proper indentation to lazy continuation lines"
1917 );
1918
1919 let warnings_after = lint_with_config(&fixed, config);
1921 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1922 }
1923
1924 #[test]
1925 fn test_lazy_continuation_with_indented_content() {
1926 let content = "- Item 1\n Indented content\nLazy text";
1928 let config = MD032Config {
1929 allow_lazy_continuation: false,
1930 };
1931 let warnings = lint_with_config(content, config);
1932 assert_eq!(
1933 warnings.len(),
1934 1,
1935 "Should warn for lazy text after indented content. Got: {warnings:?}"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_lazy_continuation_properly_separated() {
1941 let content = "- Item 1\n\nSome text.";
1943 let config = MD032Config {
1944 allow_lazy_continuation: false,
1945 };
1946 let warnings = lint_with_config(content, config);
1947 assert_eq!(
1948 warnings.len(),
1949 0,
1950 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1951 );
1952 }
1953
1954 #[test]
1957 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1958 let content = "1) First item\nLazy continuation";
1960 let config = MD032Config {
1961 allow_lazy_continuation: false,
1962 };
1963 let warnings = lint_with_config(content, config.clone());
1964 assert_eq!(
1965 warnings.len(),
1966 1,
1967 "Should warn for lazy continuation with parenthesis marker"
1968 );
1969
1970 let fixed = fix_with_config(content, config);
1971 assert_eq!(fixed, "1) First item\n Lazy continuation");
1973 }
1974
1975 #[test]
1976 fn test_lazy_continuation_followed_by_another_list() {
1977 let content = "- Item 1\nSome text\n- Item 2";
1983 let config = MD032Config {
1984 allow_lazy_continuation: false,
1985 };
1986 let warnings = lint_with_config(content, config);
1987 assert_eq!(
1989 warnings.len(),
1990 1,
1991 "Should warn about lazy continuation within list. Got: {warnings:?}"
1992 );
1993 assert!(
1994 warnings[0].message.contains("Lazy continuation"),
1995 "Warning should be about lazy continuation"
1996 );
1997 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1998 }
1999
2000 #[test]
2001 fn test_lazy_continuation_multiple_in_document() {
2002 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2007 let config = MD032Config {
2008 allow_lazy_continuation: false,
2009 };
2010 let warnings = lint_with_config(content, config.clone());
2011 assert_eq!(
2013 warnings.len(),
2014 2,
2015 "Should warn for both lazy continuations. Got: {warnings:?}"
2016 );
2017
2018 let fixed = fix_with_config(content, config.clone());
2019 assert!(
2021 fixed.contains(" Lazy 1"),
2022 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2023 );
2024 assert!(
2025 fixed.contains(" Lazy 2"),
2026 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2027 );
2028
2029 let warnings_after = lint_with_config(&fixed, config);
2030 assert_eq!(
2032 warnings_after.len(),
2033 0,
2034 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2035 );
2036 }
2037
2038 #[test]
2039 fn test_lazy_continuation_end_of_document_no_newline() {
2040 let content = "- Item\nNo trailing newline";
2042 let config = MD032Config {
2043 allow_lazy_continuation: false,
2044 };
2045 let warnings = lint_with_config(content, config.clone());
2046 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2047
2048 let fixed = fix_with_config(content, config);
2049 assert_eq!(fixed, "- Item\n No trailing newline");
2051 }
2052
2053 #[test]
2054 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2055 let content = "- Item 1\n---";
2058 let config = MD032Config {
2059 allow_lazy_continuation: false,
2060 };
2061 let warnings = lint_with_config(content, config.clone());
2062 assert_eq!(
2064 warnings.len(),
2065 1,
2066 "List should need blank line before thematic break. Got: {warnings:?}"
2067 );
2068
2069 let fixed = fix_with_config(content, config);
2071 assert_eq!(fixed, "- Item 1\n\n---");
2072 }
2073
2074 #[test]
2075 fn test_lazy_continuation_heading_not_flagged() {
2076 let content = "- Item 1\n# Heading";
2079 let config = MD032Config {
2080 allow_lazy_continuation: false,
2081 };
2082 let warnings = lint_with_config(content, config);
2083 assert!(
2086 warnings.iter().all(|w| !w.message.contains("lazy")),
2087 "Heading should not trigger lazy continuation warning"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_lazy_continuation_mixed_list_types() {
2093 let content = "- Unordered\n1. Ordered\nLazy text";
2095 let config = MD032Config {
2096 allow_lazy_continuation: false,
2097 };
2098 let warnings = lint_with_config(content, config.clone());
2099 assert!(!warnings.is_empty(), "Should warn about structure issues");
2100 }
2101
2102 #[test]
2103 fn test_lazy_continuation_deep_nesting() {
2104 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2106 let config = MD032Config {
2107 allow_lazy_continuation: false,
2108 };
2109 let warnings = lint_with_config(content, config.clone());
2110 assert!(
2111 !warnings.is_empty(),
2112 "Should warn about lazy continuation after nested list"
2113 );
2114
2115 let fixed = fix_with_config(content, config.clone());
2116 let warnings_after = lint_with_config(&fixed, config);
2117 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2118 }
2119
2120 #[test]
2121 fn test_lazy_continuation_with_emphasis_in_text() {
2122 let content = "- Item\n*emphasized* continuation";
2124 let config = MD032Config {
2125 allow_lazy_continuation: false,
2126 };
2127 let warnings = lint_with_config(content, config.clone());
2128 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2129
2130 let fixed = fix_with_config(content, config);
2131 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2133 }
2134
2135 #[test]
2136 fn test_lazy_continuation_with_code_span() {
2137 let content = "- Item\n`code` continuation";
2139 let config = MD032Config {
2140 allow_lazy_continuation: false,
2141 };
2142 let warnings = lint_with_config(content, config.clone());
2143 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2144
2145 let fixed = fix_with_config(content, config);
2146 assert_eq!(fixed, "- Item\n `code` continuation");
2148 }
2149
2150 #[test]
2157 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2158 let content = r#"1. Create a new Chat conversation:
2161 - On the sidebar, select **New Chat**.
2162 - In the box, type `/new`.
2163 A new Chat conversation replaces the previous one.
21641. Under the Chat text box, turn off the toggle."#;
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 after nested bullets. Got: {warnings:?}"
2177 );
2178 assert!(
2179 lazy_warnings.iter().any(|w| w.line == 4),
2180 "Should warn on line 4. Got: {lazy_warnings:?}"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2186 let content = r#"- `field`: Is the specific key:
2189 - `password`: Accesses the password.
2190 - `api_key`: Accesses the api_key.
2191 `token`: Specifies which ID token to use.
2192- `version_id`: Is the unique identifier."#;
2193 let config = MD032Config {
2194 allow_lazy_continuation: false,
2195 };
2196 let warnings = lint_with_config(content, config);
2197 let lazy_warnings: Vec<_> = warnings
2199 .iter()
2200 .filter(|w| w.message.contains("Lazy continuation"))
2201 .collect();
2202 assert!(
2203 !lazy_warnings.is_empty(),
2204 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2205 );
2206 assert!(
2207 lazy_warnings.iter().any(|w| w.line == 4),
2208 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2209 );
2210 }
2211
2212 #[test]
2213 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2214 let content = r#"- Check out the branch, and test locally.
2216 - If the MR requires significant modifications:
2217 - **Skip local testing** and review instead.
2218 - **Request verification** from the author.
2219 - **Identify the minimal change** needed.
2220 Your testing might result in opportunities.
2221- If you don't understand, _say so_."#;
2222 let config = MD032Config {
2223 allow_lazy_continuation: false,
2224 };
2225 let warnings = lint_with_config(content, config);
2226 let lazy_warnings: Vec<_> = warnings
2228 .iter()
2229 .filter(|w| w.message.contains("Lazy continuation"))
2230 .collect();
2231 assert!(
2232 !lazy_warnings.is_empty(),
2233 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2234 );
2235 assert!(
2236 lazy_warnings.iter().any(|w| w.line == 6),
2237 "Should warn on line 6. Got: {lazy_warnings:?}"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_issue295_ordered_list_nested_bullets_continuation() {
2243 let content = r#"# Test
2246
22471. First item.
2248 - Nested A.
2249 - Nested B.
2250 Continuation at outer level.
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!(
2262 !lazy_warnings.is_empty(),
2263 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2264 );
2265 assert!(
2267 lazy_warnings.iter().any(|w| w.line == 6),
2268 "Should warn on line 6. Got: {lazy_warnings:?}"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_issue295_multiple_lazy_lines_after_nested() {
2274 let content = r#"1. The device client receives a response.
2276 - Those defined by OAuth Framework.
2277 - Those specific to device authorization.
2278 Those error responses are described below.
2279 For more information on each response,
2280 see the documentation.
22811. Next step in the process."#;
2282 let config = MD032Config {
2283 allow_lazy_continuation: false,
2284 };
2285 let warnings = lint_with_config(content, config);
2286 let lazy_warnings: Vec<_> = warnings
2288 .iter()
2289 .filter(|w| w.message.contains("Lazy continuation"))
2290 .collect();
2291 assert!(
2292 lazy_warnings.len() >= 3,
2293 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2294 lazy_warnings.len()
2295 );
2296 }
2297
2298 #[test]
2299 fn test_issue295_properly_indented_not_lazy() {
2300 let content = r#"1. First item.
2302 - Nested A.
2303 - Nested B.
2304
2305 Properly indented continuation.
23061. Second item."#;
2307 let config = MD032Config {
2308 allow_lazy_continuation: false,
2309 };
2310 let warnings = lint_with_config(content, config);
2311 let lazy_warnings: Vec<_> = warnings
2313 .iter()
2314 .filter(|w| w.message.contains("Lazy continuation"))
2315 .collect();
2316 assert_eq!(
2317 lazy_warnings.len(),
2318 0,
2319 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2320 );
2321 }
2322
2323 #[test]
2330 fn test_html_comment_before_list_with_preceding_blank() {
2331 let content = "Some text.\n\n<!-- comment -->\n- List item";
2334 let warnings = lint(content);
2335 assert_eq!(
2336 warnings.len(),
2337 0,
2338 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_html_comment_after_list_with_following_blank() {
2344 let content = "- List item\n<!-- comment -->\n\nSome text.";
2346 let warnings = lint(content);
2347 assert_eq!(
2348 warnings.len(),
2349 0,
2350 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_list_inside_html_comment_ignored() {
2356 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2358 let warnings = lint(content);
2359 assert_eq!(
2360 warnings.len(),
2361 0,
2362 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2363 );
2364 }
2365
2366 #[test]
2367 fn test_multiline_html_comment_before_list() {
2368 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2370 let warnings = lint(content);
2371 assert_eq!(
2372 warnings.len(),
2373 0,
2374 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_no_blank_before_html_comment_still_warns() {
2380 let content = "Some text.\n<!-- comment -->\n- List item";
2382 let warnings = lint(content);
2383 assert_eq!(
2384 warnings.len(),
2385 1,
2386 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2387 );
2388 assert!(
2389 warnings[0].message.contains("preceded by blank line"),
2390 "Should be 'preceded by blank line' warning"
2391 );
2392 }
2393
2394 #[test]
2395 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2396 let content = "- List item\n<!-- comment -->\nSome text.";
2399 let warnings = lint(content);
2400 assert_eq!(
2401 warnings.len(),
2402 0,
2403 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2404 );
2405 }
2406
2407 #[test]
2408 fn test_list_followed_by_heading_through_comment_should_warn() {
2409 let content = "- List item\n<!-- comment -->\n# Heading";
2411 let warnings = lint(content);
2412 assert!(
2415 warnings.len() <= 1,
2416 "Should handle heading after comment gracefully. Got: {warnings:?}"
2417 );
2418 }
2419
2420 #[test]
2421 fn test_html_comment_between_list_and_text_both_directions() {
2422 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2424 let warnings = lint(content);
2425 assert_eq!(
2426 warnings.len(),
2427 0,
2428 "Should not warn with proper separation through comments. Got: {warnings:?}"
2429 );
2430 }
2431
2432 #[test]
2433 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2434 let content = "Text.\n\n<!-- comment -->\n- Item";
2436 let fixed = fix(content);
2437 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2438 }
2439
2440 #[test]
2441 fn test_html_comment_fix_adds_blank_when_needed() {
2442 let content = "Text.\n<!-- comment -->\n- Item";
2445 let fixed = fix(content);
2446 assert!(
2447 fixed.contains("<!-- comment -->\n\n- Item"),
2448 "Fix should add blank line before list. Got: {fixed}"
2449 );
2450 }
2451
2452 #[test]
2453 fn test_ordered_list_inside_html_comment() {
2454 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2456 let warnings = lint(content);
2457 assert_eq!(
2458 warnings.len(),
2459 0,
2460 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2461 );
2462 }
2463
2464 #[test]
2471 fn test_blockquote_list_exit_no_warning() {
2472 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2474 let warnings = lint(content);
2475 assert_eq!(
2476 warnings.len(),
2477 0,
2478 "Should not warn when exiting blockquote. Got: {warnings:?}"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_nested_blockquote_list_exit() {
2484 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2486 let warnings = lint(content);
2487 assert_eq!(
2488 warnings.len(),
2489 0,
2490 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2491 );
2492 }
2493
2494 #[test]
2495 fn test_blockquote_same_level_no_warning() {
2496 let content = "> - item 1\n> - item 2\n> Text after";
2499 let warnings = lint(content);
2500 assert_eq!(
2501 warnings.len(),
2502 0,
2503 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2504 );
2505 }
2506
2507 #[test]
2508 fn test_blockquote_list_with_special_chars() {
2509 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2511 let warnings = lint(content);
2512 assert_eq!(
2513 warnings.len(),
2514 0,
2515 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2516 );
2517 }
2518
2519 #[test]
2520 fn test_lazy_continuation_whitespace_only_line() {
2521 let content = "- Item\n \nText after whitespace-only line";
2524 let config = MD032Config {
2525 allow_lazy_continuation: false,
2526 };
2527 let warnings = lint_with_config(content, config);
2528 assert_eq!(
2530 warnings.len(),
2531 0,
2532 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2533 );
2534 }
2535
2536 #[test]
2537 fn test_lazy_continuation_blockquote_context() {
2538 let content = "> - Item\n> Lazy in quote";
2540 let config = MD032Config {
2541 allow_lazy_continuation: false,
2542 };
2543 let warnings = lint_with_config(content, config);
2544 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2547 }
2548
2549 #[test]
2550 fn test_lazy_continuation_fix_preserves_content() {
2551 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2553 let config = MD032Config {
2554 allow_lazy_continuation: false,
2555 };
2556 let fixed = fix_with_config(content, config);
2557 assert!(fixed.contains("<>&"), "Should preserve special chars");
2558 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2559 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2561 }
2562
2563 #[test]
2564 fn test_lazy_continuation_fix_idempotent() {
2565 let content = "- Item\nLazy";
2567 let config = MD032Config {
2568 allow_lazy_continuation: false,
2569 };
2570 let fixed_once = fix_with_config(content, config.clone());
2571 let fixed_twice = fix_with_config(&fixed_once, config);
2572 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2573 }
2574
2575 #[test]
2576 fn test_lazy_continuation_config_default_allows() {
2577 let content = "- Item\nLazy text that continues";
2579 let default_config = MD032Config::default();
2580 assert!(
2581 default_config.allow_lazy_continuation,
2582 "Default should allow lazy continuation"
2583 );
2584 let warnings = lint_with_config(content, default_config);
2585 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2586 }
2587
2588 #[test]
2589 fn test_lazy_continuation_after_multi_line_item() {
2590 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2592 let config = MD032Config {
2593 allow_lazy_continuation: false,
2594 };
2595 let warnings = lint_with_config(content, config.clone());
2596 assert_eq!(
2597 warnings.len(),
2598 1,
2599 "Should warn only for the lazy line, not the indented line"
2600 );
2601 }
2602
2603 #[test]
2605 fn test_blockquote_list_with_continuation_and_nested() {
2606 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2609 let warnings = lint(content);
2610 assert_eq!(
2611 warnings.len(),
2612 0,
2613 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2614 );
2615 }
2616
2617 #[test]
2618 fn test_blockquote_list_simple() {
2619 let content = "> - item 1\n> - item 2";
2621 let warnings = lint(content);
2622 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2623 }
2624
2625 #[test]
2626 fn test_blockquote_list_with_continuation_only() {
2627 let content = "> - item 1\n> continuation\n> - item 2";
2629 let warnings = lint(content);
2630 assert_eq!(
2631 warnings.len(),
2632 0,
2633 "Blockquoted list with continuation should have no warnings"
2634 );
2635 }
2636
2637 #[test]
2638 fn test_blockquote_list_with_lazy_continuation() {
2639 let content = "> - item 1\n> lazy continuation\n> - item 2";
2641 let warnings = lint(content);
2642 assert_eq!(
2643 warnings.len(),
2644 0,
2645 "Blockquoted list with lazy continuation should have no warnings"
2646 );
2647 }
2648
2649 #[test]
2650 fn test_nested_blockquote_list() {
2651 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2653 let warnings = lint(content);
2654 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2655 }
2656
2657 #[test]
2658 fn test_blockquote_list_needs_preceding_blank() {
2659 let content = "> Text before\n> - item 1\n> - item 2";
2661 let warnings = lint(content);
2662 assert_eq!(
2663 warnings.len(),
2664 1,
2665 "Should warn for missing blank before blockquoted list"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_blockquote_list_properly_separated() {
2671 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2673 let warnings = lint(content);
2674 assert_eq!(
2675 warnings.len(),
2676 0,
2677 "Properly separated blockquoted list should have no warnings"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_blockquote_ordered_list() {
2683 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2685 let warnings = lint(content);
2686 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2687 }
2688
2689 #[test]
2690 fn test_blockquote_list_with_empty_blockquote_line() {
2691 let content = "> - item 1\n>\n> - item 2";
2693 let warnings = lint(content);
2694 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2695 }
2696
2697 #[test]
2699 fn test_blockquote_list_multi_paragraph_items() {
2700 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2703 let warnings = lint(content);
2704 assert_eq!(
2705 warnings.len(),
2706 0,
2707 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2708 );
2709 }
2710
2711 #[test]
2713 fn test_blockquote_ordered_list_multi_paragraph_items() {
2714 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2715 let warnings = lint(content);
2716 assert_eq!(
2717 warnings.len(),
2718 0,
2719 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2720 );
2721 }
2722
2723 #[test]
2725 fn test_blockquote_list_multiple_continuations() {
2726 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2727 let warnings = lint(content);
2728 assert_eq!(
2729 warnings.len(),
2730 0,
2731 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2732 );
2733 }
2734
2735 #[test]
2737 fn test_nested_blockquote_multi_paragraph_list() {
2738 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2739 let warnings = lint(content);
2740 assert_eq!(
2741 warnings.len(),
2742 0,
2743 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2744 );
2745 }
2746
2747 #[test]
2749 fn test_triple_nested_blockquote_multi_paragraph_list() {
2750 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2751 let warnings = lint(content);
2752 assert_eq!(
2753 warnings.len(),
2754 0,
2755 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2756 );
2757 }
2758
2759 #[test]
2761 fn test_blockquote_list_last_item_continuation() {
2762 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2763 let warnings = lint(content);
2764 assert_eq!(
2765 warnings.len(),
2766 0,
2767 "Last item with continuation should have no warnings. Got: {warnings:?}"
2768 );
2769 }
2770
2771 #[test]
2773 fn test_blockquote_list_first_item_only_continuation() {
2774 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2775 let warnings = lint(content);
2776 assert_eq!(
2777 warnings.len(),
2778 0,
2779 "Single item with continuation should have no warnings. Got: {warnings:?}"
2780 );
2781 }
2782
2783 #[test]
2787 fn test_blockquote_level_change_breaks_list() {
2788 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2790 let warnings = lint(content);
2791 assert!(
2795 warnings.len() <= 2,
2796 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2797 );
2798 }
2799
2800 #[test]
2802 fn test_exit_blockquote_needs_blank_before_list() {
2803 let content = "> Blockquote text\n\n- List outside blockquote\n";
2805 let warnings = lint(content);
2806 assert_eq!(
2807 warnings.len(),
2808 0,
2809 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2810 );
2811
2812 let content2 = "> Blockquote text\n- List outside blockquote\n";
2816 let warnings2 = lint(content2);
2817 assert!(
2819 warnings2.len() <= 1,
2820 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2821 );
2822 }
2823
2824 #[test]
2826 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2827 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2829 let warnings = lint(content_dash);
2830 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2831
2832 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2834 let warnings = lint(content_asterisk);
2835 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2836
2837 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2839 let warnings = lint(content_plus);
2840 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2841 }
2842
2843 #[test]
2845 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2846 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2847 let warnings = lint(content);
2848 assert_eq!(
2849 warnings.len(),
2850 0,
2851 "Parenthesis ordered markers should work. Got: {warnings:?}"
2852 );
2853 }
2854
2855 #[test]
2857 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2858 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2860 let warnings = lint(content);
2861 assert_eq!(
2862 warnings.len(),
2863 0,
2864 "Multi-digit ordered list should work. Got: {warnings:?}"
2865 );
2866 }
2867
2868 #[test]
2870 fn test_blockquote_multi_paragraph_with_formatting() {
2871 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2872 let warnings = lint(content);
2873 assert_eq!(
2874 warnings.len(),
2875 0,
2876 "Continuation with inline formatting should work. Got: {warnings:?}"
2877 );
2878 }
2879
2880 #[test]
2882 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2883 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2884 let warnings = lint(content);
2885 assert_eq!(
2886 warnings.len(),
2887 0,
2888 "All items with continuations should work. Got: {warnings:?}"
2889 );
2890 }
2891
2892 #[test]
2894 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2895 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2896 let warnings = lint(content);
2897 assert_eq!(
2898 warnings.len(),
2899 0,
2900 "Lowercase continuation should work. Got: {warnings:?}"
2901 );
2902 }
2903
2904 #[test]
2906 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2907 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2908 let warnings = lint(content);
2909 assert_eq!(
2910 warnings.len(),
2911 0,
2912 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2913 );
2914 }
2915
2916 #[test]
2918 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2919 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2921 let warnings = lint(content);
2922 assert!(
2924 warnings.len() <= 1,
2925 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2926 );
2927 }
2928
2929 #[test]
2931 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2932 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2934 let warnings = lint(content);
2935 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2936 }
2937
2938 #[test]
2939 fn test_blockquote_list_varying_spaces_after_marker() {
2940 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2942 let warnings = lint(content);
2943 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2944 }
2945
2946 #[test]
2947 fn test_deeply_nested_blockquote_list() {
2948 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2950 let warnings = lint(content);
2951 assert_eq!(
2952 warnings.len(),
2953 0,
2954 "Deeply nested blockquote list should have no warnings"
2955 );
2956 }
2957
2958 #[test]
2959 fn test_blockquote_level_change_in_list() {
2960 let content = "> - item 1\n>> - deeper item\n> - item 2";
2962 let warnings = lint(content);
2965 assert!(
2966 !warnings.is_empty(),
2967 "Blockquote level change should break list and trigger warnings"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_blockquote_list_with_code_span() {
2973 let content = "> - item with `code`\n> continuation\n> - item 2";
2975 let warnings = lint(content);
2976 assert_eq!(
2977 warnings.len(),
2978 0,
2979 "Blockquote list with code span should have no warnings"
2980 );
2981 }
2982
2983 #[test]
2984 fn test_blockquote_list_at_document_end() {
2985 let content = "> Some text\n>\n> - item 1\n> - item 2";
2987 let warnings = lint(content);
2988 assert_eq!(
2989 warnings.len(),
2990 0,
2991 "Blockquote list at document end should have no warnings"
2992 );
2993 }
2994
2995 #[test]
2996 fn test_fix_preserves_blockquote_prefix_before_list() {
2997 let content = "> Text before
2999> - Item 1
3000> - Item 2";
3001 let fixed = fix(content);
3002
3003 let expected = "> Text before
3005>
3006> - Item 1
3007> - Item 2";
3008 assert_eq!(
3009 fixed, expected,
3010 "Fix should insert '>' blank line, not plain blank line"
3011 );
3012 }
3013
3014 #[test]
3015 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3016 let content = ">>> Triple nested
3019>>> - Item 1
3020>>> - Item 2
3021>>> More text";
3022 let fixed = fix(content);
3023
3024 let expected = ">>> Triple nested
3026>>>
3027>>> - Item 1
3028>>> - Item 2
3029>>> More text";
3030 assert_eq!(
3031 fixed, expected,
3032 "Fix should preserve triple-nested blockquote prefix '>>>'"
3033 );
3034 }
3035
3036 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3039 let rule = MD032BlanksAroundLists::default();
3040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3041 rule.check(&ctx).unwrap()
3042 }
3043
3044 #[test]
3045 fn test_quarto_list_after_div_open() {
3046 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3048 let warnings = lint_quarto(content);
3049 assert!(
3051 warnings.is_empty(),
3052 "Quarto div marker should be transparent before list: {warnings:?}"
3053 );
3054 }
3055
3056 #[test]
3057 fn test_quarto_list_before_div_close() {
3058 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3060 let warnings = lint_quarto(content);
3061 assert!(
3063 warnings.is_empty(),
3064 "Quarto div marker should be transparent after list: {warnings:?}"
3065 );
3066 }
3067
3068 #[test]
3069 fn test_quarto_list_needs_blank_without_div() {
3070 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3072 let warnings = lint_quarto(content);
3073 assert!(
3076 !warnings.is_empty(),
3077 "Should still require blank when not present: {warnings:?}"
3078 );
3079 }
3080
3081 #[test]
3082 fn test_quarto_list_in_callout_with_content() {
3083 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3085 let warnings = lint_quarto(content);
3086 assert!(
3087 warnings.is_empty(),
3088 "List with proper blanks inside callout should pass: {warnings:?}"
3089 );
3090 }
3091
3092 #[test]
3093 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3094 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3096 let warnings = lint(content); assert!(
3099 !warnings.is_empty(),
3100 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3101 );
3102 }
3103
3104 #[test]
3105 fn test_quarto_nested_divs_with_list() {
3106 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3108 let warnings = lint_quarto(content);
3109 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3110 }
3111
3112 #[test]
3113 fn test_issue512_complex_nested_list_with_continuation() {
3114 let content = "\
3117- First level of indentation.
3118 - Second level of indentation.
3119 - Third level of indentation.
3120 - Third level of indentation.
3121
3122 Second level list continuation.
3123
3124 First level list continuation.
3125- First level of indentation.
3126";
3127 let warnings = lint(content);
3128 assert!(
3129 warnings.is_empty(),
3130 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3131 );
3132 }
3133
3134 #[test]
3135 fn test_issue512_continuation_at_root_level() {
3136 let content = "\
3140- First level.
3141 - Second level.
3142
3143 First level continuation.
3144
3145Root level lazy continuation.
3146- Another first level item.
3147";
3148 let warnings = lint(content);
3149 assert_eq!(
3150 warnings.len(),
3151 1,
3152 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3153 );
3154 assert_eq!(warnings[0].line, 7);
3155 }
3156
3157 #[test]
3158 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3159 let content = "\
3161- Level 1 item.
3162 - Level 2 item.
3163 - Level 3 item.
3164
3165 Level 3 continuation.
3166
3167 Level 2 continuation.
3168
3169 Level 1 continuation (indented under marker).
3170- Another level 1 item.
3171";
3172 let warnings = lint(content);
3173 assert!(
3174 warnings.is_empty(),
3175 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3176 );
3177 }
3178}