1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::quarto_divs;
4use crate::utils::range_utils::{LineIndex, calculate_line_range};
5use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
6use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
7use regex::Regex;
8use std::collections::HashSet;
9use std::sync::LazyLock;
10
11mod md032_config;
12pub use md032_config::MD032Config;
13
14static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
16
17fn is_thematic_break(line: &str) -> bool {
20 if ElementCache::calculate_indentation_width_default(line) > 3 {
22 return false;
23 }
24
25 let trimmed = line.trim();
26 if trimmed.len() < 3 {
27 return false;
28 }
29
30 let chars: Vec<char> = trimmed.chars().collect();
31 let first_non_space = chars.iter().find(|&&c| c != ' ');
32
33 if let Some(&marker) = first_non_space {
34 if marker != '-' && marker != '*' && marker != '_' {
35 return false;
36 }
37 let marker_count = chars.iter().filter(|&&c| c == marker).count();
38 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
39 marker_count >= 3 && other_count == 0
40 } else {
41 false
42 }
43}
44
45#[derive(Debug, Clone, Default)]
115pub struct MD032BlanksAroundLists {
116 config: MD032Config,
117}
118
119impl MD032BlanksAroundLists {
120 pub fn from_config_struct(config: MD032Config) -> Self {
121 Self { config }
122 }
123}
124
125impl MD032BlanksAroundLists {
126 fn should_require_blank_line_before(
128 ctx: &crate::lint_context::LintContext,
129 prev_line_num: usize,
130 current_line_num: usize,
131 ) -> bool {
132 if ctx
134 .line_info(prev_line_num)
135 .is_some_and(|info| info.in_code_block || info.in_front_matter)
136 {
137 return true;
138 }
139
140 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
142 return false;
143 }
144
145 true
147 }
148
149 fn is_nested_list(
151 ctx: &crate::lint_context::LintContext,
152 prev_line_num: usize, current_line_num: usize, ) -> bool {
155 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
157 let current_line = &ctx.lines[current_line_num - 1];
158 if current_line.indent >= 2 {
159 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
161 let prev_line = &ctx.lines[prev_line_num - 1];
162 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
164 return true;
165 }
166 }
167 }
168 }
169 false
170 }
171
172 fn detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
178 let mut lazy_lines = HashSet::new();
179 let parser = Parser::new_ext(ctx.content, Options::all());
180
181 let mut item_stack: Vec<usize> = vec![];
183 let mut after_soft_break = false;
184
185 for (event, range) in parser.into_offset_iter() {
186 match event {
187 Event::Start(Tag::Item) => {
188 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
190 let content_col = ctx
191 .lines
192 .get(line_num.saturating_sub(1))
193 .and_then(|li| li.list_item.as_ref())
194 .map(|item| item.content_column)
195 .unwrap_or(0);
196 item_stack.push(content_col);
197 after_soft_break = false;
198 }
199 Event::End(TagEnd::Item) => {
200 item_stack.pop();
201 after_soft_break = false;
202 }
203 Event::SoftBreak if !item_stack.is_empty() => {
204 after_soft_break = true;
205 }
206 Event::Text(_) | Event::Code(_) if after_soft_break => {
209 if let Some(&expected_col) = item_stack.last() {
210 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
211 let actual_indent = ctx
212 .lines
213 .get(line_num.saturating_sub(1))
214 .map(|li| li.indent)
215 .unwrap_or(0);
216
217 if actual_indent < expected_col {
219 lazy_lines.insert(line_num);
220 }
221 }
222 after_soft_break = false;
223 }
224 _ => {
225 after_soft_break = false;
226 }
227 }
228 }
229
230 lazy_lines
231 }
232
233 fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
235 match line_offsets.binary_search(&byte_offset) {
236 Ok(idx) => idx + 1,
237 Err(idx) => idx.max(1),
238 }
239 }
240
241 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
249 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
250 for line_num in (1..before_line).rev() {
251 let idx = line_num - 1;
252 if let Some(info) = ctx.lines.get(idx) {
253 if info.in_html_comment {
255 continue;
256 }
257 if is_quarto {
259 let trimmed = info.content(ctx.content).trim();
260 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
261 continue;
262 }
263 }
264 return (line_num, info.is_blank);
265 }
266 }
267 (0, true)
269 }
270
271 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
278 let is_quarto = ctx.flavor == crate::config::MarkdownFlavor::Quarto;
279 let num_lines = ctx.lines.len();
280 for line_num in (after_line + 1)..=num_lines {
281 let idx = line_num - 1;
282 if let Some(info) = ctx.lines.get(idx) {
283 if info.in_html_comment {
285 continue;
286 }
287 if is_quarto {
289 let trimmed = info.content(ctx.content).trim();
290 if quarto_divs::is_div_open(trimmed) || quarto_divs::is_div_close(trimmed) {
291 continue;
292 }
293 }
294 return (line_num, info.is_blank);
295 }
296 }
297 (0, true)
299 }
300
301 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
303 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
304
305 for block in &ctx.list_blocks {
306 let mut segments: Vec<(usize, usize)> = Vec::new();
312 let mut current_start = block.start_line;
313 let mut prev_item_line = 0;
314
315 let get_blockquote_level = |line_num: usize| -> usize {
317 if line_num == 0 || line_num > ctx.lines.len() {
318 return 0;
319 }
320 let line_content = ctx.lines[line_num - 1].content(ctx.content);
321 BLOCKQUOTE_PREFIX_RE
322 .find(line_content)
323 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
324 .unwrap_or(0)
325 };
326
327 let mut prev_bq_level = 0;
328
329 for &item_line in &block.item_lines {
330 let current_bq_level = get_blockquote_level(item_line);
331
332 if prev_item_line > 0 {
333 let blockquote_level_changed = prev_bq_level != current_bq_level;
335
336 let mut has_standalone_code_fence = false;
339
340 let min_indent_for_content = if block.is_ordered {
342 3 } else {
346 2 };
349
350 for check_line in (prev_item_line + 1)..item_line {
351 if check_line - 1 < ctx.lines.len() {
352 let line = &ctx.lines[check_line - 1];
353 let line_content = line.content(ctx.content);
354 if line.in_code_block
355 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
356 {
357 if line.indent < min_indent_for_content {
360 has_standalone_code_fence = true;
361 break;
362 }
363 }
364 }
365 }
366
367 if has_standalone_code_fence || blockquote_level_changed {
368 segments.push((current_start, prev_item_line));
370 current_start = item_line;
371 }
372 }
373 prev_item_line = item_line;
374 prev_bq_level = current_bq_level;
375 }
376
377 if prev_item_line > 0 {
380 segments.push((current_start, prev_item_line));
381 }
382
383 let has_code_fence_splits = segments.len() > 1 && {
385 let mut found_fence = false;
387 for i in 0..segments.len() - 1 {
388 let seg_end = segments[i].1;
389 let next_start = segments[i + 1].0;
390 for check_line in (seg_end + 1)..next_start {
392 if check_line - 1 < ctx.lines.len() {
393 let line = &ctx.lines[check_line - 1];
394 let line_content = line.content(ctx.content);
395 if line.in_code_block
396 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
397 {
398 found_fence = true;
399 break;
400 }
401 }
402 }
403 if found_fence {
404 break;
405 }
406 }
407 found_fence
408 };
409
410 for (start, end) in segments.iter() {
412 let mut actual_end = *end;
414
415 if !has_code_fence_splits && *end < block.end_line {
418 let min_continuation_indent = ctx
421 .lines
422 .get(*end - 1)
423 .and_then(|line_info| line_info.list_item.as_ref())
424 .map(|item| item.content_column)
425 .unwrap_or(2);
426
427 for check_line in (*end + 1)..=block.end_line {
428 if check_line - 1 < ctx.lines.len() {
429 let line = &ctx.lines[check_line - 1];
430 let line_content = line.content(ctx.content);
431 if block.item_lines.contains(&check_line) || line.heading.is_some() {
433 break;
434 }
435 if line.in_code_block {
437 break;
438 }
439 if line.indent >= min_continuation_indent {
441 actual_end = check_line;
442 }
443 else if self.config.allow_lazy_continuation
448 && !line.is_blank
449 && line.heading.is_none()
450 && !block.item_lines.contains(&check_line)
451 && !is_thematic_break(line_content)
452 {
453 actual_end = check_line;
456 } else if !line.is_blank {
457 break;
459 }
460 }
461 }
462 }
463
464 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
465 }
466 }
467
468 blocks.retain(|(start, end, _)| {
470 let all_in_comment =
472 (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
473 !all_in_comment
474 });
475
476 blocks
477 }
478
479 fn perform_checks(
480 &self,
481 ctx: &crate::lint_context::LintContext,
482 lines: &[&str],
483 list_blocks: &[(usize, usize, String)],
484 line_index: &LineIndex,
485 ) -> LintResult {
486 let mut warnings = Vec::new();
487 let num_lines = lines.len();
488
489 for (line_idx, line) in lines.iter().enumerate() {
492 let line_num = line_idx + 1;
493
494 let is_in_list = list_blocks
496 .iter()
497 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
498 if is_in_list {
499 continue;
500 }
501
502 if ctx
504 .line_info(line_num)
505 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
506 {
507 continue;
508 }
509
510 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
512 if line_idx > 0 {
514 let prev_line = lines[line_idx - 1];
515 let prev_is_blank = is_blank_in_context(prev_line);
516 let prev_excluded = ctx
517 .line_info(line_idx)
518 .is_some_and(|info| info.in_code_block || info.in_front_matter);
519
520 let prev_trimmed = prev_line.trim();
525 let is_sentence_continuation = !prev_is_blank
526 && !prev_trimmed.is_empty()
527 && !prev_trimmed.ends_with('.')
528 && !prev_trimmed.ends_with('!')
529 && !prev_trimmed.ends_with('?')
530 && !prev_trimmed.ends_with(':')
531 && !prev_trimmed.ends_with(';')
532 && !prev_trimmed.ends_with('>')
533 && !prev_trimmed.ends_with('-')
534 && !prev_trimmed.ends_with('*');
535
536 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
537 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
539
540 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
541 warnings.push(LintWarning {
542 line: start_line,
543 column: start_col,
544 end_line,
545 end_column: end_col,
546 severity: Severity::Warning,
547 rule_name: Some(self.name().to_string()),
548 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
549 fix: Some(Fix {
550 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
551 replacement: format!("{bq_prefix}\n"),
552 }),
553 });
554 }
555 }
556 }
557 }
558
559 for &(start_line, end_line, ref prefix) in list_blocks {
560 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
562 continue;
563 }
564
565 if start_line > 1 {
566 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
568
569 if !has_blank_separation && content_line > 0 {
571 let prev_line_str = lines[content_line - 1];
572 let is_prev_excluded = ctx
573 .line_info(content_line)
574 .is_some_and(|info| info.in_code_block || info.in_front_matter);
575 let prev_prefix = BLOCKQUOTE_PREFIX_RE
576 .find(prev_line_str)
577 .map_or(String::new(), |m| m.as_str().to_string());
578 let prefixes_match = prev_prefix.trim() == prefix.trim();
579
580 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
583 if !is_prev_excluded && prefixes_match && should_require {
584 let (start_line, start_col, end_line, end_col) =
586 calculate_line_range(start_line, lines[start_line - 1]);
587
588 warnings.push(LintWarning {
589 line: start_line,
590 column: start_col,
591 end_line,
592 end_column: end_col,
593 severity: Severity::Warning,
594 rule_name: Some(self.name().to_string()),
595 message: "List should be preceded by blank line".to_string(),
596 fix: Some(Fix {
597 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
598 replacement: format!("{prefix}\n"),
599 }),
600 });
601 }
602 }
603 }
604
605 if end_line < num_lines {
606 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
608
609 if !has_blank_separation && content_line > 0 {
611 let next_line_str = lines[content_line - 1];
612 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
615 || (content_line <= ctx.lines.len()
616 && ctx.lines[content_line - 1].in_code_block
617 && ctx.lines[content_line - 1].indent >= 2);
618 let next_prefix = BLOCKQUOTE_PREFIX_RE
619 .find(next_line_str)
620 .map_or(String::new(), |m| m.as_str().to_string());
621
622 let end_line_str = lines[end_line - 1];
627 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
628 .find(end_line_str)
629 .map_or(String::new(), |m| m.as_str().to_string());
630 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
631 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
632 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
633
634 let prefixes_match = next_prefix.trim() == prefix.trim();
635
636 if !is_next_excluded && prefixes_match && !exits_blockquote {
639 let (start_line_last, start_col_last, end_line_last, end_col_last) =
641 calculate_line_range(end_line, lines[end_line - 1]);
642
643 warnings.push(LintWarning {
644 line: start_line_last,
645 column: start_col_last,
646 end_line: end_line_last,
647 end_column: end_col_last,
648 severity: Severity::Warning,
649 rule_name: Some(self.name().to_string()),
650 message: "List should be followed by blank line".to_string(),
651 fix: Some(Fix {
652 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
653 replacement: format!("{prefix}\n"),
654 }),
655 });
656 }
657 }
658 }
659 }
660 Ok(warnings)
661 }
662}
663
664impl Rule for MD032BlanksAroundLists {
665 fn name(&self) -> &'static str {
666 "MD032"
667 }
668
669 fn description(&self) -> &'static str {
670 "Lists should be surrounded by blank lines"
671 }
672
673 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
674 let content = ctx.content;
675 let lines: Vec<&str> = content.lines().collect();
676 let line_index = &ctx.line_index;
677
678 if lines.is_empty() {
680 return Ok(Vec::new());
681 }
682
683 let list_blocks = self.convert_list_blocks(ctx);
684
685 if list_blocks.is_empty() {
686 return Ok(Vec::new());
687 }
688
689 let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
690
691 if !self.config.allow_lazy_continuation {
696 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
697
698 for line_num in lazy_lines {
699 let is_within_block = list_blocks
703 .iter()
704 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
705
706 if !is_within_block {
707 continue;
708 }
709
710 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
712 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
713
714 warnings.push(LintWarning {
718 line: start_line,
719 column: start_col,
720 end_line,
721 end_column: end_col,
722 severity: Severity::Warning,
723 rule_name: Some(self.name().to_string()),
724 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
725 fix: None,
726 });
727 }
728 }
729
730 Ok(warnings)
731 }
732
733 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
734 self.fix_with_structure_impl(ctx)
735 }
736
737 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
738 if ctx.content.is_empty() || !ctx.likely_has_lists() {
740 return true;
741 }
742 ctx.list_blocks.is_empty()
744 }
745
746 fn category(&self) -> RuleCategory {
747 RuleCategory::List
748 }
749
750 fn as_any(&self) -> &dyn std::any::Any {
751 self
752 }
753
754 fn default_config_section(&self) -> Option<(String, toml::Value)> {
755 use crate::rule_config_serde::RuleConfig;
756 let default_config = MD032Config::default();
757 let json_value = serde_json::to_value(&default_config).ok()?;
758 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
759
760 if let toml::Value::Table(table) = toml_value {
761 if !table.is_empty() {
762 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
763 } else {
764 None
765 }
766 } else {
767 None
768 }
769 }
770
771 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
772 where
773 Self: Sized,
774 {
775 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
776 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
777 }
778}
779
780impl MD032BlanksAroundLists {
781 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
783 let lines: Vec<&str> = ctx.content.lines().collect();
784 let num_lines = lines.len();
785 if num_lines == 0 {
786 return Ok(String::new());
787 }
788
789 let list_blocks = self.convert_list_blocks(ctx);
790 if list_blocks.is_empty() {
791 return Ok(ctx.content.to_string());
792 }
793
794 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
795
796 for &(start_line, end_line, ref prefix) in &list_blocks {
798 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
800 continue;
801 }
802
803 if start_line > 1 {
805 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
807
808 if !has_blank_separation && content_line > 0 {
810 let prev_line_str = lines[content_line - 1];
811 let is_prev_excluded = ctx
812 .line_info(content_line)
813 .is_some_and(|info| info.in_code_block || info.in_front_matter);
814 let prev_prefix = BLOCKQUOTE_PREFIX_RE
815 .find(prev_line_str)
816 .map_or(String::new(), |m| m.as_str().to_string());
817
818 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
819 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
821 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
823 insertions.insert(start_line, bq_prefix);
824 }
825 }
826 }
827
828 if end_line < num_lines {
830 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
832
833 if !has_blank_separation && content_line > 0 {
835 let next_line_str = lines[content_line - 1];
836 let is_next_excluded = ctx
838 .line_info(content_line)
839 .is_some_and(|info| info.in_code_block || info.in_front_matter)
840 || (content_line <= ctx.lines.len()
841 && ctx.lines[content_line - 1].in_code_block
842 && ctx.lines[content_line - 1].indent >= 2
843 && (ctx.lines[content_line - 1]
844 .content(ctx.content)
845 .trim()
846 .starts_with("```")
847 || ctx.lines[content_line - 1]
848 .content(ctx.content)
849 .trim()
850 .starts_with("~~~")));
851 let next_prefix = BLOCKQUOTE_PREFIX_RE
852 .find(next_line_str)
853 .map_or(String::new(), |m| m.as_str().to_string());
854
855 let end_line_str = lines[end_line - 1];
857 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
858 .find(end_line_str)
859 .map_or(String::new(), |m| m.as_str().to_string());
860 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
861 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
862 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
863
864 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
867 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
869 insertions.insert(end_line + 1, bq_prefix);
870 }
871 }
872 }
873 }
874
875 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
877 for (i, line) in lines.iter().enumerate() {
878 let current_line_num = i + 1;
879 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
880 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
881 {
882 result_lines.push(prefix_to_insert.clone());
883 }
884 result_lines.push(line.to_string());
885 }
886
887 let mut result = result_lines.join("\n");
889 if ctx.content.ends_with('\n') {
890 result.push('\n');
891 }
892 Ok(result)
893 }
894}
895
896fn is_blank_in_context(line: &str) -> bool {
898 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
901 line[m.end()..].trim().is_empty()
903 } else {
904 line.trim().is_empty()
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912 use crate::lint_context::LintContext;
913 use crate::rule::Rule;
914
915 fn lint(content: &str) -> Vec<LintWarning> {
916 let rule = MD032BlanksAroundLists::default();
917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 rule.check(&ctx).expect("Lint check failed")
919 }
920
921 fn fix(content: &str) -> String {
922 let rule = MD032BlanksAroundLists::default();
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 rule.fix(&ctx).expect("Lint fix failed")
925 }
926
927 fn check_warnings_have_fixes(content: &str) {
929 let warnings = lint(content);
930 for warning in &warnings {
931 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
932 }
933 }
934
935 #[test]
936 fn test_list_at_start() {
937 let content = "- Item 1\n- Item 2\nText";
940 let warnings = lint(content);
941 assert_eq!(
942 warnings.len(),
943 0,
944 "Trailing text is lazy continuation per CommonMark - no warning expected"
945 );
946 }
947
948 #[test]
949 fn test_list_at_end() {
950 let content = "Text\n- Item 1\n- Item 2";
951 let warnings = lint(content);
952 assert_eq!(
953 warnings.len(),
954 1,
955 "Expected 1 warning for list at end without preceding blank line"
956 );
957 assert_eq!(
958 warnings[0].line, 2,
959 "Warning should be on the first line of the list (line 2)"
960 );
961 assert!(warnings[0].message.contains("preceded by blank line"));
962
963 check_warnings_have_fixes(content);
965
966 let fixed_content = fix(content);
967 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
968
969 let warnings_after_fix = lint(&fixed_content);
971 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
972 }
973
974 #[test]
975 fn test_list_in_middle() {
976 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
979 let warnings = lint(content);
980 assert_eq!(
981 warnings.len(),
982 1,
983 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
984 );
985 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
986 assert!(warnings[0].message.contains("preceded by blank line"));
987
988 check_warnings_have_fixes(content);
990
991 let fixed_content = fix(content);
992 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
993
994 let warnings_after_fix = lint(&fixed_content);
996 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
997 }
998
999 #[test]
1000 fn test_correct_spacing() {
1001 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
1002 let warnings = lint(content);
1003 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
1004
1005 let fixed_content = fix(content);
1006 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
1007 }
1008
1009 #[test]
1010 fn test_list_with_content() {
1011 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
1014 let warnings = lint(content);
1015 assert_eq!(
1016 warnings.len(),
1017 1,
1018 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1019 );
1020 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1021 assert!(warnings[0].message.contains("preceded by blank line"));
1022
1023 check_warnings_have_fixes(content);
1025
1026 let fixed_content = fix(content);
1027 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1028 assert_eq!(
1029 fixed_content, expected_fixed,
1030 "Fix did not produce the expected output. Got:\n{fixed_content}"
1031 );
1032
1033 let warnings_after_fix = lint(&fixed_content);
1035 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1036 }
1037
1038 #[test]
1039 fn test_nested_list() {
1040 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1042 let warnings = lint(content);
1043 assert_eq!(
1044 warnings.len(),
1045 1,
1046 "Nested list block needs preceding blank only. Got: {warnings:?}"
1047 );
1048 assert_eq!(warnings[0].line, 2);
1049 assert!(warnings[0].message.contains("preceded by blank line"));
1050
1051 check_warnings_have_fixes(content);
1053
1054 let fixed_content = fix(content);
1055 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1056
1057 let warnings_after_fix = lint(&fixed_content);
1059 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1060 }
1061
1062 #[test]
1063 fn test_list_with_internal_blanks() {
1064 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1066 let warnings = lint(content);
1067 assert_eq!(
1068 warnings.len(),
1069 1,
1070 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1071 );
1072 assert_eq!(warnings[0].line, 2);
1073 assert!(warnings[0].message.contains("preceded by blank line"));
1074
1075 check_warnings_have_fixes(content);
1077
1078 let fixed_content = fix(content);
1079 assert_eq!(
1080 fixed_content,
1081 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1082 );
1083
1084 let warnings_after_fix = lint(&fixed_content);
1086 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1087 }
1088
1089 #[test]
1090 fn test_ignore_code_blocks() {
1091 let content = "```\n- Not a list item\n```\nText";
1092 let warnings = lint(content);
1093 assert_eq!(warnings.len(), 0);
1094 let fixed_content = fix(content);
1095 assert_eq!(fixed_content, content);
1096 }
1097
1098 #[test]
1099 fn test_ignore_front_matter() {
1100 let content = "---\ntitle: Test\n---\n- List Item\nText";
1102 let warnings = lint(content);
1103 assert_eq!(
1104 warnings.len(),
1105 0,
1106 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1107 );
1108
1109 let fixed_content = fix(content);
1111 assert_eq!(fixed_content, content, "No changes when no warnings");
1112 }
1113
1114 #[test]
1115 fn test_multiple_lists() {
1116 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1121 let warnings = lint(content);
1122 assert!(
1124 !warnings.is_empty(),
1125 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1126 );
1127
1128 check_warnings_have_fixes(content);
1130
1131 let fixed_content = fix(content);
1132 let warnings_after_fix = lint(&fixed_content);
1134 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1135 }
1136
1137 #[test]
1138 fn test_adjacent_lists() {
1139 let content = "- List 1\n\n* List 2";
1140 let warnings = lint(content);
1141 assert_eq!(warnings.len(), 0);
1142 let fixed_content = fix(content);
1143 assert_eq!(fixed_content, content);
1144 }
1145
1146 #[test]
1147 fn test_list_in_blockquote() {
1148 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1150 let warnings = lint(content);
1151 assert_eq!(
1152 warnings.len(),
1153 1,
1154 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1155 );
1156 assert_eq!(warnings[0].line, 2);
1157
1158 check_warnings_have_fixes(content);
1160
1161 let fixed_content = fix(content);
1162 assert_eq!(
1164 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1165 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1166 );
1167
1168 let warnings_after_fix = lint(&fixed_content);
1170 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1171 }
1172
1173 #[test]
1174 fn test_ordered_list() {
1175 let content = "Text\n1. Item 1\n2. Item 2\nText";
1177 let warnings = lint(content);
1178 assert_eq!(warnings.len(), 1);
1179
1180 check_warnings_have_fixes(content);
1182
1183 let fixed_content = fix(content);
1184 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1185
1186 let warnings_after_fix = lint(&fixed_content);
1188 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1189 }
1190
1191 #[test]
1192 fn test_no_double_blank_fix() {
1193 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1196 assert_eq!(
1197 warnings.len(),
1198 0,
1199 "Should have no warnings - properly preceded, trailing is lazy"
1200 );
1201
1202 let fixed_content = fix(content);
1203 assert_eq!(
1204 fixed_content, content,
1205 "No fix needed when no warnings. Got:\n{fixed_content}"
1206 );
1207
1208 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1210 assert_eq!(warnings2.len(), 1);
1211 if !warnings2.is_empty() {
1212 assert_eq!(
1213 warnings2[0].line, 2,
1214 "Warning line for missing blank before should be the first line of the block"
1215 );
1216 }
1217
1218 check_warnings_have_fixes(content2);
1220
1221 let fixed_content2 = fix(content2);
1222 assert_eq!(
1223 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1224 "Fix added extra blank before. Got:\n{fixed_content2}"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_empty_input() {
1230 let content = "";
1231 let warnings = lint(content);
1232 assert_eq!(warnings.len(), 0);
1233 let fixed_content = fix(content);
1234 assert_eq!(fixed_content, "");
1235 }
1236
1237 #[test]
1238 fn test_only_list() {
1239 let content = "- Item 1\n- Item 2";
1240 let warnings = lint(content);
1241 assert_eq!(warnings.len(), 0);
1242 let fixed_content = fix(content);
1243 assert_eq!(fixed_content, content);
1244 }
1245
1246 #[test]
1249 fn test_fix_complex_nested_blockquote() {
1250 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1252 let warnings = lint(content);
1253 assert_eq!(
1254 warnings.len(),
1255 1,
1256 "Should warn for missing preceding blank only. Got: {warnings:?}"
1257 );
1258
1259 check_warnings_have_fixes(content);
1261
1262 let fixed_content = fix(content);
1263 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1265 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1266
1267 let warnings_after_fix = lint(&fixed_content);
1268 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1269 }
1270
1271 #[test]
1272 fn test_fix_mixed_list_markers() {
1273 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1276 let warnings = lint(content);
1277 assert!(
1279 !warnings.is_empty(),
1280 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1281 );
1282
1283 check_warnings_have_fixes(content);
1285
1286 let fixed_content = fix(content);
1287 assert!(
1289 fixed_content.contains("Text\n\n-"),
1290 "Fix should add blank line before first list item"
1291 );
1292
1293 let warnings_after_fix = lint(&fixed_content);
1295 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1296 }
1297
1298 #[test]
1299 fn test_fix_ordered_list_with_different_numbers() {
1300 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1302 let warnings = lint(content);
1303 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1304
1305 check_warnings_have_fixes(content);
1307
1308 let fixed_content = fix(content);
1309 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1310 assert_eq!(
1311 fixed_content, expected,
1312 "Fix should handle ordered lists with non-sequential numbers"
1313 );
1314
1315 let warnings_after_fix = lint(&fixed_content);
1317 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1318 }
1319
1320 #[test]
1321 fn test_fix_list_with_code_blocks_inside() {
1322 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1324 let warnings = lint(content);
1325 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1326
1327 check_warnings_have_fixes(content);
1329
1330 let fixed_content = fix(content);
1331 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1332 assert_eq!(
1333 fixed_content, expected,
1334 "Fix should handle lists with internal code blocks"
1335 );
1336
1337 let warnings_after_fix = lint(&fixed_content);
1339 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1340 }
1341
1342 #[test]
1343 fn test_fix_deeply_nested_lists() {
1344 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1346 let warnings = lint(content);
1347 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1348
1349 check_warnings_have_fixes(content);
1351
1352 let fixed_content = fix(content);
1353 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1354 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
1355
1356 let warnings_after_fix = lint(&fixed_content);
1358 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1359 }
1360
1361 #[test]
1362 fn test_fix_list_with_multiline_items() {
1363 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1366 let warnings = lint(content);
1367 assert_eq!(
1368 warnings.len(),
1369 1,
1370 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1371 );
1372
1373 check_warnings_have_fixes(content);
1375
1376 let fixed_content = fix(content);
1377 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1378 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1379
1380 let warnings_after_fix = lint(&fixed_content);
1382 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1383 }
1384
1385 #[test]
1386 fn test_fix_list_at_document_boundaries() {
1387 let content1 = "- Item 1\n- Item 2";
1389 let warnings1 = lint(content1);
1390 assert_eq!(
1391 warnings1.len(),
1392 0,
1393 "List at document start should not need blank before"
1394 );
1395 let fixed1 = fix(content1);
1396 assert_eq!(fixed1, content1, "No fix needed for list at start");
1397
1398 let content2 = "Text\n- Item 1\n- Item 2";
1400 let warnings2 = lint(content2);
1401 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1402 check_warnings_have_fixes(content2);
1403 let fixed2 = fix(content2);
1404 assert_eq!(
1405 fixed2, "Text\n\n- Item 1\n- Item 2",
1406 "Should add blank before list at end"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_fix_preserves_existing_blank_lines() {
1412 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1413 let warnings = lint(content);
1414 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1415 let fixed_content = fix(content);
1416 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1417 }
1418
1419 #[test]
1420 fn test_fix_handles_tabs_and_spaces() {
1421 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1424 let warnings = lint(content);
1425 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1427
1428 check_warnings_have_fixes(content);
1430
1431 let fixed_content = fix(content);
1432 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1435 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1436
1437 let warnings_after_fix = lint(&fixed_content);
1439 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1440 }
1441
1442 #[test]
1443 fn test_fix_warning_objects_have_correct_ranges() {
1444 let content = "Text\n- Item 1\n- Item 2\nText";
1446 let warnings = lint(content);
1447 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1448
1449 for warning in &warnings {
1451 assert!(warning.fix.is_some(), "Warning should have fix");
1452 let fix = warning.fix.as_ref().unwrap();
1453 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1454 assert!(
1455 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1456 "Fix should have replacement or be insertion"
1457 );
1458 }
1459 }
1460
1461 #[test]
1462 fn test_fix_idempotent() {
1463 let content = "Text\n- Item 1\n- Item 2\nText";
1465
1466 let fixed_once = fix(content);
1468 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1469
1470 let fixed_twice = fix(&fixed_once);
1472 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1473
1474 let warnings_after_fix = lint(&fixed_once);
1476 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1477 }
1478
1479 #[test]
1480 fn test_fix_with_normalized_line_endings() {
1481 let content = "Text\n- Item 1\n- Item 2\nText";
1485 let warnings = lint(content);
1486 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1487
1488 check_warnings_have_fixes(content);
1490
1491 let fixed_content = fix(content);
1492 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1494 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1495 }
1496
1497 #[test]
1498 fn test_fix_preserves_final_newline() {
1499 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1502 let fixed_with_newline = fix(content_with_newline);
1503 assert!(
1504 fixed_with_newline.ends_with('\n'),
1505 "Fix should preserve final newline when present"
1506 );
1507 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1509
1510 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1512 let fixed_without_newline = fix(content_without_newline);
1513 assert!(
1514 !fixed_without_newline.ends_with('\n'),
1515 "Fix should not add final newline when not present"
1516 );
1517 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1519 }
1520
1521 #[test]
1522 fn test_fix_multiline_list_items_no_indent() {
1523 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";
1524
1525 let warnings = lint(content);
1526 assert_eq!(
1528 warnings.len(),
1529 0,
1530 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1531 );
1532
1533 let fixed_content = fix(content);
1534 assert_eq!(
1536 fixed_content, content,
1537 "Should not modify correctly formatted multi-line list items"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_nested_list_with_lazy_continuation() {
1543 let content = r#"# Test
1549
1550- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1551 1. Switch/case dispatcher statements (original Phase 3.2)
1552 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1553`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1554 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1555 references"#;
1556
1557 let warnings = lint(content);
1558 let md032_warnings: Vec<_> = warnings
1561 .iter()
1562 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1563 .collect();
1564 assert_eq!(
1565 md032_warnings.len(),
1566 0,
1567 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1568 );
1569 }
1570
1571 #[test]
1572 fn test_pipes_in_code_spans_not_detected_as_table() {
1573 let content = r#"# Test
1575
1576- Item with `a | b` inline code
1577 - Nested item should work
1578
1579"#;
1580
1581 let warnings = lint(content);
1582 let md032_warnings: Vec<_> = warnings
1583 .iter()
1584 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1585 .collect();
1586 assert_eq!(
1587 md032_warnings.len(),
1588 0,
1589 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1590 );
1591 }
1592
1593 #[test]
1594 fn test_multiple_code_spans_with_pipes() {
1595 let content = r#"# Test
1597
1598- Item with `a | b` and `c || d` operators
1599 - Nested item should work
1600
1601"#;
1602
1603 let warnings = lint(content);
1604 let md032_warnings: Vec<_> = warnings
1605 .iter()
1606 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1607 .collect();
1608 assert_eq!(
1609 md032_warnings.len(),
1610 0,
1611 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_actual_table_breaks_list() {
1617 let content = r#"# Test
1619
1620- Item before table
1621
1622| Col1 | Col2 |
1623|------|------|
1624| A | B |
1625
1626- Item after table
1627
1628"#;
1629
1630 let warnings = lint(content);
1631 let md032_warnings: Vec<_> = warnings
1633 .iter()
1634 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1635 .collect();
1636 assert_eq!(
1637 md032_warnings.len(),
1638 0,
1639 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1640 );
1641 }
1642
1643 #[test]
1644 fn test_thematic_break_not_lazy_continuation() {
1645 let content = r#"- Item 1
1648- Item 2
1649***
1650
1651More text.
1652"#;
1653
1654 let warnings = lint(content);
1655 let md032_warnings: Vec<_> = warnings
1656 .iter()
1657 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1658 .collect();
1659 assert_eq!(
1660 md032_warnings.len(),
1661 1,
1662 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1663 );
1664 assert!(
1665 md032_warnings[0].message.contains("followed by blank line"),
1666 "Warning should be about missing blank after list"
1667 );
1668 }
1669
1670 #[test]
1671 fn test_thematic_break_with_blank_line() {
1672 let content = r#"- Item 1
1674- Item 2
1675
1676***
1677
1678More text.
1679"#;
1680
1681 let warnings = lint(content);
1682 let md032_warnings: Vec<_> = warnings
1683 .iter()
1684 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1685 .collect();
1686 assert_eq!(
1687 md032_warnings.len(),
1688 0,
1689 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1690 );
1691 }
1692
1693 #[test]
1694 fn test_various_thematic_break_styles() {
1695 for hr in ["---", "***", "___"] {
1700 let content = format!(
1701 r#"- Item 1
1702- Item 2
1703{hr}
1704
1705More text.
1706"#
1707 );
1708
1709 let warnings = lint(&content);
1710 let md032_warnings: Vec<_> = warnings
1711 .iter()
1712 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1713 .collect();
1714 assert_eq!(
1715 md032_warnings.len(),
1716 1,
1717 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1718 );
1719 }
1720 }
1721
1722 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1725 let rule = MD032BlanksAroundLists::from_config_struct(config);
1726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727 rule.check(&ctx).expect("Lint check failed")
1728 }
1729
1730 fn fix_with_config(content: &str, config: MD032Config) -> String {
1731 let rule = MD032BlanksAroundLists::from_config_struct(config);
1732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1733 rule.fix(&ctx).expect("Lint fix failed")
1734 }
1735
1736 #[test]
1737 fn test_lazy_continuation_allowed_by_default() {
1738 let content = "# Heading\n\n1. List\nSome text.";
1740 let warnings = lint(content);
1741 assert_eq!(
1742 warnings.len(),
1743 0,
1744 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_lazy_continuation_disallowed() {
1750 let content = "# Heading\n\n1. List\nSome text.";
1752 let config = MD032Config {
1753 allow_lazy_continuation: false,
1754 };
1755 let warnings = lint_with_config(content, config);
1756 assert_eq!(
1757 warnings.len(),
1758 1,
1759 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1760 );
1761 assert!(
1762 warnings[0].message.contains("followed by blank line"),
1763 "Warning message should mention blank line"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_lazy_continuation_fix() {
1769 let content = "# Heading\n\n1. List\nSome text.";
1771 let config = MD032Config {
1772 allow_lazy_continuation: false,
1773 };
1774 let fixed = fix_with_config(content, config.clone());
1775 assert_eq!(
1776 fixed, "# Heading\n\n1. List\n\nSome text.",
1777 "Fix should insert blank line before lazy continuation"
1778 );
1779
1780 let warnings_after = lint_with_config(&fixed, config);
1782 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1783 }
1784
1785 #[test]
1786 fn test_lazy_continuation_multiple_lines() {
1787 let content = "- Item 1\nLine 2\nLine 3";
1789 let config = MD032Config {
1790 allow_lazy_continuation: false,
1791 };
1792 let warnings = lint_with_config(content, config.clone());
1793 assert_eq!(
1794 warnings.len(),
1795 1,
1796 "Should warn for lazy continuation. Got: {warnings:?}"
1797 );
1798
1799 let fixed = fix_with_config(content, config.clone());
1800 assert_eq!(
1801 fixed, "- Item 1\n\nLine 2\nLine 3",
1802 "Fix should insert blank line after list"
1803 );
1804
1805 let warnings_after = lint_with_config(&fixed, config);
1807 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1808 }
1809
1810 #[test]
1811 fn test_lazy_continuation_with_indented_content() {
1812 let content = "- Item 1\n Indented content\nLazy text";
1814 let config = MD032Config {
1815 allow_lazy_continuation: false,
1816 };
1817 let warnings = lint_with_config(content, config);
1818 assert_eq!(
1819 warnings.len(),
1820 1,
1821 "Should warn for lazy text after indented content. Got: {warnings:?}"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_lazy_continuation_properly_separated() {
1827 let content = "- Item 1\n\nSome text.";
1829 let config = MD032Config {
1830 allow_lazy_continuation: false,
1831 };
1832 let warnings = lint_with_config(content, config);
1833 assert_eq!(
1834 warnings.len(),
1835 0,
1836 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1837 );
1838 }
1839
1840 #[test]
1843 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1844 let content = "1) First item\nLazy continuation";
1846 let config = MD032Config {
1847 allow_lazy_continuation: false,
1848 };
1849 let warnings = lint_with_config(content, config.clone());
1850 assert_eq!(
1851 warnings.len(),
1852 1,
1853 "Should warn for lazy continuation with parenthesis marker"
1854 );
1855
1856 let fixed = fix_with_config(content, config);
1857 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1858 }
1859
1860 #[test]
1861 fn test_lazy_continuation_followed_by_another_list() {
1862 let content = "- Item 1\nSome text\n- Item 2";
1868 let config = MD032Config {
1869 allow_lazy_continuation: false,
1870 };
1871 let warnings = lint_with_config(content, config);
1872 assert_eq!(
1874 warnings.len(),
1875 1,
1876 "Should warn about lazy continuation within list. Got: {warnings:?}"
1877 );
1878 assert!(
1879 warnings[0].message.contains("Lazy continuation"),
1880 "Warning should be about lazy continuation"
1881 );
1882 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1883 }
1884
1885 #[test]
1886 fn test_lazy_continuation_multiple_in_document() {
1887 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1892 let config = MD032Config {
1893 allow_lazy_continuation: false,
1894 };
1895 let warnings = lint_with_config(content, config.clone());
1896 assert_eq!(
1900 warnings.len(),
1901 2,
1902 "Should warn for both lazy continuations and list end. Got: {warnings:?}"
1903 );
1904
1905 let fixed = fix_with_config(content, config.clone());
1906 let warnings_after = lint_with_config(&fixed, config);
1907 assert_eq!(
1910 warnings_after.len(),
1911 1,
1912 "Within-list lazy continuation warning should remain (no auto-fix)"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_lazy_continuation_end_of_document_no_newline() {
1918 let content = "- Item\nNo trailing newline";
1920 let config = MD032Config {
1921 allow_lazy_continuation: false,
1922 };
1923 let warnings = lint_with_config(content, config.clone());
1924 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1925
1926 let fixed = fix_with_config(content, config);
1927 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1928 }
1929
1930 #[test]
1931 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1932 let content = "- Item 1\n---";
1935 let config = MD032Config {
1936 allow_lazy_continuation: false,
1937 };
1938 let warnings = lint_with_config(content, config.clone());
1939 assert_eq!(
1941 warnings.len(),
1942 1,
1943 "List should need blank line before thematic break. Got: {warnings:?}"
1944 );
1945
1946 let fixed = fix_with_config(content, config);
1948 assert_eq!(fixed, "- Item 1\n\n---");
1949 }
1950
1951 #[test]
1952 fn test_lazy_continuation_heading_not_flagged() {
1953 let content = "- Item 1\n# Heading";
1956 let config = MD032Config {
1957 allow_lazy_continuation: false,
1958 };
1959 let warnings = lint_with_config(content, config);
1960 assert!(
1963 warnings.iter().all(|w| !w.message.contains("lazy")),
1964 "Heading should not trigger lazy continuation warning"
1965 );
1966 }
1967
1968 #[test]
1969 fn test_lazy_continuation_mixed_list_types() {
1970 let content = "- Unordered\n1. Ordered\nLazy text";
1972 let config = MD032Config {
1973 allow_lazy_continuation: false,
1974 };
1975 let warnings = lint_with_config(content, config.clone());
1976 assert!(!warnings.is_empty(), "Should warn about structure issues");
1977 }
1978
1979 #[test]
1980 fn test_lazy_continuation_deep_nesting() {
1981 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1983 let config = MD032Config {
1984 allow_lazy_continuation: false,
1985 };
1986 let warnings = lint_with_config(content, config.clone());
1987 assert!(
1988 !warnings.is_empty(),
1989 "Should warn about lazy continuation after nested list"
1990 );
1991
1992 let fixed = fix_with_config(content, config.clone());
1993 let warnings_after = lint_with_config(&fixed, config);
1994 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1995 }
1996
1997 #[test]
1998 fn test_lazy_continuation_with_emphasis_in_text() {
1999 let content = "- Item\n*emphasized* continuation";
2001 let config = MD032Config {
2002 allow_lazy_continuation: false,
2003 };
2004 let warnings = lint_with_config(content, config.clone());
2005 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
2006
2007 let fixed = fix_with_config(content, config);
2008 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
2009 }
2010
2011 #[test]
2012 fn test_lazy_continuation_with_code_span() {
2013 let content = "- Item\n`code` continuation";
2015 let config = MD032Config {
2016 allow_lazy_continuation: false,
2017 };
2018 let warnings = lint_with_config(content, config.clone());
2019 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2020
2021 let fixed = fix_with_config(content, config);
2022 assert_eq!(fixed, "- Item\n\n`code` continuation");
2023 }
2024
2025 #[test]
2032 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2033 let content = r#"1. Create a new Chat conversation:
2036 - On the sidebar, select **New Chat**.
2037 - In the box, type `/new`.
2038 A new Chat conversation replaces the previous one.
20391. Under the Chat text box, turn off the toggle."#;
2040 let config = MD032Config {
2041 allow_lazy_continuation: false,
2042 };
2043 let warnings = lint_with_config(content, config);
2044 let lazy_warnings: Vec<_> = warnings
2046 .iter()
2047 .filter(|w| w.message.contains("Lazy continuation"))
2048 .collect();
2049 assert!(
2050 !lazy_warnings.is_empty(),
2051 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2052 );
2053 assert!(
2054 lazy_warnings.iter().any(|w| w.line == 4),
2055 "Should warn on line 4. Got: {lazy_warnings:?}"
2056 );
2057 }
2058
2059 #[test]
2060 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2061 let content = r#"- `field`: Is the specific key:
2064 - `password`: Accesses the password.
2065 - `api_key`: Accesses the api_key.
2066 `token`: Specifies which ID token to use.
2067- `version_id`: Is the unique identifier."#;
2068 let config = MD032Config {
2069 allow_lazy_continuation: false,
2070 };
2071 let warnings = lint_with_config(content, config);
2072 let lazy_warnings: Vec<_> = warnings
2074 .iter()
2075 .filter(|w| w.message.contains("Lazy continuation"))
2076 .collect();
2077 assert!(
2078 !lazy_warnings.is_empty(),
2079 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2080 );
2081 assert!(
2082 lazy_warnings.iter().any(|w| w.line == 4),
2083 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2084 );
2085 }
2086
2087 #[test]
2088 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2089 let content = r#"- Check out the branch, and test locally.
2091 - If the MR requires significant modifications:
2092 - **Skip local testing** and review instead.
2093 - **Request verification** from the author.
2094 - **Identify the minimal change** needed.
2095 Your testing might result in opportunities.
2096- If you don't understand, _say so_."#;
2097 let config = MD032Config {
2098 allow_lazy_continuation: false,
2099 };
2100 let warnings = lint_with_config(content, config);
2101 let lazy_warnings: Vec<_> = warnings
2103 .iter()
2104 .filter(|w| w.message.contains("Lazy continuation"))
2105 .collect();
2106 assert!(
2107 !lazy_warnings.is_empty(),
2108 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2109 );
2110 assert!(
2111 lazy_warnings.iter().any(|w| w.line == 6),
2112 "Should warn on line 6. Got: {lazy_warnings:?}"
2113 );
2114 }
2115
2116 #[test]
2117 fn test_issue295_ordered_list_nested_bullets_continuation() {
2118 let content = r#"# Test
2121
21221. First item.
2123 - Nested A.
2124 - Nested B.
2125 Continuation at outer level.
21261. Second item."#;
2127 let config = MD032Config {
2128 allow_lazy_continuation: false,
2129 };
2130 let warnings = lint_with_config(content, config);
2131 let lazy_warnings: Vec<_> = warnings
2133 .iter()
2134 .filter(|w| w.message.contains("Lazy continuation"))
2135 .collect();
2136 assert!(
2137 !lazy_warnings.is_empty(),
2138 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2139 );
2140 assert!(
2142 lazy_warnings.iter().any(|w| w.line == 6),
2143 "Should warn on line 6. Got: {lazy_warnings:?}"
2144 );
2145 }
2146
2147 #[test]
2148 fn test_issue295_multiple_lazy_lines_after_nested() {
2149 let content = r#"1. The device client receives a response.
2151 - Those defined by OAuth Framework.
2152 - Those specific to device authorization.
2153 Those error responses are described below.
2154 For more information on each response,
2155 see the documentation.
21561. Next step in the process."#;
2157 let config = MD032Config {
2158 allow_lazy_continuation: false,
2159 };
2160 let warnings = lint_with_config(content, config);
2161 let lazy_warnings: Vec<_> = warnings
2163 .iter()
2164 .filter(|w| w.message.contains("Lazy continuation"))
2165 .collect();
2166 assert!(
2167 lazy_warnings.len() >= 3,
2168 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2169 lazy_warnings.len()
2170 );
2171 }
2172
2173 #[test]
2174 fn test_issue295_properly_indented_not_lazy() {
2175 let content = r#"1. First item.
2177 - Nested A.
2178 - Nested B.
2179
2180 Properly indented continuation.
21811. Second item."#;
2182 let config = MD032Config {
2183 allow_lazy_continuation: false,
2184 };
2185 let warnings = lint_with_config(content, config);
2186 let lazy_warnings: Vec<_> = warnings
2188 .iter()
2189 .filter(|w| w.message.contains("Lazy continuation"))
2190 .collect();
2191 assert_eq!(
2192 lazy_warnings.len(),
2193 0,
2194 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2195 );
2196 }
2197
2198 #[test]
2205 fn test_html_comment_before_list_with_preceding_blank() {
2206 let content = "Some text.\n\n<!-- comment -->\n- List item";
2209 let warnings = lint(content);
2210 assert_eq!(
2211 warnings.len(),
2212 0,
2213 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_html_comment_after_list_with_following_blank() {
2219 let content = "- List item\n<!-- comment -->\n\nSome text.";
2221 let warnings = lint(content);
2222 assert_eq!(
2223 warnings.len(),
2224 0,
2225 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_list_inside_html_comment_ignored() {
2231 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2233 let warnings = lint(content);
2234 assert_eq!(
2235 warnings.len(),
2236 0,
2237 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_multiline_html_comment_before_list() {
2243 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2245 let warnings = lint(content);
2246 assert_eq!(
2247 warnings.len(),
2248 0,
2249 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2250 );
2251 }
2252
2253 #[test]
2254 fn test_no_blank_before_html_comment_still_warns() {
2255 let content = "Some text.\n<!-- comment -->\n- List item";
2257 let warnings = lint(content);
2258 assert_eq!(
2259 warnings.len(),
2260 1,
2261 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2262 );
2263 assert!(
2264 warnings[0].message.contains("preceded by blank line"),
2265 "Should be 'preceded by blank line' warning"
2266 );
2267 }
2268
2269 #[test]
2270 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2271 let content = "- List item\n<!-- comment -->\nSome text.";
2274 let warnings = lint(content);
2275 assert_eq!(
2276 warnings.len(),
2277 0,
2278 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_list_followed_by_heading_through_comment_should_warn() {
2284 let content = "- List item\n<!-- comment -->\n# Heading";
2286 let warnings = lint(content);
2287 assert!(
2290 warnings.len() <= 1,
2291 "Should handle heading after comment gracefully. Got: {warnings:?}"
2292 );
2293 }
2294
2295 #[test]
2296 fn test_html_comment_between_list_and_text_both_directions() {
2297 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2299 let warnings = lint(content);
2300 assert_eq!(
2301 warnings.len(),
2302 0,
2303 "Should not warn with proper separation through comments. Got: {warnings:?}"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2309 let content = "Text.\n\n<!-- comment -->\n- Item";
2311 let fixed = fix(content);
2312 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2313 }
2314
2315 #[test]
2316 fn test_html_comment_fix_adds_blank_when_needed() {
2317 let content = "Text.\n<!-- comment -->\n- Item";
2320 let fixed = fix(content);
2321 assert!(
2322 fixed.contains("<!-- comment -->\n\n- Item"),
2323 "Fix should add blank line before list. Got: {fixed}"
2324 );
2325 }
2326
2327 #[test]
2328 fn test_ordered_list_inside_html_comment() {
2329 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2331 let warnings = lint(content);
2332 assert_eq!(
2333 warnings.len(),
2334 0,
2335 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2336 );
2337 }
2338
2339 #[test]
2346 fn test_blockquote_list_exit_no_warning() {
2347 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2349 let warnings = lint(content);
2350 assert_eq!(
2351 warnings.len(),
2352 0,
2353 "Should not warn when exiting blockquote. Got: {warnings:?}"
2354 );
2355 }
2356
2357 #[test]
2358 fn test_nested_blockquote_list_exit() {
2359 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2361 let warnings = lint(content);
2362 assert_eq!(
2363 warnings.len(),
2364 0,
2365 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2366 );
2367 }
2368
2369 #[test]
2370 fn test_blockquote_same_level_no_warning() {
2371 let content = "> - item 1\n> - item 2\n> Text after";
2374 let warnings = lint(content);
2375 assert_eq!(
2376 warnings.len(),
2377 0,
2378 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2379 );
2380 }
2381
2382 #[test]
2383 fn test_blockquote_list_with_special_chars() {
2384 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2386 let warnings = lint(content);
2387 assert_eq!(
2388 warnings.len(),
2389 0,
2390 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2391 );
2392 }
2393
2394 #[test]
2395 fn test_lazy_continuation_whitespace_only_line() {
2396 let content = "- Item\n \nText after whitespace-only line";
2399 let config = MD032Config {
2400 allow_lazy_continuation: false,
2401 };
2402 let warnings = lint_with_config(content, config.clone());
2403 assert_eq!(
2405 warnings.len(),
2406 1,
2407 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
2408 );
2409
2410 let fixed = fix_with_config(content, config);
2412 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
2413 }
2414
2415 #[test]
2416 fn test_lazy_continuation_blockquote_context() {
2417 let content = "> - Item\n> Lazy in quote";
2419 let config = MD032Config {
2420 allow_lazy_continuation: false,
2421 };
2422 let warnings = lint_with_config(content, config);
2423 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2426 }
2427
2428 #[test]
2429 fn test_lazy_continuation_fix_preserves_content() {
2430 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2432 let config = MD032Config {
2433 allow_lazy_continuation: false,
2434 };
2435 let fixed = fix_with_config(content, config);
2436 assert!(fixed.contains("<>&"), "Should preserve special chars");
2437 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2438 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
2439 }
2440
2441 #[test]
2442 fn test_lazy_continuation_fix_idempotent() {
2443 let content = "- Item\nLazy";
2445 let config = MD032Config {
2446 allow_lazy_continuation: false,
2447 };
2448 let fixed_once = fix_with_config(content, config.clone());
2449 let fixed_twice = fix_with_config(&fixed_once, config);
2450 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2451 }
2452
2453 #[test]
2454 fn test_lazy_continuation_config_default_allows() {
2455 let content = "- Item\nLazy text that continues";
2457 let default_config = MD032Config::default();
2458 assert!(
2459 default_config.allow_lazy_continuation,
2460 "Default should allow lazy continuation"
2461 );
2462 let warnings = lint_with_config(content, default_config);
2463 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2464 }
2465
2466 #[test]
2467 fn test_lazy_continuation_after_multi_line_item() {
2468 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2470 let config = MD032Config {
2471 allow_lazy_continuation: false,
2472 };
2473 let warnings = lint_with_config(content, config.clone());
2474 assert_eq!(
2475 warnings.len(),
2476 1,
2477 "Should warn only for the lazy line, not the indented line"
2478 );
2479 }
2480
2481 #[test]
2483 fn test_blockquote_list_with_continuation_and_nested() {
2484 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2487 let warnings = lint(content);
2488 assert_eq!(
2489 warnings.len(),
2490 0,
2491 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_blockquote_list_simple() {
2497 let content = "> - item 1\n> - item 2";
2499 let warnings = lint(content);
2500 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2501 }
2502
2503 #[test]
2504 fn test_blockquote_list_with_continuation_only() {
2505 let content = "> - item 1\n> continuation\n> - item 2";
2507 let warnings = lint(content);
2508 assert_eq!(
2509 warnings.len(),
2510 0,
2511 "Blockquoted list with continuation should have no warnings"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_blockquote_list_with_lazy_continuation() {
2517 let content = "> - item 1\n> lazy continuation\n> - item 2";
2519 let warnings = lint(content);
2520 assert_eq!(
2521 warnings.len(),
2522 0,
2523 "Blockquoted list with lazy continuation should have no warnings"
2524 );
2525 }
2526
2527 #[test]
2528 fn test_nested_blockquote_list() {
2529 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2531 let warnings = lint(content);
2532 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2533 }
2534
2535 #[test]
2536 fn test_blockquote_list_needs_preceding_blank() {
2537 let content = "> Text before\n> - item 1\n> - item 2";
2539 let warnings = lint(content);
2540 assert_eq!(
2541 warnings.len(),
2542 1,
2543 "Should warn for missing blank before blockquoted list"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_blockquote_list_properly_separated() {
2549 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2551 let warnings = lint(content);
2552 assert_eq!(
2553 warnings.len(),
2554 0,
2555 "Properly separated blockquoted list should have no warnings"
2556 );
2557 }
2558
2559 #[test]
2560 fn test_blockquote_ordered_list() {
2561 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2563 let warnings = lint(content);
2564 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2565 }
2566
2567 #[test]
2568 fn test_blockquote_list_with_empty_blockquote_line() {
2569 let content = "> - item 1\n>\n> - item 2";
2571 let warnings = lint(content);
2572 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2573 }
2574
2575 #[test]
2577 fn test_blockquote_list_multi_paragraph_items() {
2578 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2581 let warnings = lint(content);
2582 assert_eq!(
2583 warnings.len(),
2584 0,
2585 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2586 );
2587 }
2588
2589 #[test]
2591 fn test_blockquote_ordered_list_multi_paragraph_items() {
2592 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2593 let warnings = lint(content);
2594 assert_eq!(
2595 warnings.len(),
2596 0,
2597 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2598 );
2599 }
2600
2601 #[test]
2603 fn test_blockquote_list_multiple_continuations() {
2604 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2605 let warnings = lint(content);
2606 assert_eq!(
2607 warnings.len(),
2608 0,
2609 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2610 );
2611 }
2612
2613 #[test]
2615 fn test_nested_blockquote_multi_paragraph_list() {
2616 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2617 let warnings = lint(content);
2618 assert_eq!(
2619 warnings.len(),
2620 0,
2621 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2622 );
2623 }
2624
2625 #[test]
2627 fn test_triple_nested_blockquote_multi_paragraph_list() {
2628 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2629 let warnings = lint(content);
2630 assert_eq!(
2631 warnings.len(),
2632 0,
2633 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2634 );
2635 }
2636
2637 #[test]
2639 fn test_blockquote_list_last_item_continuation() {
2640 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2641 let warnings = lint(content);
2642 assert_eq!(
2643 warnings.len(),
2644 0,
2645 "Last item with continuation should have no warnings. Got: {warnings:?}"
2646 );
2647 }
2648
2649 #[test]
2651 fn test_blockquote_list_first_item_only_continuation() {
2652 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2653 let warnings = lint(content);
2654 assert_eq!(
2655 warnings.len(),
2656 0,
2657 "Single item with continuation should have no warnings. Got: {warnings:?}"
2658 );
2659 }
2660
2661 #[test]
2665 fn test_blockquote_level_change_breaks_list() {
2666 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2668 let warnings = lint(content);
2669 assert!(
2673 warnings.len() <= 2,
2674 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2675 );
2676 }
2677
2678 #[test]
2680 fn test_exit_blockquote_needs_blank_before_list() {
2681 let content = "> Blockquote text\n\n- List outside blockquote\n";
2683 let warnings = lint(content);
2684 assert_eq!(
2685 warnings.len(),
2686 0,
2687 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2688 );
2689
2690 let content2 = "> Blockquote text\n- List outside blockquote\n";
2694 let warnings2 = lint(content2);
2695 assert!(
2697 warnings2.len() <= 1,
2698 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2699 );
2700 }
2701
2702 #[test]
2704 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2705 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2707 let warnings = lint(content_dash);
2708 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2709
2710 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2712 let warnings = lint(content_asterisk);
2713 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2714
2715 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2717 let warnings = lint(content_plus);
2718 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2719 }
2720
2721 #[test]
2723 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2724 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2725 let warnings = lint(content);
2726 assert_eq!(
2727 warnings.len(),
2728 0,
2729 "Parenthesis ordered markers should work. Got: {warnings:?}"
2730 );
2731 }
2732
2733 #[test]
2735 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2736 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2738 let warnings = lint(content);
2739 assert_eq!(
2740 warnings.len(),
2741 0,
2742 "Multi-digit ordered list should work. Got: {warnings:?}"
2743 );
2744 }
2745
2746 #[test]
2748 fn test_blockquote_multi_paragraph_with_formatting() {
2749 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2750 let warnings = lint(content);
2751 assert_eq!(
2752 warnings.len(),
2753 0,
2754 "Continuation with inline formatting should work. Got: {warnings:?}"
2755 );
2756 }
2757
2758 #[test]
2760 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2761 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2762 let warnings = lint(content);
2763 assert_eq!(
2764 warnings.len(),
2765 0,
2766 "All items with continuations should work. Got: {warnings:?}"
2767 );
2768 }
2769
2770 #[test]
2772 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2773 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2774 let warnings = lint(content);
2775 assert_eq!(
2776 warnings.len(),
2777 0,
2778 "Lowercase continuation should work. Got: {warnings:?}"
2779 );
2780 }
2781
2782 #[test]
2784 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2785 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2786 let warnings = lint(content);
2787 assert_eq!(
2788 warnings.len(),
2789 0,
2790 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2791 );
2792 }
2793
2794 #[test]
2796 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2797 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2799 let warnings = lint(content);
2800 assert!(
2802 warnings.len() <= 1,
2803 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2804 );
2805 }
2806
2807 #[test]
2809 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2810 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2812 let warnings = lint(content);
2813 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2814 }
2815
2816 #[test]
2817 fn test_blockquote_list_varying_spaces_after_marker() {
2818 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2820 let warnings = lint(content);
2821 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2822 }
2823
2824 #[test]
2825 fn test_deeply_nested_blockquote_list() {
2826 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2828 let warnings = lint(content);
2829 assert_eq!(
2830 warnings.len(),
2831 0,
2832 "Deeply nested blockquote list should have no warnings"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_blockquote_level_change_in_list() {
2838 let content = "> - item 1\n>> - deeper item\n> - item 2";
2840 let warnings = lint(content);
2843 assert!(
2844 !warnings.is_empty(),
2845 "Blockquote level change should break list and trigger warnings"
2846 );
2847 }
2848
2849 #[test]
2850 fn test_blockquote_list_with_code_span() {
2851 let content = "> - item with `code`\n> continuation\n> - item 2";
2853 let warnings = lint(content);
2854 assert_eq!(
2855 warnings.len(),
2856 0,
2857 "Blockquote list with code span should have no warnings"
2858 );
2859 }
2860
2861 #[test]
2862 fn test_blockquote_list_at_document_end() {
2863 let content = "> Some text\n>\n> - item 1\n> - item 2";
2865 let warnings = lint(content);
2866 assert_eq!(
2867 warnings.len(),
2868 0,
2869 "Blockquote list at document end should have no warnings"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_fix_preserves_blockquote_prefix_before_list() {
2875 let content = "> Text before
2877> - Item 1
2878> - Item 2";
2879 let fixed = fix(content);
2880
2881 let expected = "> Text before
2883>
2884> - Item 1
2885> - Item 2";
2886 assert_eq!(
2887 fixed, expected,
2888 "Fix should insert '>' blank line, not plain blank line"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2894 let content = ">>> Triple nested
2897>>> - Item 1
2898>>> - Item 2
2899>>> More text";
2900 let fixed = fix(content);
2901
2902 let expected = ">>> Triple nested
2904>>>
2905>>> - Item 1
2906>>> - Item 2
2907>>> More text";
2908 assert_eq!(
2909 fixed, expected,
2910 "Fix should preserve triple-nested blockquote prefix '>>>'"
2911 );
2912 }
2913
2914 fn lint_quarto(content: &str) -> Vec<LintWarning> {
2917 let rule = MD032BlanksAroundLists::default();
2918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Quarto, None);
2919 rule.check(&ctx).unwrap()
2920 }
2921
2922 #[test]
2923 fn test_quarto_list_after_div_open() {
2924 let content = "Content\n\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
2926 let warnings = lint_quarto(content);
2927 assert!(
2929 warnings.is_empty(),
2930 "Quarto div marker should be transparent before list: {warnings:?}"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_quarto_list_before_div_close() {
2936 let content = "::: {.callout-note}\n\n- Item 1\n- Item 2\n:::\n";
2938 let warnings = lint_quarto(content);
2939 assert!(
2941 warnings.is_empty(),
2942 "Quarto div marker should be transparent after list: {warnings:?}"
2943 );
2944 }
2945
2946 #[test]
2947 fn test_quarto_list_needs_blank_without_div() {
2948 let content = "Content\n::: {.callout-note}\n- Item 1\n- Item 2\n:::\n";
2950 let warnings = lint_quarto(content);
2951 assert!(
2954 !warnings.is_empty(),
2955 "Should still require blank when not present: {warnings:?}"
2956 );
2957 }
2958
2959 #[test]
2960 fn test_quarto_list_in_callout_with_content() {
2961 let content = "::: {.callout-note}\nNote introduction:\n\n- Item 1\n- Item 2\n\nMore note content.\n:::\n";
2963 let warnings = lint_quarto(content);
2964 assert!(
2965 warnings.is_empty(),
2966 "List with proper blanks inside callout should pass: {warnings:?}"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_quarto_div_markers_not_transparent_in_standard_flavor() {
2972 let content = "Content\n\n:::\n- Item 1\n- Item 2\n:::\n";
2974 let warnings = lint(content); assert!(
2977 !warnings.is_empty(),
2978 "Standard flavor should not treat ::: as transparent: {warnings:?}"
2979 );
2980 }
2981
2982 #[test]
2983 fn test_quarto_nested_divs_with_list() {
2984 let content = "::: {.outer}\n::: {.inner}\n\n- Item 1\n- Item 2\n\n:::\n:::\n";
2986 let warnings = lint_quarto(content);
2987 assert!(warnings.is_empty(), "Nested divs with list should work: {warnings:?}");
2988 }
2989}