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 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 .map(|li| !li.in_code_block && !li.in_front_matter && !li.in_html_comment && !li.in_mdx_comment)
178 .unwrap_or(false)
179 }
180
181 fn calculate_lazy_continuation_fix(
184 ctx: &crate::lint_context::LintContext,
185 line_num: usize,
186 lazy_info: &LazyContLine,
187 ) -> Option<Fix> {
188 let line_info = ctx.lines.get(line_num.saturating_sub(1))?;
189 let line_content = line_info.content(ctx.content);
190
191 if lazy_info.blockquote_level == 0 {
192 let start_byte = line_info.byte_offset;
194 let end_byte = start_byte + lazy_info.current_indent;
195 let replacement = " ".repeat(lazy_info.expected_indent);
196
197 Some(Fix {
198 range: start_byte..end_byte,
199 replacement,
200 })
201 } else {
202 let after_bq = content_after_blockquote(line_content, lazy_info.blockquote_level);
204 let prefix_byte_len = line_content.len().saturating_sub(after_bq.len());
205 if prefix_byte_len == 0 {
206 return None;
207 }
208
209 let current_indent = after_bq.len() - after_bq.trim_start().len();
210 let start_byte = line_info.byte_offset + prefix_byte_len;
211 let end_byte = start_byte + current_indent;
212 let replacement = " ".repeat(lazy_info.expected_indent);
213
214 Some(Fix {
215 range: start_byte..end_byte,
216 replacement,
217 })
218 }
219 }
220
221 fn apply_lazy_fix_to_line(line: &str, lazy_info: &LazyContLine) -> String {
224 if lazy_info.blockquote_level == 0 {
225 let content = line.trim_start();
227 format!("{}{}", " ".repeat(lazy_info.expected_indent), content)
228 } else {
229 let after_bq = content_after_blockquote(line, lazy_info.blockquote_level);
231 let prefix_len = line.len().saturating_sub(after_bq.len());
232 if prefix_len == 0 {
233 return line.to_string();
234 }
235
236 let prefix = &line[..prefix_len];
237 let rest = after_bq.trim_start();
238 format!("{}{}{}", prefix, " ".repeat(lazy_info.expected_indent), rest)
239 }
240 }
241
242 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
250 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
251 for line_num in (1..before_line).rev() {
252 let idx = line_num - 1;
253 if let Some(info) = ctx.lines.get(idx) {
254 if info.in_html_comment || info.in_mdx_comment {
256 continue;
257 }
258 if is_quarto {
260 let trimmed = info.content(ctx.content).trim();
261 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
262 continue;
263 }
264 }
265 return (line_num, info.is_blank);
266 }
267 }
268 (0, true)
270 }
271
272 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
279 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
280 let num_lines = ctx.lines.len();
281 for line_num in (after_line + 1)..=num_lines {
282 let idx = line_num - 1;
283 if let Some(info) = ctx.lines.get(idx) {
284 if info.in_html_comment || info.in_mdx_comment {
286 continue;
287 }
288 if is_quarto {
290 let trimmed = info.content(ctx.content).trim();
291 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
292 continue;
293 }
294 }
295 return (line_num, info.is_blank);
296 }
297 }
298 (0, true)
300 }
301
302 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
304 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
305
306 for block in &ctx.list_blocks {
307 if ctx
309 .line_info(block.start_line)
310 .is_some_and(|info| info.in_footnote_definition)
311 {
312 continue;
313 }
314
315 let mut segments: Vec<(usize, usize)> = Vec::new();
321 let mut current_start = block.start_line;
322 let mut prev_item_line = 0;
323
324 let get_blockquote_level = |line_num: usize| -> usize {
326 if line_num == 0 || line_num > ctx.lines.len() {
327 return 0;
328 }
329 let line_content = ctx.lines[line_num - 1].content(ctx.content);
330 BLOCKQUOTE_PREFIX_RE
331 .find(line_content)
332 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
333 .unwrap_or(0)
334 };
335
336 let mut prev_bq_level = 0;
337
338 for &item_line in &block.item_lines {
339 let current_bq_level = get_blockquote_level(item_line);
340
341 if prev_item_line > 0 {
342 let blockquote_level_changed = prev_bq_level != current_bq_level;
344
345 let mut has_standalone_code_fence = false;
348
349 let min_indent_for_content = if block.is_ordered {
351 3 } else {
355 2 };
358
359 for check_line in (prev_item_line + 1)..item_line {
360 if check_line - 1 < ctx.lines.len() {
361 let line = &ctx.lines[check_line - 1];
362 let line_content = line.content(ctx.content);
363 if line.in_code_block
364 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
365 {
366 if line.indent < min_indent_for_content {
369 has_standalone_code_fence = true;
370 break;
371 }
372 }
373 }
374 }
375
376 if has_standalone_code_fence || blockquote_level_changed {
377 segments.push((current_start, prev_item_line));
379 current_start = item_line;
380 }
381 }
382 prev_item_line = item_line;
383 prev_bq_level = current_bq_level;
384 }
385
386 if prev_item_line > 0 {
389 segments.push((current_start, prev_item_line));
390 }
391
392 let has_code_fence_splits = segments.len() > 1 && {
394 let mut found_fence = false;
396 for i in 0..segments.len() - 1 {
397 let seg_end = segments[i].1;
398 let next_start = segments[i + 1].0;
399 for check_line in (seg_end + 1)..next_start {
401 if check_line - 1 < ctx.lines.len() {
402 let line = &ctx.lines[check_line - 1];
403 let line_content = line.content(ctx.content);
404 if line.in_code_block
405 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
406 {
407 found_fence = true;
408 break;
409 }
410 }
411 }
412 if found_fence {
413 break;
414 }
415 }
416 found_fence
417 };
418
419 for (start, end) in segments.iter() {
421 let mut actual_end = *end;
423
424 if !has_code_fence_splits && *end < block.end_line {
427 let block_bq_level = block.blockquote_prefix.chars().filter(|&c| c == '>').count();
429
430 let min_continuation_indent = if block_bq_level > 0 {
433 if block.is_ordered {
435 block.max_marker_width
436 } else {
437 2 }
439 } else {
440 ctx.lines
441 .get(*end - 1)
442 .and_then(|line_info| line_info.list_item.as_ref())
443 .map(|item| item.content_column)
444 .unwrap_or(2)
445 };
446
447 for check_line in (*end + 1)..=block.end_line {
448 if check_line - 1 < ctx.lines.len() {
449 let line = &ctx.lines[check_line - 1];
450 let line_content = line.content(ctx.content);
451 if block.item_lines.contains(&check_line) || line.heading.is_some() {
453 break;
454 }
455 if line.in_code_block {
457 break;
458 }
459
460 let effective_indent =
462 effective_indent_in_blockquote(line_content, block_bq_level, line.indent);
463
464 if effective_indent >= min_continuation_indent {
466 actual_end = check_line;
467 }
468 else if !line.is_blank
473 && line.heading.is_none()
474 && !block.item_lines.contains(&check_line)
475 && !is_thematic_break(line_content)
476 {
477 actual_end = check_line;
479 } else if !line.is_blank {
480 break;
482 }
483 }
484 }
485 }
486
487 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
488 }
489 }
490
491 blocks.retain(|(start, end, _)| {
493 let all_in_comment = (*start..=*end).all(|line_num| {
495 ctx.lines
496 .get(line_num - 1)
497 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
498 });
499 !all_in_comment
500 });
501
502 blocks
503 }
504
505 fn perform_checks(
506 &self,
507 ctx: &crate::lint_context::LintContext,
508 lines: &[&str],
509 list_blocks: &[(usize, usize, String)],
510 line_index: &LineIndex,
511 ) -> LintResult {
512 let mut warnings = Vec::new();
513 let num_lines = lines.len();
514
515 for (line_idx, line) in lines.iter().enumerate() {
518 let line_num = line_idx + 1;
519
520 let is_in_list = list_blocks
522 .iter()
523 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
524 if is_in_list {
525 continue;
526 }
527
528 if ctx.line_info(line_num).is_some_and(|info| {
530 info.in_code_block
531 || info.in_front_matter
532 || info.in_html_comment
533 || info.in_mdx_comment
534 || info.in_html_block
535 || info.in_jsx_block
536 }) {
537 continue;
538 }
539
540 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
542 if line_idx > 0 {
544 let prev_line = lines[line_idx - 1];
545 let prev_is_blank = is_blank_in_context(prev_line);
546 let prev_excluded = ctx
547 .line_info(line_idx)
548 .is_some_and(|info| info.in_code_block || info.in_front_matter);
549
550 let prev_trimmed = prev_line.trim();
555 let is_sentence_continuation = !prev_is_blank
556 && !prev_trimmed.is_empty()
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 && !prev_trimmed.ends_with('>')
563 && !prev_trimmed.ends_with('-')
564 && !prev_trimmed.ends_with('*');
565
566 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
567 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
569
570 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
571 warnings.push(LintWarning {
572 line: start_line,
573 column: start_col,
574 end_line,
575 end_column: end_col,
576 severity: Severity::Warning,
577 rule_name: Some(self.name().to_string()),
578 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
579 fix: Some(Fix {
580 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
581 replacement: format!("{bq_prefix}\n"),
582 }),
583 });
584 }
585
586 if line_idx + 1 < num_lines {
589 let next_line = lines[line_idx + 1];
590 let next_is_blank = is_blank_in_context(next_line);
591 let next_excluded = ctx.line_info(line_idx + 2).is_some_and(|info| info.in_front_matter);
592
593 if !next_is_blank && !next_excluded && !next_line.trim().is_empty() {
594 let next_trimmed = next_line.trim_start();
598 let next_is_ordered_content = ORDERED_LIST_NON_ONE_RE.is_match(next_line)
599 || next_line.starts_with("1. ")
600 || (next_line.len() > next_trimmed.len()
601 && !next_trimmed.starts_with("- ")
602 && !next_trimmed.starts_with("* ")
603 && !next_trimmed.starts_with("+ ")); if !next_is_ordered_content {
606 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
607 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
608 warnings.push(LintWarning {
609 line: start_line,
610 column: start_col,
611 end_line,
612 end_column: end_col,
613 severity: Severity::Warning,
614 rule_name: Some(self.name().to_string()),
615 message: "List should be followed by blank line".to_string(),
616 fix: Some(Fix {
617 range: line_index.line_col_to_byte_range_with_length(line_num + 1, 1, 0),
618 replacement: format!("{bq_prefix}\n"),
619 }),
620 });
621 }
622 }
623 }
624 }
625 }
626 }
627
628 for &(start_line, end_line, ref prefix) in list_blocks {
629 if ctx
631 .line_info(start_line)
632 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
633 {
634 continue;
635 }
636
637 if start_line > 1 {
638 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
640
641 if !has_blank_separation && content_line > 0 {
643 let prev_line_str = lines[content_line - 1];
644 let is_prev_excluded = ctx
645 .line_info(content_line)
646 .is_some_and(|info| info.in_code_block || info.in_front_matter);
647 let prev_prefix = BLOCKQUOTE_PREFIX_RE
648 .find(prev_line_str)
649 .map_or(String::new(), |m| m.as_str().to_string());
650 let prefixes_match = prev_prefix.trim() == prefix.trim();
651
652 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
655 if !is_prev_excluded && prefixes_match && should_require {
656 let (start_line, start_col, end_line, end_col) =
658 calculate_line_range(start_line, lines[start_line - 1]);
659
660 warnings.push(LintWarning {
661 line: start_line,
662 column: start_col,
663 end_line,
664 end_column: end_col,
665 severity: Severity::Warning,
666 rule_name: Some(self.name().to_string()),
667 message: "List should be preceded by blank line".to_string(),
668 fix: Some(Fix {
669 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
670 replacement: format!("{prefix}\n"),
671 }),
672 });
673 }
674 }
675 }
676
677 if end_line < num_lines {
678 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
680
681 if !has_blank_separation && content_line > 0 {
683 let next_line_str = lines[content_line - 1];
684 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
687 || (content_line <= ctx.lines.len()
688 && ctx.lines[content_line - 1].in_code_block
689 && ctx.lines[content_line - 1].indent >= 2);
690 let next_prefix = BLOCKQUOTE_PREFIX_RE
691 .find(next_line_str)
692 .map_or(String::new(), |m| m.as_str().to_string());
693
694 let end_line_str = lines[end_line - 1];
699 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
700 .find(end_line_str)
701 .map_or(String::new(), |m| m.as_str().to_string());
702 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
703 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
704 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
705
706 let prefixes_match = next_prefix.trim() == prefix.trim();
707
708 if !is_next_excluded && prefixes_match && !exits_blockquote {
711 let (start_line_last, start_col_last, end_line_last, end_col_last) =
713 calculate_line_range(end_line, lines[end_line - 1]);
714
715 warnings.push(LintWarning {
716 line: start_line_last,
717 column: start_col_last,
718 end_line: end_line_last,
719 end_column: end_col_last,
720 severity: Severity::Warning,
721 rule_name: Some(self.name().to_string()),
722 message: "List should be followed by blank line".to_string(),
723 fix: Some(Fix {
724 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
725 replacement: format!("{prefix}\n"),
726 }),
727 });
728 }
729 }
730 }
731 }
732 Ok(warnings)
733 }
734}
735
736impl Rule for MD032BlanksAroundLists {
737 fn name(&self) -> &'static str {
738 "MD032"
739 }
740
741 fn description(&self) -> &'static str {
742 "Lists should be surrounded by blank lines"
743 }
744
745 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
746 let lines = ctx.raw_lines();
747 let line_index = &ctx.line_index;
748
749 if lines.is_empty() {
751 return Ok(Vec::new());
752 }
753
754 let list_blocks = self.convert_list_blocks(ctx);
755
756 if list_blocks.is_empty() {
757 return Ok(Vec::new());
758 }
759
760 let mut warnings = self.perform_checks(ctx, lines, &list_blocks, line_index)?;
761
762 if !self.config.allow_lazy_continuation {
767 let lazy_cont_lines = ctx.lazy_continuation_lines();
768
769 for lazy_info in lazy_cont_lines.iter() {
770 let line_num = lazy_info.line_num;
771
772 let is_within_block = list_blocks
776 .iter()
777 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
778
779 if !is_within_block {
780 continue;
781 }
782
783 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
785 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
786
787 let fix = if Self::should_apply_lazy_fix(ctx, line_num) {
789 Self::calculate_lazy_continuation_fix(ctx, line_num, lazy_info)
790 } else {
791 None
792 };
793
794 warnings.push(LintWarning {
795 line: start_line,
796 column: start_col,
797 end_line,
798 end_column: end_col,
799 severity: Severity::Warning,
800 rule_name: Some(self.name().to_string()),
801 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
802 fix,
803 });
804 }
805 }
806
807 Ok(warnings)
808 }
809
810 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
811 self.fix_with_structure_impl(ctx)
812 }
813
814 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
815 ctx.content.is_empty() || ctx.list_blocks.is_empty()
818 }
819
820 fn category(&self) -> RuleCategory {
821 RuleCategory::List
822 }
823
824 fn as_any(&self) -> &dyn std::any::Any {
825 self
826 }
827
828 fn default_config_section(&self) -> Option<(String, toml::Value)> {
829 use crate::rule_config_serde::RuleConfig;
830 let default_config = MD032Config::default();
831 let json_value = serde_json::to_value(&default_config).ok()?;
832 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
833
834 if let toml::Value::Table(table) = toml_value {
835 if !table.is_empty() {
836 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
837 } else {
838 None
839 }
840 } else {
841 None
842 }
843 }
844
845 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
846 where
847 Self: Sized,
848 {
849 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
850 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
851 }
852}
853
854impl MD032BlanksAroundLists {
855 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
857 let lines = ctx.raw_lines();
858 let num_lines = lines.len();
859 if num_lines == 0 {
860 return Ok(String::new());
861 }
862
863 let list_blocks = self.convert_list_blocks(ctx);
864 if list_blocks.is_empty() {
865 return Ok(ctx.content.to_string());
866 }
867
868 let mut lazy_fixes: std::collections::BTreeMap<usize, LazyContLine> = std::collections::BTreeMap::new();
871 if !self.config.allow_lazy_continuation {
872 let lazy_cont_lines = ctx.lazy_continuation_lines();
873 for lazy_info in lazy_cont_lines.iter() {
874 let line_num = lazy_info.line_num;
875 let is_within_block = list_blocks
877 .iter()
878 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
879 if !is_within_block {
880 continue;
881 }
882 if !Self::should_apply_lazy_fix(ctx, line_num) {
884 continue;
885 }
886 lazy_fixes.insert(line_num, lazy_info.clone());
887 }
888 }
889
890 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
891
892 for &(start_line, end_line, ref prefix) in &list_blocks {
894 if ctx.inline_config().is_rule_disabled("MD032", start_line) {
896 continue;
897 }
898
899 if ctx
901 .line_info(start_line)
902 .is_some_and(|info| info.in_html_comment || info.in_mdx_comment)
903 {
904 continue;
905 }
906
907 if start_line > 1 {
909 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
911
912 if !has_blank_separation && content_line > 0 {
914 let prev_line_str = lines[content_line - 1];
915 let is_prev_excluded = ctx
916 .line_info(content_line)
917 .is_some_and(|info| info.in_code_block || info.in_front_matter);
918 let prev_prefix = BLOCKQUOTE_PREFIX_RE
919 .find(prev_line_str)
920 .map_or(String::new(), |m| m.as_str().to_string());
921
922 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
923 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
925 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
927 insertions.insert(start_line, bq_prefix);
928 }
929 }
930 }
931
932 if end_line < num_lines {
934 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
936
937 if !has_blank_separation && content_line > 0 {
939 let next_line_str = lines[content_line - 1];
940 let is_next_excluded = ctx
942 .line_info(content_line)
943 .is_some_and(|info| info.in_code_block || info.in_front_matter)
944 || (content_line <= ctx.lines.len()
945 && ctx.lines[content_line - 1].in_code_block
946 && ctx.lines[content_line - 1].indent >= 2
947 && (ctx.lines[content_line - 1]
948 .content(ctx.content)
949 .trim()
950 .starts_with("```")
951 || ctx.lines[content_line - 1]
952 .content(ctx.content)
953 .trim()
954 .starts_with("~~~")));
955 let next_prefix = BLOCKQUOTE_PREFIX_RE
956 .find(next_line_str)
957 .map_or(String::new(), |m| m.as_str().to_string());
958
959 let end_line_str = lines[end_line - 1];
961 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
962 .find(end_line_str)
963 .map_or(String::new(), |m| m.as_str().to_string());
964 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
965 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
966 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
967
968 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
971 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
973 insertions.insert(end_line + 1, bq_prefix);
974 }
975 }
976 }
977 }
978
979 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
981 for (i, line) in lines.iter().enumerate() {
982 let current_line_num = i + 1;
983 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
984 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
985 {
986 result_lines.push(prefix_to_insert.clone());
987 }
988
989 if let Some(lazy_info) = lazy_fixes.get(¤t_line_num)
991 && !ctx.inline_config().is_rule_disabled("MD032", current_line_num)
992 {
993 let fixed_line = Self::apply_lazy_fix_to_line(line, lazy_info);
994 result_lines.push(fixed_line);
995 } else {
996 result_lines.push(line.to_string());
997 }
998 }
999
1000 let mut result = result_lines.join("\n");
1002 if ctx.content.ends_with('\n') {
1003 result.push('\n');
1004 }
1005 Ok(result)
1006 }
1007}
1008
1009fn is_blank_in_context(line: &str) -> bool {
1011 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
1014 line[m.end()..].trim().is_empty()
1016 } else {
1017 line.trim().is_empty()
1019 }
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024 use super::*;
1025 use crate::lint_context::LintContext;
1026 use crate::rule::Rule;
1027
1028 fn lint(content: &str) -> Vec<LintWarning> {
1029 let rule = MD032BlanksAroundLists::default();
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 rule.check(&ctx).expect("Lint check failed")
1032 }
1033
1034 fn fix(content: &str) -> String {
1035 let rule = MD032BlanksAroundLists::default();
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 rule.fix(&ctx).expect("Lint fix failed")
1038 }
1039
1040 fn check_warnings_have_fixes(content: &str) {
1042 let warnings = lint(content);
1043 for warning in &warnings {
1044 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
1045 }
1046 }
1047
1048 #[test]
1049 fn test_list_at_start() {
1050 let content = "- Item 1\n- Item 2\nText";
1053 let warnings = lint(content);
1054 assert_eq!(
1055 warnings.len(),
1056 0,
1057 "Trailing text is lazy continuation per CommonMark - no warning expected"
1058 );
1059 }
1060
1061 #[test]
1062 fn test_list_at_end() {
1063 let content = "Text\n- Item 1\n- Item 2";
1064 let warnings = lint(content);
1065 assert_eq!(
1066 warnings.len(),
1067 1,
1068 "Expected 1 warning for list at end without preceding blank line"
1069 );
1070 assert_eq!(
1071 warnings[0].line, 2,
1072 "Warning should be on the first line of the list (line 2)"
1073 );
1074 assert!(warnings[0].message.contains("preceded by blank line"));
1075
1076 check_warnings_have_fixes(content);
1078
1079 let fixed_content = fix(content);
1080 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
1081
1082 let warnings_after_fix = lint(&fixed_content);
1084 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1085 }
1086
1087 #[test]
1088 fn test_list_in_middle() {
1089 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
1092 let warnings = lint(content);
1093 assert_eq!(
1094 warnings.len(),
1095 1,
1096 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
1097 );
1098 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
1099 assert!(warnings[0].message.contains("preceded by blank line"));
1100
1101 check_warnings_have_fixes(content);
1103
1104 let fixed_content = fix(content);
1105 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
1106
1107 let warnings_after_fix = lint(&fixed_content);
1109 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1110 }
1111
1112 #[test]
1113 fn test_correct_spacing() {
1114 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1115 let warnings = lint(content);
1116 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1117
1118 let fixed_content = fix(content);
1119 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1120 }
1121
1122 #[test]
1123 fn test_list_with_content() {
1124 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1127 let warnings = lint(content);
1128 assert_eq!(
1129 warnings.len(),
1130 1,
1131 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1132 );
1133 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1134 assert!(warnings[0].message.contains("preceded by blank line"));
1135
1136 check_warnings_have_fixes(content);
1138
1139 let fixed_content = fix(content);
1140 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1141 assert_eq!(
1142 fixed_content, expected_fixed,
1143 "Fix did not produce the expected output. Got:\n{fixed_content}"
1144 );
1145
1146 let warnings_after_fix = lint(&fixed_content);
1148 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1149 }
1150
1151 #[test]
1152 fn test_nested_list() {
1153 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1155 let warnings = lint(content);
1156 assert_eq!(
1157 warnings.len(),
1158 1,
1159 "Nested list block needs preceding blank only. Got: {warnings:?}"
1160 );
1161 assert_eq!(warnings[0].line, 2);
1162 assert!(warnings[0].message.contains("preceded by blank line"));
1163
1164 check_warnings_have_fixes(content);
1166
1167 let fixed_content = fix(content);
1168 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1169
1170 let warnings_after_fix = lint(&fixed_content);
1172 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1173 }
1174
1175 #[test]
1176 fn test_list_with_internal_blanks() {
1177 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1179 let warnings = lint(content);
1180 assert_eq!(
1181 warnings.len(),
1182 1,
1183 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1184 );
1185 assert_eq!(warnings[0].line, 2);
1186 assert!(warnings[0].message.contains("preceded by blank line"));
1187
1188 check_warnings_have_fixes(content);
1190
1191 let fixed_content = fix(content);
1192 assert_eq!(
1193 fixed_content,
1194 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1195 );
1196
1197 let warnings_after_fix = lint(&fixed_content);
1199 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1200 }
1201
1202 #[test]
1203 fn test_ignore_code_blocks() {
1204 let content = "```\n- Not a list item\n```\nText";
1205 let warnings = lint(content);
1206 assert_eq!(warnings.len(), 0);
1207 let fixed_content = fix(content);
1208 assert_eq!(fixed_content, content);
1209 }
1210
1211 #[test]
1212 fn test_ignore_front_matter() {
1213 let content = "---\ntitle: Test\n---\n- List Item\nText";
1215 let warnings = lint(content);
1216 assert_eq!(
1217 warnings.len(),
1218 0,
1219 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1220 );
1221
1222 let fixed_content = fix(content);
1224 assert_eq!(fixed_content, content, "No changes when no warnings");
1225 }
1226
1227 #[test]
1228 fn test_multiple_lists() {
1229 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1234 let warnings = lint(content);
1235 assert!(
1237 !warnings.is_empty(),
1238 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1239 );
1240
1241 check_warnings_have_fixes(content);
1243
1244 let fixed_content = fix(content);
1245 let warnings_after_fix = lint(&fixed_content);
1247 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1248 }
1249
1250 #[test]
1251 fn test_adjacent_lists() {
1252 let content = "- List 1\n\n* List 2";
1253 let warnings = lint(content);
1254 assert_eq!(warnings.len(), 0);
1255 let fixed_content = fix(content);
1256 assert_eq!(fixed_content, content);
1257 }
1258
1259 #[test]
1260 fn test_list_in_blockquote() {
1261 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1263 let warnings = lint(content);
1264 assert_eq!(
1265 warnings.len(),
1266 1,
1267 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1268 );
1269 assert_eq!(warnings[0].line, 2);
1270
1271 check_warnings_have_fixes(content);
1273
1274 let fixed_content = fix(content);
1275 assert_eq!(
1277 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1278 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1279 );
1280
1281 let warnings_after_fix = lint(&fixed_content);
1283 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1284 }
1285
1286 #[test]
1287 fn test_ordered_list() {
1288 let content = "Text\n1. Item 1\n2. Item 2\nText";
1290 let warnings = lint(content);
1291 assert_eq!(warnings.len(), 1);
1292
1293 check_warnings_have_fixes(content);
1295
1296 let fixed_content = fix(content);
1297 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1298
1299 let warnings_after_fix = lint(&fixed_content);
1301 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1302 }
1303
1304 #[test]
1305 fn test_no_double_blank_fix() {
1306 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1309 assert_eq!(
1310 warnings.len(),
1311 0,
1312 "Should have no warnings - properly preceded, trailing is lazy"
1313 );
1314
1315 let fixed_content = fix(content);
1316 assert_eq!(
1317 fixed_content, content,
1318 "No fix needed when no warnings. Got:\n{fixed_content}"
1319 );
1320
1321 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1323 assert_eq!(warnings2.len(), 1);
1324 if !warnings2.is_empty() {
1325 assert_eq!(
1326 warnings2[0].line, 2,
1327 "Warning line for missing blank before should be the first line of the block"
1328 );
1329 }
1330
1331 check_warnings_have_fixes(content2);
1333
1334 let fixed_content2 = fix(content2);
1335 assert_eq!(
1336 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1337 "Fix added extra blank before. Got:\n{fixed_content2}"
1338 );
1339 }
1340
1341 #[test]
1342 fn test_empty_input() {
1343 let content = "";
1344 let warnings = lint(content);
1345 assert_eq!(warnings.len(), 0);
1346 let fixed_content = fix(content);
1347 assert_eq!(fixed_content, "");
1348 }
1349
1350 #[test]
1351 fn test_only_list() {
1352 let content = "- Item 1\n- Item 2";
1353 let warnings = lint(content);
1354 assert_eq!(warnings.len(), 0);
1355 let fixed_content = fix(content);
1356 assert_eq!(fixed_content, content);
1357 }
1358
1359 #[test]
1362 fn test_fix_complex_nested_blockquote() {
1363 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1365 let warnings = lint(content);
1366 assert_eq!(
1367 warnings.len(),
1368 1,
1369 "Should warn for missing preceding blank only. Got: {warnings:?}"
1370 );
1371
1372 check_warnings_have_fixes(content);
1374
1375 let fixed_content = fix(content);
1376 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1378 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1379
1380 let warnings_after_fix = lint(&fixed_content);
1381 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1382 }
1383
1384 #[test]
1385 fn test_fix_mixed_list_markers() {
1386 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1389 let warnings = lint(content);
1390 assert!(
1392 !warnings.is_empty(),
1393 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1394 );
1395
1396 check_warnings_have_fixes(content);
1398
1399 let fixed_content = fix(content);
1400 assert!(
1402 fixed_content.contains("Text\n\n-"),
1403 "Fix should add blank line before first list item"
1404 );
1405
1406 let warnings_after_fix = lint(&fixed_content);
1408 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1409 }
1410
1411 #[test]
1412 fn test_fix_ordered_list_with_different_numbers() {
1413 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1415 let warnings = lint(content);
1416 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1417
1418 check_warnings_have_fixes(content);
1420
1421 let fixed_content = fix(content);
1422 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1423 assert_eq!(
1424 fixed_content, expected,
1425 "Fix should handle ordered lists with non-sequential numbers"
1426 );
1427
1428 let warnings_after_fix = lint(&fixed_content);
1430 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1431 }
1432
1433 #[test]
1434 fn test_fix_list_with_code_blocks_inside() {
1435 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1437 let warnings = lint(content);
1438 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1439
1440 check_warnings_have_fixes(content);
1442
1443 let fixed_content = fix(content);
1444 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1445 assert_eq!(
1446 fixed_content, expected,
1447 "Fix should handle lists with internal code blocks"
1448 );
1449
1450 let warnings_after_fix = lint(&fixed_content);
1452 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1453 }
1454
1455 #[test]
1456 fn test_fix_deeply_nested_lists() {
1457 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1459 let warnings = lint(content);
1460 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1461
1462 check_warnings_have_fixes(content);
1464
1465 let fixed_content = fix(content);
1466 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1467 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1468
1469 let warnings_after_fix = lint(&fixed_content);
1471 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1472 }
1473
1474 #[test]
1475 fn test_fix_list_with_multiline_items() {
1476 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1479 let warnings = lint(content);
1480 assert_eq!(
1481 warnings.len(),
1482 1,
1483 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1484 );
1485
1486 check_warnings_have_fixes(content);
1488
1489 let fixed_content = fix(content);
1490 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1491 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1492
1493 let warnings_after_fix = lint(&fixed_content);
1495 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1496 }
1497
1498 #[test]
1499 fn test_fix_list_at_document_boundaries() {
1500 let content1 = "- Item 1\n- Item 2";
1502 let warnings1 = lint(content1);
1503 assert_eq!(
1504 warnings1.len(),
1505 0,
1506 "List at document start should not need blank before"
1507 );
1508 let fixed1 = fix(content1);
1509 assert_eq!(fixed1, content1, "No fix needed for list at start");
1510
1511 let content2 = "Text\n- Item 1\n- Item 2";
1513 let warnings2 = lint(content2);
1514 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1515 check_warnings_have_fixes(content2);
1516 let fixed2 = fix(content2);
1517 assert_eq!(
1518 fixed2, "Text\n\n- Item 1\n- Item 2",
1519 "Should add blank before list at end"
1520 );
1521 }
1522
1523 #[test]
1524 fn test_fix_preserves_existing_blank_lines() {
1525 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1526 let warnings = lint(content);
1527 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1528 let fixed_content = fix(content);
1529 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1530 }
1531
1532 #[test]
1533 fn test_fix_handles_tabs_and_spaces() {
1534 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1537 let warnings = lint(content);
1538 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1540
1541 check_warnings_have_fixes(content);
1543
1544 let fixed_content = fix(content);
1545 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1548 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1549
1550 let warnings_after_fix = lint(&fixed_content);
1552 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1553 }
1554
1555 #[test]
1556 fn test_fix_warning_objects_have_correct_ranges() {
1557 let content = "Text\n- Item 1\n- Item 2\nText";
1559 let warnings = lint(content);
1560 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1561
1562 for warning in &warnings {
1564 assert!(warning.fix.is_some(), "Warning should have fix");
1565 let fix = warning.fix.as_ref().unwrap();
1566 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1567 assert!(
1568 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1569 "Fix should have replacement or be insertion"
1570 );
1571 }
1572 }
1573
1574 #[test]
1575 fn test_fix_idempotent() {
1576 let content = "Text\n- Item 1\n- Item 2\nText";
1578
1579 let fixed_once = fix(content);
1581 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1582
1583 let fixed_twice = fix(&fixed_once);
1585 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1586
1587 let warnings_after_fix = lint(&fixed_once);
1589 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1590 }
1591
1592 #[test]
1593 fn test_fix_with_normalized_line_endings() {
1594 let content = "Text\n- Item 1\n- Item 2\nText";
1598 let warnings = lint(content);
1599 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1600
1601 check_warnings_have_fixes(content);
1603
1604 let fixed_content = fix(content);
1605 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1607 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1608 }
1609
1610 #[test]
1611 fn test_fix_preserves_final_newline() {
1612 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1615 let fixed_with_newline = fix(content_with_newline);
1616 assert!(
1617 fixed_with_newline.ends_with('\n'),
1618 "Fix should preserve final newline when present"
1619 );
1620 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1622
1623 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1625 let fixed_without_newline = fix(content_without_newline);
1626 assert!(
1627 !fixed_without_newline.ends_with('\n'),
1628 "Fix should not add final newline when not present"
1629 );
1630 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1632 }
1633
1634 #[test]
1635 fn test_fix_multiline_list_items_no_indent() {
1636 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";
1637
1638 let warnings = lint(content);
1639 assert_eq!(
1641 warnings.len(),
1642 0,
1643 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1644 );
1645
1646 let fixed_content = fix(content);
1647 assert_eq!(
1649 fixed_content, content,
1650 "Should not modify correctly formatted multi-line list items"
1651 );
1652 }
1653
1654 #[test]
1655 fn test_nested_list_with_lazy_continuation() {
1656 let content = r#"# Test
1662
1663- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1664 1. Switch/case dispatcher statements (original Phase 3.2)
1665 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1666`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1667 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1668 references"#;
1669
1670 let warnings = lint(content);
1671 let md032_warnings: Vec<_> = warnings
1674 .iter()
1675 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1676 .collect();
1677 assert_eq!(
1678 md032_warnings.len(),
1679 0,
1680 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1681 );
1682 }
1683
1684 #[test]
1685 fn test_pipes_in_code_spans_not_detected_as_table() {
1686 let content = r#"# Test
1688
1689- Item with `a | b` inline code
1690 - Nested item should work
1691
1692"#;
1693
1694 let warnings = lint(content);
1695 let md032_warnings: Vec<_> = warnings
1696 .iter()
1697 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1698 .collect();
1699 assert_eq!(
1700 md032_warnings.len(),
1701 0,
1702 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1703 );
1704 }
1705
1706 #[test]
1707 fn test_multiple_code_spans_with_pipes() {
1708 let content = r#"# Test
1710
1711- Item with `a | b` and `c || d` operators
1712 - Nested item should work
1713
1714"#;
1715
1716 let warnings = lint(content);
1717 let md032_warnings: Vec<_> = warnings
1718 .iter()
1719 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1720 .collect();
1721 assert_eq!(
1722 md032_warnings.len(),
1723 0,
1724 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1725 );
1726 }
1727
1728 #[test]
1729 fn test_actual_table_breaks_list() {
1730 let content = r#"# Test
1732
1733- Item before table
1734
1735| Col1 | Col2 |
1736|------|------|
1737| A | B |
1738
1739- Item after table
1740
1741"#;
1742
1743 let warnings = lint(content);
1744 let md032_warnings: Vec<_> = warnings
1746 .iter()
1747 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1748 .collect();
1749 assert_eq!(
1750 md032_warnings.len(),
1751 0,
1752 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_thematic_break_not_lazy_continuation() {
1758 let content = r#"- Item 1
1761- Item 2
1762***
1763
1764More text.
1765"#;
1766
1767 let warnings = lint(content);
1768 let md032_warnings: Vec<_> = warnings
1769 .iter()
1770 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1771 .collect();
1772 assert_eq!(
1773 md032_warnings.len(),
1774 1,
1775 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1776 );
1777 assert!(
1778 md032_warnings[0].message.contains("followed by blank line"),
1779 "Warning should be about missing blank after list"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_thematic_break_with_blank_line() {
1785 let content = r#"- Item 1
1787- Item 2
1788
1789***
1790
1791More text.
1792"#;
1793
1794 let warnings = lint(content);
1795 let md032_warnings: Vec<_> = warnings
1796 .iter()
1797 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1798 .collect();
1799 assert_eq!(
1800 md032_warnings.len(),
1801 0,
1802 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_various_thematic_break_styles() {
1808 for hr in ["---", "***", "___"] {
1813 let content = format!(
1814 r#"- Item 1
1815- Item 2
1816{hr}
1817
1818More text.
1819"#
1820 );
1821
1822 let warnings = lint(&content);
1823 let md032_warnings: Vec<_> = warnings
1824 .iter()
1825 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1826 .collect();
1827 assert_eq!(
1828 md032_warnings.len(),
1829 1,
1830 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1831 );
1832 }
1833 }
1834
1835 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1838 let rule = MD032BlanksAroundLists::from_config_struct(config);
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 rule.check(&ctx).expect("Lint check failed")
1841 }
1842
1843 fn fix_with_config(content: &str, config: MD032Config) -> String {
1844 let rule = MD032BlanksAroundLists::from_config_struct(config);
1845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1846 rule.fix(&ctx).expect("Lint fix failed")
1847 }
1848
1849 #[test]
1850 fn test_lazy_continuation_allowed_by_default() {
1851 let content = "# Heading\n\n1. List\nSome text.";
1853 let warnings = lint(content);
1854 assert_eq!(
1855 warnings.len(),
1856 0,
1857 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_lazy_continuation_disallowed() {
1863 let content = "# Heading\n\n1. List\nSome text.";
1865 let config = MD032Config {
1866 allow_lazy_continuation: false,
1867 };
1868 let warnings = lint_with_config(content, config);
1869 assert_eq!(
1870 warnings.len(),
1871 1,
1872 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1873 );
1874 assert!(
1875 warnings[0].message.contains("Lazy continuation"),
1876 "Warning message should mention lazy continuation"
1877 );
1878 assert_eq!(warnings[0].line, 4, "Warning should be on the lazy line");
1879 }
1880
1881 #[test]
1882 fn test_lazy_continuation_fix() {
1883 let content = "# Heading\n\n1. List\nSome text.";
1885 let config = MD032Config {
1886 allow_lazy_continuation: false,
1887 };
1888 let fixed = fix_with_config(content, config.clone());
1889 assert_eq!(
1891 fixed, "# Heading\n\n1. List\n Some text.",
1892 "Fix should add proper indentation to lazy continuation"
1893 );
1894
1895 let warnings_after = lint_with_config(&fixed, config);
1897 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1898 }
1899
1900 #[test]
1901 fn test_lazy_continuation_multiple_lines() {
1902 let content = "- Item 1\nLine 2\nLine 3";
1904 let config = MD032Config {
1905 allow_lazy_continuation: false,
1906 };
1907 let warnings = lint_with_config(content, config.clone());
1908 assert_eq!(
1910 warnings.len(),
1911 2,
1912 "Should warn for each lazy continuation line. Got: {warnings:?}"
1913 );
1914
1915 let fixed = fix_with_config(content, config.clone());
1916 assert_eq!(
1918 fixed, "- Item 1\n Line 2\n Line 3",
1919 "Fix should add proper indentation to lazy continuation lines"
1920 );
1921
1922 let warnings_after = lint_with_config(&fixed, config);
1924 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1925 }
1926
1927 #[test]
1928 fn test_lazy_continuation_with_indented_content() {
1929 let content = "- Item 1\n Indented content\nLazy text";
1931 let config = MD032Config {
1932 allow_lazy_continuation: false,
1933 };
1934 let warnings = lint_with_config(content, config);
1935 assert_eq!(
1936 warnings.len(),
1937 1,
1938 "Should warn for lazy text after indented content. Got: {warnings:?}"
1939 );
1940 }
1941
1942 #[test]
1943 fn test_lazy_continuation_properly_separated() {
1944 let content = "- Item 1\n\nSome text.";
1946 let config = MD032Config {
1947 allow_lazy_continuation: false,
1948 };
1949 let warnings = lint_with_config(content, config);
1950 assert_eq!(
1951 warnings.len(),
1952 0,
1953 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1954 );
1955 }
1956
1957 #[test]
1960 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1961 let content = "1) First item\nLazy continuation";
1963 let config = MD032Config {
1964 allow_lazy_continuation: false,
1965 };
1966 let warnings = lint_with_config(content, config.clone());
1967 assert_eq!(
1968 warnings.len(),
1969 1,
1970 "Should warn for lazy continuation with parenthesis marker"
1971 );
1972
1973 let fixed = fix_with_config(content, config);
1974 assert_eq!(fixed, "1) First item\n Lazy continuation");
1976 }
1977
1978 #[test]
1979 fn test_lazy_continuation_followed_by_another_list() {
1980 let content = "- Item 1\nSome text\n- Item 2";
1986 let config = MD032Config {
1987 allow_lazy_continuation: false,
1988 };
1989 let warnings = lint_with_config(content, config);
1990 assert_eq!(
1992 warnings.len(),
1993 1,
1994 "Should warn about lazy continuation within list. Got: {warnings:?}"
1995 );
1996 assert!(
1997 warnings[0].message.contains("Lazy continuation"),
1998 "Warning should be about lazy continuation"
1999 );
2000 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
2001 }
2002
2003 #[test]
2004 fn test_lazy_continuation_multiple_in_document() {
2005 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
2010 let config = MD032Config {
2011 allow_lazy_continuation: false,
2012 };
2013 let warnings = lint_with_config(content, config.clone());
2014 assert_eq!(
2016 warnings.len(),
2017 2,
2018 "Should warn for both lazy continuations. Got: {warnings:?}"
2019 );
2020
2021 let fixed = fix_with_config(content, config.clone());
2022 assert!(
2024 fixed.contains(" Lazy 1"),
2025 "Fixed content should have indented 'Lazy 1'. Got: {fixed:?}"
2026 );
2027 assert!(
2028 fixed.contains(" Lazy 2"),
2029 "Fixed content should have indented 'Lazy 2'. Got: {fixed:?}"
2030 );
2031
2032 let warnings_after = lint_with_config(&fixed, config);
2033 assert_eq!(
2035 warnings_after.len(),
2036 0,
2037 "All warnings should be fixed after auto-fix. Got: {warnings_after:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn test_lazy_continuation_end_of_document_no_newline() {
2043 let content = "- Item\nNo trailing newline";
2045 let config = MD032Config {
2046 allow_lazy_continuation: false,
2047 };
2048 let warnings = lint_with_config(content, config.clone());
2049 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
2050
2051 let fixed = fix_with_config(content, config);
2052 assert_eq!(fixed, "- Item\n No trailing newline");
2054 }
2055
2056 #[test]
2057 fn test_lazy_continuation_thematic_break_still_needs_blank() {
2058 let content = "- Item 1\n---";
2061 let config = MD032Config {
2062 allow_lazy_continuation: false,
2063 };
2064 let warnings = lint_with_config(content, config.clone());
2065 assert_eq!(
2067 warnings.len(),
2068 1,
2069 "List should need blank line before thematic break. Got: {warnings:?}"
2070 );
2071
2072 let fixed = fix_with_config(content, config);
2074 assert_eq!(fixed, "- Item 1\n\n---");
2075 }
2076
2077 #[test]
2078 fn test_lazy_continuation_heading_not_flagged() {
2079 let content = "- Item 1\n# Heading";
2082 let config = MD032Config {
2083 allow_lazy_continuation: false,
2084 };
2085 let warnings = lint_with_config(content, config);
2086 assert!(
2089 warnings.iter().all(|w| !w.message.contains("lazy")),
2090 "Heading should not trigger lazy continuation warning"
2091 );
2092 }
2093
2094 #[test]
2095 fn test_lazy_continuation_mixed_list_types() {
2096 let content = "- Unordered\n1. Ordered\nLazy text";
2098 let config = MD032Config {
2099 allow_lazy_continuation: false,
2100 };
2101 let warnings = lint_with_config(content, config.clone());
2102 assert!(!warnings.is_empty(), "Should warn about structure issues");
2103 }
2104
2105 #[test]
2106 fn test_lazy_continuation_deep_nesting() {
2107 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
2109 let config = MD032Config {
2110 allow_lazy_continuation: false,
2111 };
2112 let warnings = lint_with_config(content, config.clone());
2113 assert!(
2114 !warnings.is_empty(),
2115 "Should warn about lazy continuation after nested list"
2116 );
2117
2118 let fixed = fix_with_config(content, config.clone());
2119 let warnings_after = lint_with_config(&fixed, config);
2120 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
2121 }
2122
2123 #[test]
2124 fn test_lazy_continuation_with_emphasis_in_text() {
2125 let content = "- Item\n*emphasized* continuation";
2127 let config = MD032Config {
2128 allow_lazy_continuation: false,
2129 };
2130 let warnings = lint_with_config(content, config.clone());
2131 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2132
2133 let fixed = fix_with_config(content, config);
2134 assert_eq!(fixed, "- Item\n *emphasized* continuation");
2136 }
2137
2138 #[test]
2139 fn test_lazy_continuation_with_code_span() {
2140 let content = "- Item\n`code` continuation";
2142 let config = MD032Config {
2143 allow_lazy_continuation: false,
2144 };
2145 let warnings = lint_with_config(content, config.clone());
2146 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2147
2148 let fixed = fix_with_config(content, config);
2149 assert_eq!(fixed, "- Item\n `code` continuation");
2151 }
2152
2153 #[test]
2160 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2161 let content = r#"1. Create a new Chat conversation:
2164 - On the sidebar, select **New Chat**.
2165 - In the box, type `/new`.
2166 A new Chat conversation replaces the previous one.
21671. Under the Chat text box, turn off the toggle."#;
2168 let config = MD032Config {
2169 allow_lazy_continuation: false,
2170 };
2171 let warnings = lint_with_config(content, config);
2172 let lazy_warnings: Vec<_> = warnings
2174 .iter()
2175 .filter(|w| w.message.contains("Lazy continuation"))
2176 .collect();
2177 assert!(
2178 !lazy_warnings.is_empty(),
2179 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2180 );
2181 assert!(
2182 lazy_warnings.iter().any(|w| w.line == 4),
2183 "Should warn on line 4. Got: {lazy_warnings:?}"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2189 let content = r#"- `field`: Is the specific key:
2192 - `password`: Accesses the password.
2193 - `api_key`: Accesses the api_key.
2194 `token`: Specifies which ID token to use.
2195- `version_id`: Is the unique identifier."#;
2196 let config = MD032Config {
2197 allow_lazy_continuation: false,
2198 };
2199 let warnings = lint_with_config(content, config);
2200 let lazy_warnings: Vec<_> = warnings
2202 .iter()
2203 .filter(|w| w.message.contains("Lazy continuation"))
2204 .collect();
2205 assert!(
2206 !lazy_warnings.is_empty(),
2207 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2208 );
2209 assert!(
2210 lazy_warnings.iter().any(|w| w.line == 4),
2211 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2212 );
2213 }
2214
2215 #[test]
2216 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2217 let content = r#"- Check out the branch, and test locally.
2219 - If the MR requires significant modifications:
2220 - **Skip local testing** and review instead.
2221 - **Request verification** from the author.
2222 - **Identify the minimal change** needed.
2223 Your testing might result in opportunities.
2224- If you don't understand, _say so_."#;
2225 let config = MD032Config {
2226 allow_lazy_continuation: false,
2227 };
2228 let warnings = lint_with_config(content, config);
2229 let lazy_warnings: Vec<_> = warnings
2231 .iter()
2232 .filter(|w| w.message.contains("Lazy continuation"))
2233 .collect();
2234 assert!(
2235 !lazy_warnings.is_empty(),
2236 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2237 );
2238 assert!(
2239 lazy_warnings.iter().any(|w| w.line == 6),
2240 "Should warn on line 6. Got: {lazy_warnings:?}"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_issue295_ordered_list_nested_bullets_continuation() {
2246 let content = r#"# Test
2249
22501. First item.
2251 - Nested A.
2252 - Nested B.
2253 Continuation at outer level.
22541. Second item."#;
2255 let config = MD032Config {
2256 allow_lazy_continuation: false,
2257 };
2258 let warnings = lint_with_config(content, config);
2259 let lazy_warnings: Vec<_> = warnings
2261 .iter()
2262 .filter(|w| w.message.contains("Lazy continuation"))
2263 .collect();
2264 assert!(
2265 !lazy_warnings.is_empty(),
2266 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2267 );
2268 assert!(
2270 lazy_warnings.iter().any(|w| w.line == 6),
2271 "Should warn on line 6. Got: {lazy_warnings:?}"
2272 );
2273 }
2274
2275 #[test]
2276 fn test_issue295_multiple_lazy_lines_after_nested() {
2277 let content = r#"1. The device client receives a response.
2279 - Those defined by OAuth Framework.
2280 - Those specific to device authorization.
2281 Those error responses are described below.
2282 For more information on each response,
2283 see the documentation.
22841. Next step in the process."#;
2285 let config = MD032Config {
2286 allow_lazy_continuation: false,
2287 };
2288 let warnings = lint_with_config(content, config);
2289 let lazy_warnings: Vec<_> = warnings
2291 .iter()
2292 .filter(|w| w.message.contains("Lazy continuation"))
2293 .collect();
2294 assert!(
2295 lazy_warnings.len() >= 3,
2296 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2297 lazy_warnings.len()
2298 );
2299 }
2300
2301 #[test]
2302 fn test_issue295_properly_indented_not_lazy() {
2303 let content = r#"1. First item.
2305 - Nested A.
2306 - Nested B.
2307
2308 Properly indented continuation.
23091. Second item."#;
2310 let config = MD032Config {
2311 allow_lazy_continuation: false,
2312 };
2313 let warnings = lint_with_config(content, config);
2314 let lazy_warnings: Vec<_> = warnings
2316 .iter()
2317 .filter(|w| w.message.contains("Lazy continuation"))
2318 .collect();
2319 assert_eq!(
2320 lazy_warnings.len(),
2321 0,
2322 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2323 );
2324 }
2325
2326 #[test]
2333 fn test_html_comment_before_list_with_preceding_blank() {
2334 let content = "Some text.\n\n<!-- comment -->\n- List item";
2337 let warnings = lint(content);
2338 assert_eq!(
2339 warnings.len(),
2340 0,
2341 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2342 );
2343 }
2344
2345 #[test]
2346 fn test_html_comment_after_list_with_following_blank() {
2347 let content = "- List item\n<!-- comment -->\n\nSome text.";
2349 let warnings = lint(content);
2350 assert_eq!(
2351 warnings.len(),
2352 0,
2353 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2354 );
2355 }
2356
2357 #[test]
2358 fn test_list_inside_html_comment_ignored() {
2359 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2361 let warnings = lint(content);
2362 assert_eq!(
2363 warnings.len(),
2364 0,
2365 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2366 );
2367 }
2368
2369 #[test]
2370 fn test_multiline_html_comment_before_list() {
2371 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2373 let warnings = lint(content);
2374 assert_eq!(
2375 warnings.len(),
2376 0,
2377 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2378 );
2379 }
2380
2381 #[test]
2382 fn test_no_blank_before_html_comment_still_warns() {
2383 let content = "Some text.\n<!-- comment -->\n- List item";
2385 let warnings = lint(content);
2386 assert_eq!(
2387 warnings.len(),
2388 1,
2389 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2390 );
2391 assert!(
2392 warnings[0].message.contains("preceded by blank line"),
2393 "Should be 'preceded by blank line' warning"
2394 );
2395 }
2396
2397 #[test]
2398 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2399 let content = "- List item\n<!-- comment -->\nSome text.";
2402 let warnings = lint(content);
2403 assert_eq!(
2404 warnings.len(),
2405 0,
2406 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_list_followed_by_heading_through_comment_should_warn() {
2412 let content = "- List item\n<!-- comment -->\n# Heading";
2414 let warnings = lint(content);
2415 assert!(
2418 warnings.len() <= 1,
2419 "Should handle heading after comment gracefully. Got: {warnings:?}"
2420 );
2421 }
2422
2423 #[test]
2424 fn test_html_comment_between_list_and_text_both_directions() {
2425 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2427 let warnings = lint(content);
2428 assert_eq!(
2429 warnings.len(),
2430 0,
2431 "Should not warn with proper separation through comments. Got: {warnings:?}"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2437 let content = "Text.\n\n<!-- comment -->\n- Item";
2439 let fixed = fix(content);
2440 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2441 }
2442
2443 #[test]
2444 fn test_html_comment_fix_adds_blank_when_needed() {
2445 let content = "Text.\n<!-- comment -->\n- Item";
2448 let fixed = fix(content);
2449 assert!(
2450 fixed.contains("<!-- comment -->\n\n- Item"),
2451 "Fix should add blank line before list. Got: {fixed}"
2452 );
2453 }
2454
2455 #[test]
2456 fn test_ordered_list_inside_html_comment() {
2457 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2459 let warnings = lint(content);
2460 assert_eq!(
2461 warnings.len(),
2462 0,
2463 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2464 );
2465 }
2466
2467 #[test]
2474 fn test_blockquote_list_exit_no_warning() {
2475 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2477 let warnings = lint(content);
2478 assert_eq!(
2479 warnings.len(),
2480 0,
2481 "Should not warn when exiting blockquote. Got: {warnings:?}"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_nested_blockquote_list_exit() {
2487 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2489 let warnings = lint(content);
2490 assert_eq!(
2491 warnings.len(),
2492 0,
2493 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2494 );
2495 }
2496
2497 #[test]
2498 fn test_blockquote_same_level_no_warning() {
2499 let content = "> - item 1\n> - item 2\n> Text after";
2502 let warnings = lint(content);
2503 assert_eq!(
2504 warnings.len(),
2505 0,
2506 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2507 );
2508 }
2509
2510 #[test]
2511 fn test_blockquote_list_with_special_chars() {
2512 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2514 let warnings = lint(content);
2515 assert_eq!(
2516 warnings.len(),
2517 0,
2518 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2519 );
2520 }
2521
2522 #[test]
2523 fn test_lazy_continuation_whitespace_only_line() {
2524 let content = "- Item\n \nText after whitespace-only line";
2527 let config = MD032Config {
2528 allow_lazy_continuation: false,
2529 };
2530 let warnings = lint_with_config(content, config);
2531 assert_eq!(
2533 warnings.len(),
2534 0,
2535 "Whitespace-only line IS a separator in CommonMark. Got: {warnings:?}"
2536 );
2537 }
2538
2539 #[test]
2540 fn test_lazy_continuation_blockquote_context() {
2541 let content = "> - Item\n> Lazy in quote";
2543 let config = MD032Config {
2544 allow_lazy_continuation: false,
2545 };
2546 let warnings = lint_with_config(content, config);
2547 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2550 }
2551
2552 #[test]
2553 fn test_lazy_continuation_fix_preserves_content() {
2554 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2556 let config = MD032Config {
2557 allow_lazy_continuation: false,
2558 };
2559 let fixed = fix_with_config(content, config);
2560 assert!(fixed.contains("<>&"), "Should preserve special chars");
2561 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2562 assert_eq!(fixed, "- Item with special chars: <>&\n Continuation with: \"quotes\"");
2564 }
2565
2566 #[test]
2567 fn test_lazy_continuation_fix_idempotent() {
2568 let content = "- Item\nLazy";
2570 let config = MD032Config {
2571 allow_lazy_continuation: false,
2572 };
2573 let fixed_once = fix_with_config(content, config.clone());
2574 let fixed_twice = fix_with_config(&fixed_once, config);
2575 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2576 }
2577
2578 #[test]
2579 fn test_lazy_continuation_config_default_allows() {
2580 let content = "- Item\nLazy text that continues";
2582 let default_config = MD032Config::default();
2583 assert!(
2584 default_config.allow_lazy_continuation,
2585 "Default should allow lazy continuation"
2586 );
2587 let warnings = lint_with_config(content, default_config);
2588 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2589 }
2590
2591 #[test]
2592 fn test_lazy_continuation_after_multi_line_item() {
2593 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2595 let config = MD032Config {
2596 allow_lazy_continuation: false,
2597 };
2598 let warnings = lint_with_config(content, config.clone());
2599 assert_eq!(
2600 warnings.len(),
2601 1,
2602 "Should warn only for the lazy line, not the indented line"
2603 );
2604 }
2605
2606 #[test]
2608 fn test_blockquote_list_with_continuation_and_nested() {
2609 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2612 let warnings = lint(content);
2613 assert_eq!(
2614 warnings.len(),
2615 0,
2616 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2617 );
2618 }
2619
2620 #[test]
2621 fn test_blockquote_list_simple() {
2622 let content = "> - item 1\n> - item 2";
2624 let warnings = lint(content);
2625 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2626 }
2627
2628 #[test]
2629 fn test_blockquote_list_with_continuation_only() {
2630 let content = "> - item 1\n> continuation\n> - item 2";
2632 let warnings = lint(content);
2633 assert_eq!(
2634 warnings.len(),
2635 0,
2636 "Blockquoted list with continuation should have no warnings"
2637 );
2638 }
2639
2640 #[test]
2641 fn test_blockquote_list_with_lazy_continuation() {
2642 let content = "> - item 1\n> lazy continuation\n> - item 2";
2644 let warnings = lint(content);
2645 assert_eq!(
2646 warnings.len(),
2647 0,
2648 "Blockquoted list with lazy continuation should have no warnings"
2649 );
2650 }
2651
2652 #[test]
2653 fn test_nested_blockquote_list() {
2654 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2656 let warnings = lint(content);
2657 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2658 }
2659
2660 #[test]
2661 fn test_blockquote_list_needs_preceding_blank() {
2662 let content = "> Text before\n> - item 1\n> - item 2";
2664 let warnings = lint(content);
2665 assert_eq!(
2666 warnings.len(),
2667 1,
2668 "Should warn for missing blank before blockquoted list"
2669 );
2670 }
2671
2672 #[test]
2673 fn test_blockquote_list_properly_separated() {
2674 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2676 let warnings = lint(content);
2677 assert_eq!(
2678 warnings.len(),
2679 0,
2680 "Properly separated blockquoted list should have no warnings"
2681 );
2682 }
2683
2684 #[test]
2685 fn test_blockquote_ordered_list() {
2686 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2688 let warnings = lint(content);
2689 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2690 }
2691
2692 #[test]
2693 fn test_blockquote_list_with_empty_blockquote_line() {
2694 let content = "> - item 1\n>\n> - item 2";
2696 let warnings = lint(content);
2697 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2698 }
2699
2700 #[test]
2702 fn test_blockquote_list_multi_paragraph_items() {
2703 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2706 let warnings = lint(content);
2707 assert_eq!(
2708 warnings.len(),
2709 0,
2710 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2711 );
2712 }
2713
2714 #[test]
2716 fn test_blockquote_ordered_list_multi_paragraph_items() {
2717 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2718 let warnings = lint(content);
2719 assert_eq!(
2720 warnings.len(),
2721 0,
2722 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2723 );
2724 }
2725
2726 #[test]
2728 fn test_blockquote_list_multiple_continuations() {
2729 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2730 let warnings = lint(content);
2731 assert_eq!(
2732 warnings.len(),
2733 0,
2734 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2735 );
2736 }
2737
2738 #[test]
2740 fn test_nested_blockquote_multi_paragraph_list() {
2741 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2742 let warnings = lint(content);
2743 assert_eq!(
2744 warnings.len(),
2745 0,
2746 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2747 );
2748 }
2749
2750 #[test]
2752 fn test_triple_nested_blockquote_multi_paragraph_list() {
2753 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2754 let warnings = lint(content);
2755 assert_eq!(
2756 warnings.len(),
2757 0,
2758 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2759 );
2760 }
2761
2762 #[test]
2764 fn test_blockquote_list_last_item_continuation() {
2765 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2766 let warnings = lint(content);
2767 assert_eq!(
2768 warnings.len(),
2769 0,
2770 "Last item with continuation should have no warnings. Got: {warnings:?}"
2771 );
2772 }
2773
2774 #[test]
2776 fn test_blockquote_list_first_item_only_continuation() {
2777 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2778 let warnings = lint(content);
2779 assert_eq!(
2780 warnings.len(),
2781 0,
2782 "Single item with continuation should have no warnings. Got: {warnings:?}"
2783 );
2784 }
2785
2786 #[test]
2790 fn test_blockquote_level_change_breaks_list() {
2791 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2793 let warnings = lint(content);
2794 assert!(
2798 warnings.len() <= 2,
2799 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2800 );
2801 }
2802
2803 #[test]
2805 fn test_exit_blockquote_needs_blank_before_list() {
2806 let content = "> Blockquote text\n\n- List outside blockquote\n";
2808 let warnings = lint(content);
2809 assert_eq!(
2810 warnings.len(),
2811 0,
2812 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2813 );
2814
2815 let content2 = "> Blockquote text\n- List outside blockquote\n";
2819 let warnings2 = lint(content2);
2820 assert!(
2822 warnings2.len() <= 1,
2823 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2824 );
2825 }
2826
2827 #[test]
2829 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2830 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2832 let warnings = lint(content_dash);
2833 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2834
2835 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2837 let warnings = lint(content_asterisk);
2838 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2839
2840 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2842 let warnings = lint(content_plus);
2843 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2844 }
2845
2846 #[test]
2848 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2849 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2850 let warnings = lint(content);
2851 assert_eq!(
2852 warnings.len(),
2853 0,
2854 "Parenthesis ordered markers should work. Got: {warnings:?}"
2855 );
2856 }
2857
2858 #[test]
2860 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2861 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2863 let warnings = lint(content);
2864 assert_eq!(
2865 warnings.len(),
2866 0,
2867 "Multi-digit ordered list should work. Got: {warnings:?}"
2868 );
2869 }
2870
2871 #[test]
2873 fn test_blockquote_multi_paragraph_with_formatting() {
2874 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2875 let warnings = lint(content);
2876 assert_eq!(
2877 warnings.len(),
2878 0,
2879 "Continuation with inline formatting should work. Got: {warnings:?}"
2880 );
2881 }
2882
2883 #[test]
2885 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2886 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2887 let warnings = lint(content);
2888 assert_eq!(
2889 warnings.len(),
2890 0,
2891 "All items with continuations should work. Got: {warnings:?}"
2892 );
2893 }
2894
2895 #[test]
2897 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2898 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2899 let warnings = lint(content);
2900 assert_eq!(
2901 warnings.len(),
2902 0,
2903 "Lowercase continuation should work. Got: {warnings:?}"
2904 );
2905 }
2906
2907 #[test]
2909 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2910 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2911 let warnings = lint(content);
2912 assert_eq!(
2913 warnings.len(),
2914 0,
2915 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2916 );
2917 }
2918
2919 #[test]
2921 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2922 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2924 let warnings = lint(content);
2925 assert!(
2927 warnings.len() <= 1,
2928 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2929 );
2930 }
2931
2932 #[test]
2934 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2935 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2937 let warnings = lint(content);
2938 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2939 }
2940
2941 #[test]
2942 fn test_blockquote_list_varying_spaces_after_marker() {
2943 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2945 let warnings = lint(content);
2946 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2947 }
2948
2949 #[test]
2950 fn test_deeply_nested_blockquote_list() {
2951 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2953 let warnings = lint(content);
2954 assert_eq!(
2955 warnings.len(),
2956 0,
2957 "Deeply nested blockquote list should have no warnings"
2958 );
2959 }
2960
2961 #[test]
2962 fn test_blockquote_level_change_in_list() {
2963 let content = "> - item 1\n>> - deeper item\n> - item 2";
2965 let warnings = lint(content);
2968 assert!(
2969 !warnings.is_empty(),
2970 "Blockquote level change should break list and trigger warnings"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_blockquote_list_with_code_span() {
2976 let content = "> - item with `code`\n> continuation\n> - item 2";
2978 let warnings = lint(content);
2979 assert_eq!(
2980 warnings.len(),
2981 0,
2982 "Blockquote list with code span should have no warnings"
2983 );
2984 }
2985
2986 #[test]
2987 fn test_blockquote_list_at_document_end() {
2988 let content = "> Some text\n>\n> - item 1\n> - item 2";
2990 let warnings = lint(content);
2991 assert_eq!(
2992 warnings.len(),
2993 0,
2994 "Blockquote list at document end should have no warnings"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_fix_preserves_blockquote_prefix_before_list() {
3000 let content = "> Text before
3002> - Item 1
3003> - Item 2";
3004 let fixed = fix(content);
3005
3006 let expected = "> Text before
3008>
3009> - Item 1
3010> - Item 2";
3011 assert_eq!(
3012 fixed, expected,
3013 "Fix should insert '>' blank line, not plain blank line"
3014 );
3015 }
3016
3017 #[test]
3018 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
3019 let content = ">>> Triple nested
3022>>> - Item 1
3023>>> - Item 2
3024>>> More text";
3025 let fixed = fix(content);
3026
3027 let expected = ">>> Triple nested
3029>>>
3030>>> - Item 1
3031>>> - Item 2
3032>>> More text";
3033 assert_eq!(
3034 fixed, expected,
3035 "Fix should preserve triple-nested blockquote prefix '>>>'"
3036 );
3037 }
3038
3039 fn lint_quarto(content: &str) -> Vec<LintWarning> {
3042 let rule = MD032BlanksAroundLists::default();
3043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
3044 rule.check(&ctx).unwrap()
3045 }
3046
3047 #[test]
3048 fn test_quarto_list_after_div_open() {
3049 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3051 let warnings = lint_quarto(content);
3052 assert!(
3054 warnings.is_empty(),
3055 "Quarto div marker should be transparent before list: {warnings:?}"
3056 );
3057 }
3058
3059 #[test]
3060 fn test_quarto_list_before_div_close() {
3061 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
3063 let warnings = lint_quarto(content);
3064 assert!(
3066 warnings.is_empty(),
3067 "Quarto div marker should be transparent after list: {warnings:?}"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_quarto_list_needs_blank_without_div() {
3073 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
3075 let warnings = lint_quarto(content);
3076 assert!(
3079 !warnings.is_empty(),
3080 "Should still require blank when not present: {warnings:?}"
3081 );
3082 }
3083
3084 #[test]
3085 fn test_quarto_list_in_callout_with_content() {
3086 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
3088 let warnings = lint_quarto(content);
3089 assert!(
3090 warnings.is_empty(),
3091 "List with proper blanks inside callout should pass: {warnings:?}"
3092 );
3093 }
3094
3095 #[test]
3096 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
3097 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
3099 let warnings = lint(content); assert!(
3102 !warnings.is_empty(),
3103 "Standard flavor should not treat ::: as transparent: {warnings:?}"
3104 );
3105 }
3106
3107 #[test]
3108 fn test_quarto_nested_divs_with_list() {
3109 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
3111 let warnings = lint_quarto(content);
3112 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
3113 }
3114
3115 #[test]
3116 fn test_issue512_complex_nested_list_with_continuation() {
3117 let content = "\
3120- First level of indentation.
3121 - Second level of indentation.
3122 - Third level of indentation.
3123 - Third level of indentation.
3124
3125 Second level list continuation.
3126
3127 First level list continuation.
3128- First level of indentation.
3129";
3130 let warnings = lint(content);
3131 assert!(
3132 warnings.is_empty(),
3133 "Nested list with parent-level continuation should produce no warnings. Got: {warnings:?}"
3134 );
3135 }
3136
3137 #[test]
3138 fn test_issue512_continuation_at_root_level() {
3139 let content = "\
3143- First level.
3144 - Second level.
3145
3146 First level continuation.
3147
3148Root level lazy continuation.
3149- Another first level item.
3150";
3151 let warnings = lint(content);
3152 assert_eq!(
3153 warnings.len(),
3154 1,
3155 "Should warn on line 7 (new list after break). Got: {warnings:?}"
3156 );
3157 assert_eq!(warnings[0].line, 7);
3158 }
3159
3160 #[test]
3161 fn test_issue512_three_level_nesting_continuation_at_each_level() {
3162 let content = "\
3164- Level 1 item.
3165 - Level 2 item.
3166 - Level 3 item.
3167
3168 Level 3 continuation.
3169
3170 Level 2 continuation.
3171
3172 Level 1 continuation (indented under marker).
3173- Another level 1 item.
3174";
3175 let warnings = lint(content);
3176 assert!(
3177 warnings.is_empty(),
3178 "Continuation at each nesting level should produce no warnings. Got: {warnings:?}"
3179 );
3180 }
3181}