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 let is_tight_continuation_of_last_item = ctx
711 .lines
712 .get(end_line - 1)
713 .and_then(|last_li| last_li.list_item.as_ref())
714 .is_some_and(|last_item| {
715 let marker_col = last_item.marker_column;
716 ctx.lines.get(content_line - 1).is_some_and(|next_li| {
717 !next_li.is_blank && next_li.list_item.is_none() && next_li.indent > marker_col
718 })
719 });
720
721 if !is_next_excluded && prefixes_match && !exits_blockquote && !is_tight_continuation_of_last_item {
724 let (start_line_last, start_col_last, end_line_last, end_col_last) =
726 calculate_line_range(end_line, lines[end_line - 1]);
727
728 warnings.push(LintWarning {
729 line: start_line_last,
730 column: start_col_last,
731 end_line: end_line_last,
732 end_column: end_col_last,
733 severity: Severity::Warning,
734 rule_name: Some(self.name().to_string()),
735 message: "List should be followed by blank line".to_string(),
736 fix: Some(Fix {
737 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
738 replacement: format!("{prefix}\n"),
739 }),
740 });
741 }
742 }
743 }
744 }
745 warnings
746 }
747}
748
749impl Rule for MD032BlanksAroundLists {
750 fn name(&self) -> &'static str {
751 "MD032"
752 }
753
754 fn description(&self) -> &'static str {
755 "Lists should be surrounded by blank lines"
756 }
757
758 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
759 let lines = ctx.raw_lines();
760 let line_index = &ctx.line_index;
761
762 if lines.is_empty() {
764 return Ok(Vec::new());
765 }
766
767 let list_blocks = self.convert_list_blocks(ctx);
768
769 if list_blocks.is_empty() {
770 return Ok(Vec::new());
771 }
772
773 let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index);
774
775 if !self.config.allow_lazy_continuation {
780 let lazy_cont_lines = ctx.lazy_continuation_lines();
781
782 for lazy_info in lazy_cont_lines.iter() {
783 let line_num = lazy_info.line_num;
784
785 let is_within_block = list_blocks
789 .iter()
790 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
791
792 if !is_within_block {
793 continue;
794 }
795
796 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
798 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
799
800 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
802 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
803 } else {
804 None
805 };
806
807 warnings.push(LintWarning {
808 line: start_line,
809 column: start_col,
810 end_line,
811 end_column: end_col,
812 severity: Severity::Warning,
813 rule_name: Some(self.name().to_string()),
814 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
815 fix,
816 });
817 }
818 }
819
820 Ok(warnings)
821 }
822
823 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
824 Ok(self.fix_with_structure_impl(ctx))
825 }
826
827 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
828 ctx.content.is_empty() || ctx.list_blocks.is_empty()
831 }
832
833 fn category(&self) -> RuleCategory {
834 RuleCategory::List
835 }
836
837 fn as_any(&self) -> &dyn std::any::Any {
838 self
839 }
840
841 fn default_config_section(&self) -> Option<(String, toml::Value)> {
842 use crate::rule_config_serde::RuleConfig;
843 let default_config = MD032Config::default();
844 let json_value = serde_json::to_value(&default_config).ok()?;
845 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
846
847 if let toml::Value::Table(table) = toml_value {
848 if !table.is_empty() {
849 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
850 } else {
851 None
852 }
853 } else {
854 None
855 }
856 }
857
858 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
859 where
860 Self: Sized,
861 {
862 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
863 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
864 }
865}
866
867impl MD032BlanksAroundLists {
868 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> String {
870 let lines = ctx.raw_lines();
871 let num_lines = lines.len();
872 if num_lines == 0 {
873 return String::new();
874 }
875
876 let list_blocks = self.convert_list_blocks(ctx);
877 if list_blocks.is_empty() {
878 return ctx.content.to_string();
879 }
880
881 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
884 if !self.config.allow_lazy_continuation {
885 let lazy_cont_lines = ctx.lazy_continuation_lines();
886 for lazy_info in lazy_cont_lines.iter() {
887 let line_num = lazy_info.line_num;
888 let is_within_block = list_blocks
890 .iter()
891 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
892 if !is_within_block {
893 continue;
894 }
895 if !Self::should_apply_lazy_fix(ctx, line_num) {
897 continue;
898 }
899 lazy_fixes.insert(line_num, lazy_info.clone());
900 }
901 }
902
903 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
904
905 for &(start_line, end_line, ref prefix) in &list_blocks {
907 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
909 continue;
910 }
911
912 if ctx
914 .line_info(start_line)
915 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
916 {
917 continue;
918 }
919
920 if start_line > 1 {
922 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
924
925 if !has_blank_separation && content_line > 0 {
927 let prev_line_str = lines[content_line - 1];
928 let is_prev_excluded = ctx
929 .line_info(content_line)
930 .is_some_and(|info| info.in_code_block || info.in_front_matter);
931 let prev_prefix = BLOCKQUOTE_PREFIX_RE
932 .find(prev_line_str)
933 .map_or(String::new(), |m| m.as_str().to_string());
934
935 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
936 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
938 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
940 insertions.insert(start_line, bq_prefix);
941 }
942 }
943 }
944
945 if end_line < num_lines {
947 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
949
950 if !has_blank_separation && content_line > 0 {
952 let next_line_str = lines[content_line - 1];
953 let is_next_excluded = ctx
955 .line_info(content_line)
956 .is_some_and(|info| info.in_code_block || info.in_front_matter)
957 || (content_line <= ctx.lines.len()
958 && ctx.lines[content_line - 1].in_code_block
959 && ctx.lines[content_line - 1].indent >= 2
960 && (ctx.lines[content_line - 1]
961 .content(ctx.content)
962 .trim()
963 .starts_with("```")
964 || ctx.lines[content_line - 1]
965 .content(ctx.content)
966 .trim()
967 .starts_with("~~~")));
968 let next_prefix = BLOCKQUOTE_PREFIX_RE
969 .find(next_line_str)
970 .map_or(String::new(), |m| m.as_str().to_string());
971
972 let end_line_str = lines[end_line - 1];
974 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
975 .find(end_line_str)
976 .map_or(String::new(), |m| m.as_str().to_string());
977 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
978 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
979 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
980
981 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
984 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
986 insertions.insert(end_line + 1, bq_prefix);
987 }
988 }
989 }
990 }
991
992 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
994 for (i, line) in lines.iter().enumerate() {
995 let current_line_num = i + 1;
996 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
997 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
998 {
999 result_lines.push(prefix_to_insert.clone());
1000 }
1001
1002 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
1004 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
1005 {
1006 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
1007 result_lines.push(fixed_line);
1008 } else {
1009 result_lines.push(line.to_string());
1010 }
1011 }
1012
1013 let mut result = result_lines.join("\n");
1015 if ctx.content.ends_with('\n') {
1016 result.push('\n');
1017 }
1018 result
1019 }
1020}
1021
1022fn is_blank_in_context(line: &str) -> bool {
1024 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1027 line[m.end()..].trim().is_empty()
1029 } else {
1030 line.trim().is_empty()
1032 }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use crate::lint_context::LintContext;
1039 use crate::rule::Rule;
1040
1041 fn lint(content: &str) -> Vec<LintWarning> {
1042 let rule = MD032BlanksAroundLists::default();
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 rule.check(&ctx).expect("Lint check failed")
1045 }
1046
1047 fn fix(content: &str) -> String {
1048 let rule = MD032BlanksAroundLists::default();
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 rule.fix(&ctx).expect("Lint fix failed")
1051 }
1052
1053 fn check_warnings_have_fixes(content: &str) {
1055 let warnings = lint(content);
1056 for warning in &warnings {
1057 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1058 }
1059 }
1060
1061 #[test]
1062 fn test_list_at_start() {
1063 let content = "- Item 1\n- Item 2\nText";
1066 let warnings = lint(content);
1067 assert_eq!(
1068 warnings.len(),
1069 0,
1070 "Trailing text is lazy continuation per CommonMark - no warning expected"
1071 );
1072 }
1073
1074 #[test]
1075 fn test_list_at_end() {
1076 let content = "Text\n- Item 1\n- Item 2";
1077 let warnings = lint(content);
1078 assert_eq!(
1079 warnings.len(),
1080 1,
1081 "Expected 1 warning for list at end without preceding blank line"
1082 );
1083 assert_eq!(
1084 warnings[0].line, 2,
1085 "Warning should be on the first line of the list (line 2)"
1086 );
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- Item 2");
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_in_middle() {
1102 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1105 let warnings = lint(content);
1106 assert_eq!(
1107 warnings.len(),
1108 1,
1109 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1110 );
1111 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1112 assert!(warnings[0].message.contains("preceded by blank line"));
1113
1114 check_warnings_have_fixes(content);
1116
1117 let fixed_content = fix(content);
1118 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1119
1120 let warnings_after_fix = lint(&fixed_content);
1122 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1123 }
1124
1125 #[test]
1126 fn test_correct_spacing() {
1127 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1128 let warnings = lint(content);
1129 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1130
1131 let fixed_content = fix(content);
1132 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1133 }
1134
1135 #[test]
1136 fn test_list_with_content() {
1137 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1140 let warnings = lint(content);
1141 assert_eq!(
1142 warnings.len(),
1143 1,
1144 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1145 );
1146 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1147 assert!(warnings[0].message.contains("preceded by blank line"));
1148
1149 check_warnings_have_fixes(content);
1151
1152 let fixed_content = fix(content);
1153 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1154 assert_eq!(
1155 fixed_content, expected_fixed,
1156 "Fix did not produce the expected output. Got:\n{fixed_content}"
1157 );
1158
1159 let warnings_after_fix = lint(&fixed_content);
1161 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1162 }
1163
1164 #[test]
1165 fn test_nested_list() {
1166 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1168 let warnings = lint(content);
1169 assert_eq!(
1170 warnings.len(),
1171 1,
1172 "Nested list block needs preceding blank only. Got: {warnings:?}"
1173 );
1174 assert_eq!(warnings[0].line, 2);
1175 assert!(warnings[0].message.contains("preceded by blank line"));
1176
1177 check_warnings_have_fixes(content);
1179
1180 let fixed_content = fix(content);
1181 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1182
1183 let warnings_after_fix = lint(&fixed_content);
1185 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1186 }
1187
1188 #[test]
1189 fn test_list_with_internal_blanks() {
1190 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1192 let warnings = lint(content);
1193 assert_eq!(
1194 warnings.len(),
1195 1,
1196 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1197 );
1198 assert_eq!(warnings[0].line, 2);
1199 assert!(warnings[0].message.contains("preceded by blank line"));
1200
1201 check_warnings_have_fixes(content);
1203
1204 let fixed_content = fix(content);
1205 assert_eq!(
1206 fixed_content,
1207 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1208 );
1209
1210 let warnings_after_fix = lint(&fixed_content);
1212 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1213 }
1214
1215 #[test]
1216 fn test_ignore_code_blocks() {
1217 let content = "```\n- Not a list item\n```\nText";
1218 let warnings = lint(content);
1219 assert_eq!(warnings.len(), 0);
1220 let fixed_content = fix(content);
1221 assert_eq!(fixed_content, content);
1222 }
1223
1224 #[test]
1225 fn test_ignore_front_matter() {
1226 let content = "---\ntitle: Test\n---\n- List Item\nText";
1228 let warnings = lint(content);
1229 assert_eq!(
1230 warnings.len(),
1231 0,
1232 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1233 );
1234
1235 let fixed_content = fix(content);
1237 assert_eq!(fixed_content, content, "No changes when no warnings");
1238 }
1239
1240 #[test]
1241 fn test_multiple_lists() {
1242 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1247 let warnings = lint(content);
1248 assert!(
1250 !warnings.is_empty(),
1251 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1252 );
1253
1254 check_warnings_have_fixes(content);
1256
1257 let fixed_content = fix(content);
1258 let warnings_after_fix = lint(&fixed_content);
1260 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1261 }
1262
1263 #[test]
1264 fn test_adjacent_lists() {
1265 let content = "- List 1\n\n* List 2";
1266 let warnings = lint(content);
1267 assert_eq!(warnings.len(), 0);
1268 let fixed_content = fix(content);
1269 assert_eq!(fixed_content, content);
1270 }
1271
1272 #[test]
1273 fn test_list_in_blockquote() {
1274 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1276 let warnings = lint(content);
1277 assert_eq!(
1278 warnings.len(),
1279 1,
1280 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1281 );
1282 assert_eq!(warnings[0].line, 2);
1283
1284 check_warnings_have_fixes(content);
1286
1287 let fixed_content = fix(content);
1288 assert_eq!(
1290 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1291 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1292 );
1293
1294 let warnings_after_fix = lint(&fixed_content);
1296 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1297 }
1298
1299 #[test]
1300 fn test_ordered_list() {
1301 let content = "Text\n1. Item 1\n2. Item 2\nText";
1303 let warnings = lint(content);
1304 assert_eq!(warnings.len(), 1);
1305
1306 check_warnings_have_fixes(content);
1308
1309 let fixed_content = fix(content);
1310 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1311
1312 let warnings_after_fix = lint(&fixed_content);
1314 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1315 }
1316
1317 #[test]
1318 fn test_no_double_blank_fix() {
1319 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1322 assert_eq!(
1323 warnings.len(),
1324 0,
1325 "Should have no warnings - properly preceded, trailing is lazy"
1326 );
1327
1328 let fixed_content = fix(content);
1329 assert_eq!(
1330 fixed_content, content,
1331 "No fix needed when no warnings. Got:\n{fixed_content}"
1332 );
1333
1334 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1336 assert_eq!(warnings2.len(), 1);
1337 if !warnings2.is_empty() {
1338 assert_eq!(
1339 warnings2[0].line, 2,
1340 "Warning line for missing blank before should be the first line of the block"
1341 );
1342 }
1343
1344 check_warnings_have_fixes(content2);
1346
1347 let fixed_content2 = fix(content2);
1348 assert_eq!(
1349 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1350 "Fix added extra blank before. Got:\n{fixed_content2}"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_empty_input() {
1356 let content = "";
1357 let warnings = lint(content);
1358 assert_eq!(warnings.len(), 0);
1359 let fixed_content = fix(content);
1360 assert_eq!(fixed_content, "");
1361 }
1362
1363 #[test]
1364 fn test_only_list() {
1365 let content = "- Item 1\n- Item 2";
1366 let warnings = lint(content);
1367 assert_eq!(warnings.len(), 0);
1368 let fixed_content = fix(content);
1369 assert_eq!(fixed_content, content);
1370 }
1371
1372 #[test]
1375 fn test_fix_complex_nested_blockquote() {
1376 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1378 let warnings = lint(content);
1379 assert_eq!(
1380 warnings.len(),
1381 1,
1382 "Should warn for missing preceding blank only. Got: {warnings:?}"
1383 );
1384
1385 check_warnings_have_fixes(content);
1387
1388 let fixed_content = fix(content);
1389 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1391 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1392
1393 let warnings_after_fix = lint(&fixed_content);
1394 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1395 }
1396
1397 #[test]
1398 fn test_fix_mixed_list_markers() {
1399 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1402 let warnings = lint(content);
1403 assert!(
1405 !warnings.is_empty(),
1406 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1407 );
1408
1409 check_warnings_have_fixes(content);
1411
1412 let fixed_content = fix(content);
1413 assert!(
1415 fixed_content.contains("Text\n\n-"),
1416 "Fix should add blank line before first list item"
1417 );
1418
1419 let warnings_after_fix = lint(&fixed_content);
1421 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1422 }
1423
1424 #[test]
1425 fn test_fix_ordered_list_with_different_numbers() {
1426 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1428 let warnings = lint(content);
1429 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1430
1431 check_warnings_have_fixes(content);
1433
1434 let fixed_content = fix(content);
1435 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1436 assert_eq!(
1437 fixed_content, expected,
1438 "Fix should handle ordered lists with non-sequential numbers"
1439 );
1440
1441 let warnings_after_fix = lint(&fixed_content);
1443 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1444 }
1445
1446 #[test]
1447 fn test_fix_list_with_code_blocks_inside() {
1448 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1450 let warnings = lint(content);
1451 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1452
1453 check_warnings_have_fixes(content);
1455
1456 let fixed_content = fix(content);
1457 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1458 assert_eq!(
1459 fixed_content, expected,
1460 "Fix should handle lists with internal code blocks"
1461 );
1462
1463 let warnings_after_fix = lint(&fixed_content);
1465 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1466 }
1467
1468 #[test]
1469 fn test_fix_deeply_nested_lists() {
1470 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1472 let warnings = lint(content);
1473 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1474
1475 check_warnings_have_fixes(content);
1477
1478 let fixed_content = fix(content);
1479 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1480 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1481
1482 let warnings_after_fix = lint(&fixed_content);
1484 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1485 }
1486
1487 #[test]
1488 fn test_fix_list_with_multiline_items() {
1489 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1492 let warnings = lint(content);
1493 assert_eq!(
1494 warnings.len(),
1495 1,
1496 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1497 );
1498
1499 check_warnings_have_fixes(content);
1501
1502 let fixed_content = fix(content);
1503 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1504 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1505
1506 let warnings_after_fix = lint(&fixed_content);
1508 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1509 }
1510
1511 #[test]
1512 fn test_fix_list_at_document_boundaries() {
1513 let content1 = "- Item 1\n- Item 2";
1515 let warnings1 = lint(content1);
1516 assert_eq!(
1517 warnings1.len(),
1518 0,
1519 "List at document start should not need blank before"
1520 );
1521 let fixed1 = fix(content1);
1522 assert_eq!(fixed1, content1, "No fix needed for list at start");
1523
1524 let content2 = "Text\n- Item 1\n- Item 2";
1526 let warnings2 = lint(content2);
1527 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1528 check_warnings_have_fixes(content2);
1529 let fixed2 = fix(content2);
1530 assert_eq!(
1531 fixed2, "Text\n\n- Item 1\n- Item 2",
1532 "Should add blank before list at end"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_fix_preserves_existing_blank_lines() {
1538 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1539 let warnings = lint(content);
1540 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1541 let fixed_content = fix(content);
1542 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1543 }
1544
1545 #[test]
1546 fn test_fix_handles_tabs_and_spaces() {
1547 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1550 let warnings = lint(content);
1551 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1553
1554 check_warnings_have_fixes(content);
1556
1557 let fixed_content = fix(content);
1558 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1561 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1562
1563 let warnings_after_fix = lint(&fixed_content);
1565 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1566 }
1567
1568 #[test]
1569 fn test_fix_warning_objects_have_correct_ranges() {
1570 let content = "Text\n- Item 1\n- Item 2\nText";
1572 let warnings = lint(content);
1573 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1574
1575 for warning in &warnings {
1577 assert!(warning.fix.is_some(), "Warning should have fix");
1578 let fix = warning.fix.as_ref().unwrap();
1579 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1580 assert!(
1581 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1582 "Fix should have replacement or be insertion"
1583 );
1584 }
1585 }
1586
1587 #[test]
1588 fn test_fix_idempotent() {
1589 let content = "Text\n- Item 1\n- Item 2\nText";
1591
1592 let fixed_once = fix(content);
1594 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1595
1596 let fixed_twice = fix(&fixed_once);
1598 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1599
1600 let warnings_after_fix = lint(&fixed_once);
1602 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1603 }
1604
1605 #[test]
1606 fn test_fix_with_normalized_line_endings() {
1607 let content = "Text\n- Item 1\n- Item 2\nText";
1611 let warnings = lint(content);
1612 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1613
1614 check_warnings_have_fixes(content);
1616
1617 let fixed_content = fix(content);
1618 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1620 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1621 }
1622
1623 #[test]
1624 fn test_fix_preserves_final_newline() {
1625 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1628 let fixed_with_newline = fix(content_with_newline);
1629 assert!(
1630 fixed_with_newline.ends_with('\n'),
1631 "Fix should preserve final newline when present"
1632 );
1633 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1635
1636 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1638 let fixed_without_newline = fix(content_without_newline);
1639 assert!(
1640 !fixed_without_newline.ends_with('\n'),
1641 "Fix should not add final newline when not present"
1642 );
1643 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1645 }
1646
1647 #[test]
1648 fn test_fix_multiline_list_items_no_indent() {
1649 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";
1650
1651 let warnings = lint(content);
1652 assert_eq!(
1654 warnings.len(),
1655 0,
1656 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1657 );
1658
1659 let fixed_content = fix(content);
1660 assert_eq!(
1662 fixed_content, content,
1663 "Should not modify correctly formatted multi-line list items"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_nested_list_with_lazy_continuation() {
1669 let content = r#"# Test
1675
1676- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1677 1. Switch/case dispatcher statements (original Phase 3.2)
1678 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1679`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1680 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1681 references"#;
1682
1683 let warnings = lint(content);
1684 let md032_warnings: Vec<_> = warnings
1687 .iter()
1688 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1689 .collect();
1690 assert_eq!(
1691 md032_warnings.len(),
1692 0,
1693 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1694 );
1695 }
1696
1697 #[test]
1698 fn test_pipes_in_code_spans_not_detected_as_table() {
1699 let content = r#"# Test
1701
1702- Item with `a | b` inline code
1703 - Nested item should work
1704
1705"#;
1706
1707 let warnings = lint(content);
1708 let md032_warnings: Vec<_> = warnings
1709 .iter()
1710 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1711 .collect();
1712 assert_eq!(
1713 md032_warnings.len(),
1714 0,
1715 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_multiple_code_spans_with_pipes() {
1721 let content = r#"# Test
1723
1724- Item with `a | b` and `c || d` operators
1725 - Nested item should work
1726
1727"#;
1728
1729 let warnings = lint(content);
1730 let md032_warnings: Vec<_> = warnings
1731 .iter()
1732 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1733 .collect();
1734 assert_eq!(
1735 md032_warnings.len(),
1736 0,
1737 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1738 );
1739 }
1740
1741 #[test]
1742 fn test_actual_table_breaks_list() {
1743 let content = r#"# Test
1745
1746- Item before table
1747
1748| Col1 | Col2 |
1749|------|------|
1750| A | B |
1751
1752- Item after table
1753
1754"#;
1755
1756 let warnings = lint(content);
1757 let md032_warnings: Vec<_> = warnings
1759 .iter()
1760 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1761 .collect();
1762 assert_eq!(
1763 md032_warnings.len(),
1764 0,
1765 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_thematic_break_not_lazy_continuation() {
1771 let content = r#"- Item 1
1774- Item 2
1775***
1776
1777More text.
1778"#;
1779
1780 let warnings = lint(content);
1781 let md032_warnings: Vec<_> = warnings
1782 .iter()
1783 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1784 .collect();
1785 assert_eq!(
1786 md032_warnings.len(),
1787 1,
1788 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1789 );
1790 assert!(
1791 md032_warnings[0].message.contains("followed by blank line"),
1792 "Warning should be about missing blank after list"
1793 );
1794 }
1795
1796 #[test]
1797 fn test_thematic_break_with_blank_line() {
1798 let content = r#"- Item 1
1800- Item 2
1801
1802***
1803
1804More text.
1805"#;
1806
1807 let warnings = lint(content);
1808 let md032_warnings: Vec<_> = warnings
1809 .iter()
1810 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1811 .collect();
1812 assert_eq!(
1813 md032_warnings.len(),
1814 0,
1815 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1816 );
1817 }
1818
1819 #[test]
1820 fn test_various_thematic_break_styles() {
1821 for hr in ["---", "***", "___"] {
1826 let content = format!(
1827 r#"- Item 1
1828- Item 2
1829{hr}
1830
1831More text.
1832"#
1833 );
1834
1835 let warnings = lint(&content);
1836 let md032_warnings: Vec<_> = warnings
1837 .iter()
1838 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1839 .collect();
1840 assert_eq!(
1841 md032_warnings.len(),
1842 1,
1843 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1844 );
1845 }
1846 }
1847
1848 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1851 let rule = MD032BlanksAroundLists::from_config_struct(config);
1852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1853 rule.check(&ctx).expect("Lint check failed")
1854 }
1855
1856 fn fix_with_config(content: &str, config: MD032Config) -> String {
1857 let rule = MD032BlanksAroundLists::from_config_struct(config);
1858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 rule.fix(&ctx).expect("Lint fix failed")
1860 }
1861
1862 #[test]
1863 fn test_lazy_continuation_allowed_by_default() {
1864 let content = "# Heading\n\n1. List\nSome text.";
1866 let warnings = lint(content);
1867 assert_eq!(
1868 warnings.len(),
1869 0,
1870 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1871 );
1872 }
1873
1874 #[test]
1875 fn test_lazy_continuation_disallowed() {
1876 let content = "# Heading\n\n1. List\nSome text.";
1878 let config = MD032Config {
1879 allow_lazy_continuation: false,
1880 };
1881 let warnings = lint_with_config(content, config);
1882 assert_eq!(
1883 warnings.len(),
1884 1,
1885 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1886 );
1887 assert!(
1888 warnings[0].message.contains("Lazy continuation"),
1889 "Warning message should mention lazy continuation"
1890 );
1891 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1892 }
1893
1894 #[test]
1895 fn test_lazy_continuation_fix() {
1896 let content = "# Heading\n\n1. List\nSome text.";
1898 let config = MD032Config {
1899 allow_lazy_continuation: false,
1900 };
1901 let fixed = fix_with_config(content, config.clone());
1902 assert_eq!(
1904 fixed, "# Heading\n\n1. List\n Some text.",
1905 "Fix should add proper indentation to lazy continuation"
1906 );
1907
1908 let warnings_after = lint_with_config(&fixed, config);
1910 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1911 }
1912
1913 #[test]
1914 fn test_lazy_continuation_multiple_lines() {
1915 let content = "- Item 1\nLine 2\nLine 3";
1917 let config = MD032Config {
1918 allow_lazy_continuation: false,
1919 };
1920 let warnings = lint_with_config(content, config.clone());
1921 assert_eq!(
1923 warnings.len(),
1924 2,
1925 "Should warn for each lazy continuation line. Got: {warnings:?}"
1926 );
1927
1928 let fixed = fix_with_config(content, config.clone());
1929 assert_eq!(
1931 fixed, "- Item 1\n Line 2\n Line 3",
1932 "Fix should add proper indentation to lazy continuation lines"
1933 );
1934
1935 let warnings_after = lint_with_config(&fixed, config);
1937 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1938 }
1939
1940 #[test]
1941 fn test_lazy_continuation_with_indented_content() {
1942 let content = "- Item 1\n Indented content\nLazy text";
1944 let config = MD032Config {
1945 allow_lazy_continuation: false,
1946 };
1947 let warnings = lint_with_config(content, config);
1948 assert_eq!(
1949 warnings.len(),
1950 1,
1951 "Should warn for lazy text after indented content. Got: {warnings:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_lazy_continuation_properly_separated() {
1957 let content = "- Item 1\n\nSome text.";
1959 let config = MD032Config {
1960 allow_lazy_continuation: false,
1961 };
1962 let warnings = lint_with_config(content, config);
1963 assert_eq!(
1964 warnings.len(),
1965 0,
1966 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1967 );
1968 }
1969
1970 #[test]
1973 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1974 let content = "1) First item\nLazy continuation";
1976 let config = MD032Config {
1977 allow_lazy_continuation: false,
1978 };
1979 let warnings = lint_with_config(content, config.clone());
1980 assert_eq!(
1981 warnings.len(),
1982 1,
1983 "Should warn for lazy continuation with parenthesis marker"
1984 );
1985
1986 let fixed = fix_with_config(content, config);
1987 assert_eq!(fixed, "1) First item\n Lazy continuation");
1989 }
1990
1991 #[test]
1992 fn test_lazy_continuation_followed_by_another_list() {
1993 let content = "- Item 1\nSome text\n- Item 2";
1999 let config = MD032Config {
2000 allow_lazy_continuation: false,
2001 };
2002 let warnings = lint_with_config(content, config);
2003 assert_eq!(
2005 warnings.len(),
2006 1,
2007 "Should warn about lazy continuation within list. Got: {warnings:?}"
2008 );
2009 assert!(
2010 warnings[0].message.contains("Lazy continuation"),
2011 "Warning should be about lazy continuation"
2012 );
2013 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2014 }
2015
2016 #[test]
2017 fn test_lazy_continuation_multiple_in_document() {
2018 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2023 let config = MD032Config {
2024 allow_lazy_continuation: false,
2025 };
2026 let warnings = lint_with_config(content, config.clone());
2027 assert_eq!(
2029 warnings.len(),
2030 2,
2031 "Should warn for both lazy continuations. Got: {warnings:?}"
2032 );
2033
2034 let fixed = fix_with_config(content, config.clone());
2035 assert!(
2037 fixed.contains(" Lazy 1"),
2038 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2039 );
2040 assert!(
2041 fixed.contains(" Lazy 2"),
2042 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2043 );
2044
2045 let warnings_after = lint_with_config(&fixed, config);
2046 assert_eq!(
2048 warnings_after.len(),
2049 0,
2050 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_lazy_continuation_end_of_document_no_newline() {
2056 let content = "- Item\nNo trailing newline";
2058 let config = MD032Config {
2059 allow_lazy_continuation: false,
2060 };
2061 let warnings = lint_with_config(content, config.clone());
2062 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2063
2064 let fixed = fix_with_config(content, config);
2065 assert_eq!(fixed, "- Item\n No trailing newline");
2067 }
2068
2069 #[test]
2070 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2071 let content = "- Item 1\n---";
2074 let config = MD032Config {
2075 allow_lazy_continuation: false,
2076 };
2077 let warnings = lint_with_config(content, config.clone());
2078 assert_eq!(
2080 warnings.len(),
2081 1,
2082 "List should need blank line before thematic break. Got: {warnings:?}"
2083 );
2084
2085 let fixed = fix_with_config(content, config);
2087 assert_eq!(fixed, "- Item 1\n\n---");
2088 }
2089
2090 #[test]
2091 fn test_lazy_continuation_heading_not_flagged() {
2092 let content = "- Item 1\n# Heading";
2095 let config = MD032Config {
2096 allow_lazy_continuation: false,
2097 };
2098 let warnings = lint_with_config(content, config);
2099 assert!(
2102 warnings.iter().all(|w| !w.message.contains("lazy")),
2103 "Heading should not trigger lazy continuation warning"
2104 );
2105 }
2106
2107 #[test]
2108 fn test_lazy_continuation_mixed_list_types() {
2109 let content = "- Unordered\n1. Ordered\nLazy text";
2111 let config = MD032Config {
2112 allow_lazy_continuation: false,
2113 };
2114 let warnings = lint_with_config(content, config.clone());
2115 assert!(!warnings.is_empty(), "Should warn about structure issues");
2116 }
2117
2118 #[test]
2119 fn test_lazy_continuation_deep_nesting() {
2120 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2122 let config = MD032Config {
2123 allow_lazy_continuation: false,
2124 };
2125 let warnings = lint_with_config(content, config.clone());
2126 assert!(
2127 !warnings.is_empty(),
2128 "Should warn about lazy continuation after nested list"
2129 );
2130
2131 let fixed = fix_with_config(content, config.clone());
2132 let warnings_after = lint_with_config(&fixed, config);
2133 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2134 }
2135
2136 #[test]
2137 fn test_lazy_continuation_with_emphasis_in_text() {
2138 let content = "- Item\n*emphasized* continuation";
2140 let config = MD032Config {
2141 allow_lazy_continuation: false,
2142 };
2143 let warnings = lint_with_config(content, config.clone());
2144 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2145
2146 let fixed = fix_with_config(content, config);
2147 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2149 }
2150
2151 #[test]
2152 fn test_lazy_continuation_with_code_span() {
2153 let content = "- Item\n`code` continuation";
2155 let config = MD032Config {
2156 allow_lazy_continuation: false,
2157 };
2158 let warnings = lint_with_config(content, config.clone());
2159 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2160
2161 let fixed = fix_with_config(content, config);
2162 assert_eq!(fixed, "- Item\n `code` continuation");
2164 }
2165
2166 #[test]
2173 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2174 let content = r#"1. Create a new Chat conversation:
2177 - On the sidebar, select **New Chat**.
2178 - In the box, type `/new`.
2179 A new Chat conversation replaces the previous one.
21801. Under the Chat text box, turn off the toggle."#;
2181 let config = MD032Config {
2182 allow_lazy_continuation: false,
2183 };
2184 let warnings = lint_with_config(content, config);
2185 let lazy_warnings: Vec<_> = warnings
2187 .iter()
2188 .filter(|w| w.message.contains("Lazy continuation"))
2189 .collect();
2190 assert!(
2191 !lazy_warnings.is_empty(),
2192 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2193 );
2194 assert!(
2195 lazy_warnings.iter().any(|w| w.line == 4),
2196 "Should warn on line 4. Got: {lazy_warnings:?}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2202 let content = r#"- `field`: Is the specific key:
2205 - `password`: Accesses the password.
2206 - `api_key`: Accesses the api_key.
2207 `token`: Specifies which ID token to use.
2208- `version_id`: Is the unique identifier."#;
2209 let config = MD032Config {
2210 allow_lazy_continuation: false,
2211 };
2212 let warnings = lint_with_config(content, config);
2213 let lazy_warnings: Vec<_> = warnings
2215 .iter()
2216 .filter(|w| w.message.contains("Lazy continuation"))
2217 .collect();
2218 assert!(
2219 !lazy_warnings.is_empty(),
2220 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2221 );
2222 assert!(
2223 lazy_warnings.iter().any(|w| w.line == 4),
2224 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2230 let content = r#"- Check out the branch, and test locally.
2232 - If the MR requires significant modifications:
2233 - **Skip local testing** and review instead.
2234 - **Request verification** from the author.
2235 - **Identify the minimal change** needed.
2236 Your testing might result in opportunities.
2237- If you don't understand, _say so_."#;
2238 let config = MD032Config {
2239 allow_lazy_continuation: false,
2240 };
2241 let warnings = lint_with_config(content, config);
2242 let lazy_warnings: Vec<_> = warnings
2244 .iter()
2245 .filter(|w| w.message.contains("Lazy continuation"))
2246 .collect();
2247 assert!(
2248 !lazy_warnings.is_empty(),
2249 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2250 );
2251 assert!(
2252 lazy_warnings.iter().any(|w| w.line == 6),
2253 "Should warn on line 6. Got: {lazy_warnings:?}"
2254 );
2255 }
2256
2257 #[test]
2258 fn test_issue295_ordered_list_nested_bullets_continuation() {
2259 let content = r#"# Test
2262
22631. First item.
2264 - Nested A.
2265 - Nested B.
2266 Continuation at outer level.
22671. Second item."#;
2268 let config = MD032Config {
2269 allow_lazy_continuation: false,
2270 };
2271 let warnings = lint_with_config(content, config);
2272 let lazy_warnings: Vec<_> = warnings
2274 .iter()
2275 .filter(|w| w.message.contains("Lazy continuation"))
2276 .collect();
2277 assert!(
2278 !lazy_warnings.is_empty(),
2279 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2280 );
2281 assert!(
2283 lazy_warnings.iter().any(|w| w.line == 6),
2284 "Should warn on line 6. Got: {lazy_warnings:?}"
2285 );
2286 }
2287
2288 #[test]
2289 fn test_issue295_multiple_lazy_lines_after_nested() {
2290 let content = r#"1. The device client receives a response.
2292 - Those defined by OAuth Framework.
2293 - Those specific to device authorization.
2294 Those error responses are described below.
2295 For more information on each response,
2296 see the documentation.
22971. Next step in the process."#;
2298 let config = MD032Config {
2299 allow_lazy_continuation: false,
2300 };
2301 let warnings = lint_with_config(content, config);
2302 let lazy_warnings: Vec<_> = warnings
2304 .iter()
2305 .filter(|w| w.message.contains("Lazy continuation"))
2306 .collect();
2307 assert!(
2308 lazy_warnings.len() >= 3,
2309 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2310 lazy_warnings.len()
2311 );
2312 }
2313
2314 #[test]
2315 fn test_issue295_properly_indented_not_lazy() {
2316 let content = r#"1. First item.
2318 - Nested A.
2319 - Nested B.
2320
2321 Properly indented continuation.
23221. Second item."#;
2323 let config = MD032Config {
2324 allow_lazy_continuation: false,
2325 };
2326 let warnings = lint_with_config(content, config);
2327 let lazy_warnings: Vec<_> = warnings
2329 .iter()
2330 .filter(|w| w.message.contains("Lazy continuation"))
2331 .collect();
2332 assert_eq!(
2333 lazy_warnings.len(),
2334 0,
2335 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2336 );
2337 }
2338
2339 #[test]
2346 fn test_html_comment_before_list_with_preceding_blank() {
2347 let content = "Some text.\n\n<!-- comment -->\n- List item";
2350 let warnings = lint(content);
2351 assert_eq!(
2352 warnings.len(),
2353 0,
2354 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2355 );
2356 }
2357
2358 #[test]
2359 fn test_html_comment_after_list_with_following_blank() {
2360 let content = "- List item\n<!-- comment -->\n\nSome text.";
2362 let warnings = lint(content);
2363 assert_eq!(
2364 warnings.len(),
2365 0,
2366 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2367 );
2368 }
2369
2370 #[test]
2371 fn test_list_inside_html_comment_ignored() {
2372 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2374 let warnings = lint(content);
2375 assert_eq!(
2376 warnings.len(),
2377 0,
2378 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2379 );
2380 }
2381
2382 #[test]
2383 fn test_multiline_html_comment_before_list() {
2384 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2386 let warnings = lint(content);
2387 assert_eq!(
2388 warnings.len(),
2389 0,
2390 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2391 );
2392 }
2393
2394 #[test]
2395 fn test_no_blank_before_html_comment_still_warns() {
2396 let content = "Some text.\n<!-- comment -->\n- List item";
2398 let warnings = lint(content);
2399 assert_eq!(
2400 warnings.len(),
2401 1,
2402 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2403 );
2404 assert!(
2405 warnings[0].message.contains("preceded by blank line"),
2406 "Should be 'preceded by blank line' warning"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2412 let content = "- List item\n<!-- comment -->\nSome text.";
2415 let warnings = lint(content);
2416 assert_eq!(
2417 warnings.len(),
2418 0,
2419 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2420 );
2421 }
2422
2423 #[test]
2424 fn test_list_followed_by_heading_through_comment_should_warn() {
2425 let content = "- List item\n<!-- comment -->\n# Heading";
2427 let warnings = lint(content);
2428 assert!(
2431 warnings.len() <= 1,
2432 "Should handle heading after comment gracefully. Got: {warnings:?}"
2433 );
2434 }
2435
2436 #[test]
2437 fn test_html_comment_between_list_and_text_both_directions() {
2438 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2440 let warnings = lint(content);
2441 assert_eq!(
2442 warnings.len(),
2443 0,
2444 "Should not warn with proper separation through comments. Got: {warnings:?}"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2450 let content = "Text.\n\n<!-- comment -->\n- Item";
2452 let fixed = fix(content);
2453 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2454 }
2455
2456 #[test]
2457 fn test_html_comment_fix_adds_blank_when_needed() {
2458 let content = "Text.\n<!-- comment -->\n- Item";
2461 let fixed = fix(content);
2462 assert!(
2463 fixed.contains("<!-- comment -->\n\n- Item"),
2464 "Fix should add blank line before list. Got: {fixed}"
2465 );
2466 }
2467
2468 #[test]
2469 fn test_ordered_list_inside_html_comment() {
2470 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2472 let warnings = lint(content);
2473 assert_eq!(
2474 warnings.len(),
2475 0,
2476 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2477 );
2478 }
2479
2480 #[test]
2487 fn test_blockquote_list_exit_no_warning() {
2488 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2490 let warnings = lint(content);
2491 assert_eq!(
2492 warnings.len(),
2493 0,
2494 "Should not warn when exiting blockquote. Got: {warnings:?}"
2495 );
2496 }
2497
2498 #[test]
2499 fn test_nested_blockquote_list_exit() {
2500 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2502 let warnings = lint(content);
2503 assert_eq!(
2504 warnings.len(),
2505 0,
2506 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2507 );
2508 }
2509
2510 #[test]
2511 fn test_blockquote_same_level_no_warning() {
2512 let content = "> - item 1\n> - item 2\n> Text after";
2515 let warnings = lint(content);
2516 assert_eq!(
2517 warnings.len(),
2518 0,
2519 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2520 );
2521 }
2522
2523 #[test]
2524 fn test_blockquote_list_with_special_chars() {
2525 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2527 let warnings = lint(content);
2528 assert_eq!(
2529 warnings.len(),
2530 0,
2531 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2532 );
2533 }
2534
2535 #[test]
2536 fn test_lazy_continuation_whitespace_only_line() {
2537 let content = "- Item\n \nText after whitespace-only line";
2540 let config = MD032Config {
2541 allow_lazy_continuation: false,
2542 };
2543 let warnings = lint_with_config(content, config);
2544 assert_eq!(
2546 warnings.len(),
2547 0,
2548 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2549 );
2550 }
2551
2552 #[test]
2553 fn test_lazy_continuation_blockquote_context() {
2554 let content = "> - Item\n> Lazy in quote";
2556 let config = MD032Config {
2557 allow_lazy_continuation: false,
2558 };
2559 let warnings = lint_with_config(content, config);
2560 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2563 }
2564
2565 #[test]
2566 fn test_lazy_continuation_fix_preserves_content() {
2567 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2569 let config = MD032Config {
2570 allow_lazy_continuation: false,
2571 };
2572 let fixed = fix_with_config(content, config);
2573 assert!(fixed.contains("<>&"), "Should preserve special chars");
2574 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2575 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2577 }
2578
2579 #[test]
2580 fn test_lazy_continuation_fix_idempotent() {
2581 let content = "- Item\nLazy";
2583 let config = MD032Config {
2584 allow_lazy_continuation: false,
2585 };
2586 let fixed_once = fix_with_config(content, config.clone());
2587 let fixed_twice = fix_with_config(&fixed_once, config);
2588 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2589 }
2590
2591 #[test]
2592 fn test_lazy_continuation_config_default_allows() {
2593 let content = "- Item\nLazy text that continues";
2595 let default_config = MD032Config::default();
2596 assert!(
2597 default_config.allow_lazy_continuation,
2598 "Default should allow lazy continuation"
2599 );
2600 let warnings = lint_with_config(content, default_config);
2601 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2602 }
2603
2604 #[test]
2605 fn test_lazy_continuation_after_multi_line_item() {
2606 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2608 let config = MD032Config {
2609 allow_lazy_continuation: false,
2610 };
2611 let warnings = lint_with_config(content, config.clone());
2612 assert_eq!(
2613 warnings.len(),
2614 1,
2615 "Should warn only for the lazy line, not the indented line"
2616 );
2617 }
2618
2619 #[test]
2621 fn test_blockquote_list_with_continuation_and_nested() {
2622 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2625 let warnings = lint(content);
2626 assert_eq!(
2627 warnings.len(),
2628 0,
2629 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2630 );
2631 }
2632
2633 #[test]
2634 fn test_blockquote_list_simple() {
2635 let content = "> - item 1\n> - item 2";
2637 let warnings = lint(content);
2638 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2639 }
2640
2641 #[test]
2642 fn test_blockquote_list_with_continuation_only() {
2643 let content = "> - item 1\n> continuation\n> - item 2";
2645 let warnings = lint(content);
2646 assert_eq!(
2647 warnings.len(),
2648 0,
2649 "Blockquoted list with continuation should have no warnings"
2650 );
2651 }
2652
2653 #[test]
2654 fn test_blockquote_list_with_lazy_continuation() {
2655 let content = "> - item 1\n> lazy continuation\n> - item 2";
2657 let warnings = lint(content);
2658 assert_eq!(
2659 warnings.len(),
2660 0,
2661 "Blockquoted list with lazy continuation should have no warnings"
2662 );
2663 }
2664
2665 #[test]
2666 fn test_nested_blockquote_list() {
2667 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2669 let warnings = lint(content);
2670 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2671 }
2672
2673 #[test]
2674 fn test_blockquote_list_needs_preceding_blank() {
2675 let content = "> Text before\n> - item 1\n> - item 2";
2677 let warnings = lint(content);
2678 assert_eq!(
2679 warnings.len(),
2680 1,
2681 "Should warn for missing blank before blockquoted list"
2682 );
2683 }
2684
2685 #[test]
2686 fn test_blockquote_list_properly_separated() {
2687 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2689 let warnings = lint(content);
2690 assert_eq!(
2691 warnings.len(),
2692 0,
2693 "Properly separated blockquoted list should have no warnings"
2694 );
2695 }
2696
2697 #[test]
2698 fn test_blockquote_ordered_list() {
2699 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2701 let warnings = lint(content);
2702 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2703 }
2704
2705 #[test]
2706 fn test_blockquote_list_with_empty_blockquote_line() {
2707 let content = "> - item 1\n>\n> - item 2";
2709 let warnings = lint(content);
2710 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2711 }
2712
2713 #[test]
2715 fn test_blockquote_list_multi_paragraph_items() {
2716 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2719 let warnings = lint(content);
2720 assert_eq!(
2721 warnings.len(),
2722 0,
2723 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2724 );
2725 }
2726
2727 #[test]
2729 fn test_blockquote_ordered_list_multi_paragraph_items() {
2730 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2731 let warnings = lint(content);
2732 assert_eq!(
2733 warnings.len(),
2734 0,
2735 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2736 );
2737 }
2738
2739 #[test]
2741 fn test_blockquote_list_multiple_continuations() {
2742 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2743 let warnings = lint(content);
2744 assert_eq!(
2745 warnings.len(),
2746 0,
2747 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2748 );
2749 }
2750
2751 #[test]
2753 fn test_nested_blockquote_multi_paragraph_list() {
2754 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2755 let warnings = lint(content);
2756 assert_eq!(
2757 warnings.len(),
2758 0,
2759 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2760 );
2761 }
2762
2763 #[test]
2765 fn test_triple_nested_blockquote_multi_paragraph_list() {
2766 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2767 let warnings = lint(content);
2768 assert_eq!(
2769 warnings.len(),
2770 0,
2771 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2772 );
2773 }
2774
2775 #[test]
2777 fn test_blockquote_list_last_item_continuation() {
2778 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2779 let warnings = lint(content);
2780 assert_eq!(
2781 warnings.len(),
2782 0,
2783 "Last item with continuation should have no warnings. Got: {warnings:?}"
2784 );
2785 }
2786
2787 #[test]
2789 fn test_blockquote_list_first_item_only_continuation() {
2790 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2791 let warnings = lint(content);
2792 assert_eq!(
2793 warnings.len(),
2794 0,
2795 "Single item with continuation should have no warnings. Got: {warnings:?}"
2796 );
2797 }
2798
2799 #[test]
2803 fn test_blockquote_level_change_breaks_list() {
2804 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2806 let warnings = lint(content);
2807 assert!(
2811 warnings.len() <= 2,
2812 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2813 );
2814 }
2815
2816 #[test]
2818 fn test_exit_blockquote_needs_blank_before_list() {
2819 let content = "> Blockquote text\n\n- List outside blockquote\n";
2821 let warnings = lint(content);
2822 assert_eq!(
2823 warnings.len(),
2824 0,
2825 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2826 );
2827
2828 let content2 = "> Blockquote text\n- List outside blockquote\n";
2832 let warnings2 = lint(content2);
2833 assert!(
2835 warnings2.len() <= 1,
2836 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2837 );
2838 }
2839
2840 #[test]
2842 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2843 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2845 let warnings = lint(content_dash);
2846 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2847
2848 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2850 let warnings = lint(content_asterisk);
2851 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2852
2853 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2855 let warnings = lint(content_plus);
2856 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2857 }
2858
2859 #[test]
2861 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2862 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2863 let warnings = lint(content);
2864 assert_eq!(
2865 warnings.len(),
2866 0,
2867 "Parenthesis ordered markers should work. Got: {warnings:?}"
2868 );
2869 }
2870
2871 #[test]
2873 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2874 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2876 let warnings = lint(content);
2877 assert_eq!(
2878 warnings.len(),
2879 0,
2880 "Multi-digit ordered list should work. Got: {warnings:?}"
2881 );
2882 }
2883
2884 #[test]
2886 fn test_blockquote_multi_paragraph_with_formatting() {
2887 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2888 let warnings = lint(content);
2889 assert_eq!(
2890 warnings.len(),
2891 0,
2892 "Continuation with inline formatting should work. Got: {warnings:?}"
2893 );
2894 }
2895
2896 #[test]
2898 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2899 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2900 let warnings = lint(content);
2901 assert_eq!(
2902 warnings.len(),
2903 0,
2904 "All items with continuations should work. Got: {warnings:?}"
2905 );
2906 }
2907
2908 #[test]
2910 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2911 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2912 let warnings = lint(content);
2913 assert_eq!(
2914 warnings.len(),
2915 0,
2916 "Lowercase continuation should work. Got: {warnings:?}"
2917 );
2918 }
2919
2920 #[test]
2922 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2923 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2924 let warnings = lint(content);
2925 assert_eq!(
2926 warnings.len(),
2927 0,
2928 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2929 );
2930 }
2931
2932 #[test]
2934 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2935 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2937 let warnings = lint(content);
2938 assert!(
2940 warnings.len() <= 1,
2941 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2942 );
2943 }
2944
2945 #[test]
2947 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2948 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2950 let warnings = lint(content);
2951 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2952 }
2953
2954 #[test]
2955 fn test_blockquote_list_varying_spaces_after_marker() {
2956 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2958 let warnings = lint(content);
2959 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2960 }
2961
2962 #[test]
2963 fn test_deeply_nested_blockquote_list() {
2964 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2966 let warnings = lint(content);
2967 assert_eq!(
2968 warnings.len(),
2969 0,
2970 "Deeply nested blockquote list should have no warnings"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_blockquote_level_change_in_list() {
2976 let content = "> - item 1\n>> - deeper item\n> - item 2";
2978 let warnings = lint(content);
2981 assert!(
2982 !warnings.is_empty(),
2983 "Blockquote level change should break list and trigger warnings"
2984 );
2985 }
2986
2987 #[test]
2988 fn test_blockquote_list_with_code_span() {
2989 let content = "> - item with `code`\n> continuation\n> - item 2";
2991 let warnings = lint(content);
2992 assert_eq!(
2993 warnings.len(),
2994 0,
2995 "Blockquote list with code span should have no warnings"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_blockquote_list_at_document_end() {
3001 let content = "> Some text\n>\n> - item 1\n> - item 2";
3003 let warnings = lint(content);
3004 assert_eq!(
3005 warnings.len(),
3006 0,
3007 "Blockquote list at document end should have no warnings"
3008 );
3009 }
3010
3011 #[test]
3012 fn test_fix_preserves_blockquote_prefix_before_list() {
3013 let content = "> Text before
3015> - Item 1
3016> - Item 2";
3017 let fixed = fix(content);
3018
3019 let expected = "> Text before
3021>
3022> - Item 1
3023> - Item 2";
3024 assert_eq!(
3025 fixed, expected,
3026 "Fix should insert '>' blank line, not plain blank line"
3027 );
3028 }
3029
3030 #[test]
3031 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3032 let content = ">>> Triple nested
3035>>> - Item 1
3036>>> - Item 2
3037>>> More text";
3038 let fixed = fix(content);
3039
3040 let expected = ">>> Triple nested
3042>>>
3043>>> - Item 1
3044>>> - Item 2
3045>>> More text";
3046 assert_eq!(
3047 fixed, expected,
3048 "Fix should preserve triple-nested blockquote prefix '>>>'"
3049 );
3050 }
3051
3052 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3055 let rule = MD032BlanksAroundLists::default();
3056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3057 rule.check(&ctx).unwrap()
3058 }
3059
3060 #[test]
3061 fn test_quarto_list_after_div_open() {
3062 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3064 let warnings = lint_quarto(content);
3065 assert!(
3067 warnings.is_empty(),
3068 "Quarto div marker should be transparent before list: {warnings:?}"
3069 );
3070 }
3071
3072 #[test]
3073 fn test_quarto_list_before_div_close() {
3074 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3076 let warnings = lint_quarto(content);
3077 assert!(
3079 warnings.is_empty(),
3080 "Quarto div marker should be transparent after list: {warnings:?}"
3081 );
3082 }
3083
3084 #[test]
3085 fn test_quarto_list_needs_blank_without_div() {
3086 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3088 let warnings = lint_quarto(content);
3089 assert!(
3092 !warnings.is_empty(),
3093 "Should still require blank when not present: {warnings:?}"
3094 );
3095 }
3096
3097 #[test]
3098 fn test_quarto_list_in_callout_with_content() {
3099 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3101 let warnings = lint_quarto(content);
3102 assert!(
3103 warnings.is_empty(),
3104 "List with proper blanks inside callout should pass: {warnings:?}"
3105 );
3106 }
3107
3108 #[test]
3109 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3110 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3112 let warnings = lint(content); assert!(
3115 !warnings.is_empty(),
3116 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3117 );
3118 }
3119
3120 #[test]
3121 fn test_quarto_nested_divs_with_list() {
3122 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3124 let warnings = lint_quarto(content);
3125 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3126 }
3127
3128 #[test]
3129 fn test_issue512_complex_nested_list_with_continuation() {
3130 let content = "\
3133- First level of indentation.
3134 - Second level of indentation.
3135 - Third level of indentation.
3136 - Third level of indentation.
3137
3138 Second level list continuation.
3139
3140 First level list continuation.
3141- First level of indentation.
3142";
3143 let warnings = lint(content);
3144 assert!(
3145 warnings.is_empty(),
3146 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_issue512_continuation_at_root_level() {
3152 let content = "\
3156- First level.
3157 - Second level.
3158
3159 First level continuation.
3160
3161Root level lazy continuation.
3162- Another first level item.
3163";
3164 let warnings = lint(content);
3165 assert_eq!(
3166 warnings.len(),
3167 1,
3168 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3169 );
3170 assert_eq!(warnings[0].line, 7);
3171 }
3172
3173 #[test]
3174 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3175 let content = "\
3177- Level 1 item.
3178 - Level 2 item.
3179 - Level 3 item.
3180
3181 Level 3 continuation.
3182
3183 Level 2 continuation.
3184
3185 Level 1 continuation (indented under marker).
3186- Another level 1 item.
3187";
3188 let warnings = lint(content);
3189 assert!(
3190 warnings.is_empty(),
3191 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3192 );
3193 }
3194}