1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::utils::element_cache::ElementCache;
3use crate::utils::range_utils::{LineIndex, calculate_line_range};
4use crate::utils::regex_cache::BLOCKQUOTE_PREFIX_RE;
5use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
6use regex::Regex;
7use std::collections::HashSet;
8use std::sync::LazyLock;
9
10mod md032_config;
11pub use md032_config::MD032Config;
12
13static ORDERED_LIST_NON_ONE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*([2-9]|\d{2,})\.\s").unwrap());
15
16fn is_thematic_break(line: &str) -> bool {
19 if ElementCache::calculate_indentation_width_default(line) > 3 {
21 return false;
22 }
23
24 let trimmed = line.trim();
25 if trimmed.len() < 3 {
26 return false;
27 }
28
29 let chars: Vec<char> = trimmed.chars().collect();
30 let first_non_space = chars.iter().find(|&&c| c != ' ');
31
32 if let Some(&marker) = first_non_space {
33 if marker != '-' && marker != '*' && marker != '_' {
34 return false;
35 }
36 let marker_count = chars.iter().filter(|&&c| c == marker).count();
37 let other_count = chars.iter().filter(|&&c| c != marker && c != ' ').count();
38 marker_count >= 3 && other_count == 0
39 } else {
40 false
41 }
42}
43
44#[derive(Debug, Clone, Default)]
114pub struct MD032BlanksAroundLists {
115 config: MD032Config,
116}
117
118impl MD032BlanksAroundLists {
119 pub fn from_config_struct(config: MD032Config) -> Self {
120 Self { config }
121 }
122}
123
124impl MD032BlanksAroundLists {
125 fn should_require_blank_line_before(
127 ctx: &crate::lint_context::LintContext,
128 prev_line_num: usize,
129 current_line_num: usize,
130 ) -> bool {
131 if ctx
133 .line_info(prev_line_num)
134 .is_some_and(|info| info.in_code_block || info.in_front_matter)
135 {
136 return true;
137 }
138
139 if Self::is_nested_list(ctx, prev_line_num, current_line_num) {
141 return false;
142 }
143
144 true
146 }
147
148 fn is_nested_list(
150 ctx: &crate::lint_context::LintContext,
151 prev_line_num: usize, current_line_num: usize, ) -> bool {
154 if current_line_num > 0 && current_line_num - 1 < ctx.lines.len() {
156 let current_line = &ctx.lines[current_line_num - 1];
157 if current_line.indent >= 2 {
158 if prev_line_num > 0 && prev_line_num - 1 < ctx.lines.len() {
160 let prev_line = &ctx.lines[prev_line_num - 1];
161 if prev_line.list_item.is_some() || prev_line.indent >= 2 {
163 return true;
164 }
165 }
166 }
167 }
168 false
169 }
170
171 fn detect_lazy_continuation_lines(ctx: &crate::lint_context::LintContext) -> HashSet<usize> {
177 let mut lazy_lines = HashSet::new();
178 let parser = Parser::new_ext(ctx.content, Options::all());
179
180 let mut item_stack: Vec<usize> = vec![];
182 let mut after_soft_break = false;
183
184 for (event, range) in parser.into_offset_iter() {
185 match event {
186 Event::Start(Tag::Item) => {
187 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
189 let content_col = ctx
190 .lines
191 .get(line_num.saturating_sub(1))
192 .and_then(|li| li.list_item.as_ref())
193 .map(|item| item.content_column)
194 .unwrap_or(0);
195 item_stack.push(content_col);
196 after_soft_break = false;
197 }
198 Event::End(TagEnd::Item) => {
199 item_stack.pop();
200 after_soft_break = false;
201 }
202 Event::SoftBreak if !item_stack.is_empty() => {
203 after_soft_break = true;
204 }
205 Event::Text(_) | Event::Code(_) if after_soft_break => {
208 if let Some(&expected_col) = item_stack.last() {
209 let line_num = Self::byte_to_line(&ctx.line_offsets, range.start);
210 let actual_indent = ctx
211 .lines
212 .get(line_num.saturating_sub(1))
213 .map(|li| li.indent)
214 .unwrap_or(0);
215
216 if actual_indent < expected_col {
218 lazy_lines.insert(line_num);
219 }
220 }
221 after_soft_break = false;
222 }
223 _ => {
224 after_soft_break = false;
225 }
226 }
227 }
228
229 lazy_lines
230 }
231
232 fn byte_to_line(line_offsets: &[usize], byte_offset: usize) -> usize {
234 match line_offsets.binary_search(&byte_offset) {
235 Ok(idx) => idx + 1,
236 Err(idx) => idx.max(1),
237 }
238 }
239
240 fn find_preceding_content(ctx: &crate::lint_context::LintContext, before_line: usize) -> (usize, bool) {
248 for line_num in (1..before_line).rev() {
249 let idx = line_num - 1;
250 if let Some(info) = ctx.lines.get(idx) {
251 if info.in_html_comment {
253 continue;
254 }
255 return (line_num, info.is_blank);
256 }
257 }
258 (0, true)
260 }
261
262 fn find_following_content(ctx: &crate::lint_context::LintContext, after_line: usize) -> (usize, bool) {
267 let num_lines = ctx.lines.len();
268 for line_num in (after_line + 1)..=num_lines {
269 let idx = line_num - 1;
270 if let Some(info) = ctx.lines.get(idx) {
271 if info.in_html_comment {
273 continue;
274 }
275 return (line_num, info.is_blank);
276 }
277 }
278 (0, true)
280 }
281
282 fn convert_list_blocks(&self, ctx: &crate::lint_context::LintContext) -> Vec<(usize, usize, String)> {
284 let mut blocks: Vec<(usize, usize, String)> = Vec::new();
285
286 for block in &ctx.list_blocks {
287 let mut segments: Vec<(usize, usize)> = Vec::new();
293 let mut current_start = block.start_line;
294 let mut prev_item_line = 0;
295
296 let get_blockquote_level = |line_num: usize| -> usize {
298 if line_num == 0 || line_num > ctx.lines.len() {
299 return 0;
300 }
301 let line_content = ctx.lines[line_num - 1].content(ctx.content);
302 BLOCKQUOTE_PREFIX_RE
303 .find(line_content)
304 .map(|m| m.as_str().chars().filter(|&c| c == '>').count())
305 .unwrap_or(0)
306 };
307
308 let mut prev_bq_level = 0;
309
310 for &item_line in &block.item_lines {
311 let current_bq_level = get_blockquote_level(item_line);
312
313 if prev_item_line > 0 {
314 let blockquote_level_changed = prev_bq_level != current_bq_level;
316
317 let mut has_standalone_code_fence = false;
320
321 let min_indent_for_content = if block.is_ordered {
323 3 } else {
327 2 };
330
331 for check_line in (prev_item_line + 1)..item_line {
332 if check_line - 1 < ctx.lines.len() {
333 let line = &ctx.lines[check_line - 1];
334 let line_content = line.content(ctx.content);
335 if line.in_code_block
336 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
337 {
338 if line.indent < min_indent_for_content {
341 has_standalone_code_fence = true;
342 break;
343 }
344 }
345 }
346 }
347
348 if has_standalone_code_fence || blockquote_level_changed {
349 segments.push((current_start, prev_item_line));
351 current_start = item_line;
352 }
353 }
354 prev_item_line = item_line;
355 prev_bq_level = current_bq_level;
356 }
357
358 if prev_item_line > 0 {
361 segments.push((current_start, prev_item_line));
362 }
363
364 let has_code_fence_splits = segments.len() > 1 && {
366 let mut found_fence = false;
368 for i in 0..segments.len() - 1 {
369 let seg_end = segments[i].1;
370 let next_start = segments[i + 1].0;
371 for check_line in (seg_end + 1)..next_start {
373 if check_line - 1 < ctx.lines.len() {
374 let line = &ctx.lines[check_line - 1];
375 let line_content = line.content(ctx.content);
376 if line.in_code_block
377 && (line_content.trim().starts_with("```") || line_content.trim().starts_with("~~~"))
378 {
379 found_fence = true;
380 break;
381 }
382 }
383 }
384 if found_fence {
385 break;
386 }
387 }
388 found_fence
389 };
390
391 for (start, end) in segments.iter() {
393 let mut actual_end = *end;
395
396 if !has_code_fence_splits && *end < block.end_line {
399 let min_continuation_indent = ctx
402 .lines
403 .get(*end - 1)
404 .and_then(|line_info| line_info.list_item.as_ref())
405 .map(|item| item.content_column)
406 .unwrap_or(2);
407
408 for check_line in (*end + 1)..=block.end_line {
409 if check_line - 1 < ctx.lines.len() {
410 let line = &ctx.lines[check_line - 1];
411 let line_content = line.content(ctx.content);
412 if block.item_lines.contains(&check_line) || line.heading.is_some() {
414 break;
415 }
416 if line.in_code_block {
418 break;
419 }
420 if line.indent >= min_continuation_indent {
422 actual_end = check_line;
423 }
424 else if self.config.allow_lazy_continuation
429 && !line.is_blank
430 && line.heading.is_none()
431 && !block.item_lines.contains(&check_line)
432 && !is_thematic_break(line_content)
433 {
434 actual_end = check_line;
437 } else if !line.is_blank {
438 break;
440 }
441 }
442 }
443 }
444
445 blocks.push((*start, actual_end, block.blockquote_prefix.clone()));
446 }
447 }
448
449 blocks.retain(|(start, end, _)| {
451 let all_in_comment =
453 (*start..=*end).all(|line_num| ctx.lines.get(line_num - 1).is_some_and(|info| info.in_html_comment));
454 !all_in_comment
455 });
456
457 blocks
458 }
459
460 fn perform_checks(
461 &self,
462 ctx: &crate::lint_context::LintContext,
463 lines: &[&str],
464 list_blocks: &[(usize, usize, String)],
465 line_index: &LineIndex,
466 ) -> LintResult {
467 let mut warnings = Vec::new();
468 let num_lines = lines.len();
469
470 for (line_idx, line) in lines.iter().enumerate() {
473 let line_num = line_idx + 1;
474
475 let is_in_list = list_blocks
477 .iter()
478 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
479 if is_in_list {
480 continue;
481 }
482
483 if ctx
485 .line_info(line_num)
486 .is_some_and(|info| info.in_code_block || info.in_front_matter || info.in_html_comment)
487 {
488 continue;
489 }
490
491 if ORDERED_LIST_NON_ONE_RE.is_match(line) {
493 if line_idx > 0 {
495 let prev_line = lines[line_idx - 1];
496 let prev_is_blank = is_blank_in_context(prev_line);
497 let prev_excluded = ctx
498 .line_info(line_idx)
499 .is_some_and(|info| info.in_code_block || info.in_front_matter);
500
501 let prev_trimmed = prev_line.trim();
506 let is_sentence_continuation = !prev_is_blank
507 && !prev_trimmed.is_empty()
508 && !prev_trimmed.ends_with('.')
509 && !prev_trimmed.ends_with('!')
510 && !prev_trimmed.ends_with('?')
511 && !prev_trimmed.ends_with(':')
512 && !prev_trimmed.ends_with(';')
513 && !prev_trimmed.ends_with('>')
514 && !prev_trimmed.ends_with('-')
515 && !prev_trimmed.ends_with('*');
516
517 if !prev_is_blank && !prev_excluded && !is_sentence_continuation {
518 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
520
521 let bq_prefix = ctx.blockquote_prefix_for_blank_line(line_idx);
522 warnings.push(LintWarning {
523 line: start_line,
524 column: start_col,
525 end_line,
526 end_column: end_col,
527 severity: Severity::Warning,
528 rule_name: Some(self.name().to_string()),
529 message: "Ordered list starting with non-1 should be preceded by blank line".to_string(),
530 fix: Some(Fix {
531 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
532 replacement: format!("{bq_prefix}\n"),
533 }),
534 });
535 }
536 }
537 }
538 }
539
540 for &(start_line, end_line, ref prefix) in list_blocks {
541 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
543 continue;
544 }
545
546 if start_line > 1 {
547 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
549
550 if !has_blank_separation && content_line > 0 {
552 let prev_line_str = lines[content_line - 1];
553 let is_prev_excluded = ctx
554 .line_info(content_line)
555 .is_some_and(|info| info.in_code_block || info.in_front_matter);
556 let prev_prefix = BLOCKQUOTE_PREFIX_RE
557 .find(prev_line_str)
558 .map_or(String::new(), |m| m.as_str().to_string());
559 let prefixes_match = prev_prefix.trim() == prefix.trim();
560
561 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
564 if !is_prev_excluded && prefixes_match && should_require {
565 let (start_line, start_col, end_line, end_col) =
567 calculate_line_range(start_line, lines[start_line - 1]);
568
569 warnings.push(LintWarning {
570 line: start_line,
571 column: start_col,
572 end_line,
573 end_column: end_col,
574 severity: Severity::Warning,
575 rule_name: Some(self.name().to_string()),
576 message: "List should be preceded by blank line".to_string(),
577 fix: Some(Fix {
578 range: line_index.line_col_to_byte_range_with_length(start_line, 1, 0),
579 replacement: format!("{prefix}\n"),
580 }),
581 });
582 }
583 }
584 }
585
586 if end_line < num_lines {
587 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
589
590 if !has_blank_separation && content_line > 0 {
592 let next_line_str = lines[content_line - 1];
593 let is_next_excluded = ctx.line_info(content_line).is_some_and(|info| info.in_front_matter)
596 || (content_line <= ctx.lines.len()
597 && ctx.lines[content_line - 1].in_code_block
598 && ctx.lines[content_line - 1].indent >= 2);
599 let next_prefix = BLOCKQUOTE_PREFIX_RE
600 .find(next_line_str)
601 .map_or(String::new(), |m| m.as_str().to_string());
602
603 let end_line_str = lines[end_line - 1];
608 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
609 .find(end_line_str)
610 .map_or(String::new(), |m| m.as_str().to_string());
611 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
612 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
613 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
614
615 let prefixes_match = next_prefix.trim() == prefix.trim();
616
617 if !is_next_excluded && prefixes_match && !exits_blockquote {
620 let (start_line_last, start_col_last, end_line_last, end_col_last) =
622 calculate_line_range(end_line, lines[end_line - 1]);
623
624 warnings.push(LintWarning {
625 line: start_line_last,
626 column: start_col_last,
627 end_line: end_line_last,
628 end_column: end_col_last,
629 severity: Severity::Warning,
630 rule_name: Some(self.name().to_string()),
631 message: "List should be followed by blank line".to_string(),
632 fix: Some(Fix {
633 range: line_index.line_col_to_byte_range_with_length(end_line + 1, 1, 0),
634 replacement: format!("{prefix}\n"),
635 }),
636 });
637 }
638 }
639 }
640 }
641 Ok(warnings)
642 }
643}
644
645impl Rule for MD032BlanksAroundLists {
646 fn name(&self) -> &'static str {
647 "MD032"
648 }
649
650 fn description(&self) -> &'static str {
651 "Lists should be surrounded by blank lines"
652 }
653
654 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
655 let content = ctx.content;
656 let lines: Vec<&str> = content.lines().collect();
657 let line_index = &ctx.line_index;
658
659 if lines.is_empty() {
661 return Ok(Vec::new());
662 }
663
664 let list_blocks = self.convert_list_blocks(ctx);
665
666 if list_blocks.is_empty() {
667 return Ok(Vec::new());
668 }
669
670 let mut warnings = self.perform_checks(ctx, &lines, &list_blocks, line_index)?;
671
672 if !self.config.allow_lazy_continuation {
677 let lazy_lines = Self::detect_lazy_continuation_lines(ctx);
678
679 for line_num in lazy_lines {
680 let is_within_block = list_blocks
684 .iter()
685 .any(|(start, end, _)| line_num >= *start && line_num <= *end);
686
687 if !is_within_block {
688 continue;
689 }
690
691 let line_content = lines.get(line_num.saturating_sub(1)).unwrap_or(&"");
693 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line_content);
694
695 warnings.push(LintWarning {
699 line: start_line,
700 column: start_col,
701 end_line,
702 end_column: end_col,
703 severity: Severity::Warning,
704 rule_name: Some(self.name().to_string()),
705 message: "Lazy continuation line should be properly indented or preceded by blank line".to_string(),
706 fix: None,
707 });
708 }
709 }
710
711 Ok(warnings)
712 }
713
714 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
715 self.fix_with_structure_impl(ctx)
716 }
717
718 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
719 if ctx.content.is_empty() || !ctx.likely_has_lists() {
721 return true;
722 }
723 ctx.list_blocks.is_empty()
725 }
726
727 fn category(&self) -> RuleCategory {
728 RuleCategory::List
729 }
730
731 fn as_any(&self) -> &dyn std::any::Any {
732 self
733 }
734
735 fn default_config_section(&self) -> Option<(String, toml::Value)> {
736 use crate::rule_config_serde::RuleConfig;
737 let default_config = MD032Config::default();
738 let json_value = serde_json::to_value(&default_config).ok()?;
739 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
740
741 if let toml::Value::Table(table) = toml_value {
742 if !table.is_empty() {
743 Some((MD032Config::RULE_NAME.to_string(), toml::Value::Table(table)))
744 } else {
745 None
746 }
747 } else {
748 None
749 }
750 }
751
752 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
753 where
754 Self: Sized,
755 {
756 let rule_config = crate::rule_config_serde::load_rule_config::<MD032Config>(config);
757 Box::new(MD032BlanksAroundLists::from_config_struct(rule_config))
758 }
759}
760
761impl MD032BlanksAroundLists {
762 fn fix_with_structure_impl(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
764 let lines: Vec<&str> = ctx.content.lines().collect();
765 let num_lines = lines.len();
766 if num_lines == 0 {
767 return Ok(String::new());
768 }
769
770 let list_blocks = self.convert_list_blocks(ctx);
771 if list_blocks.is_empty() {
772 return Ok(ctx.content.to_string());
773 }
774
775 let mut insertions: std::collections::BTreeMap<usize, String> = std::collections::BTreeMap::new();
776
777 for &(start_line, end_line, ref prefix) in &list_blocks {
779 if ctx.line_info(start_line).is_some_and(|info| info.in_html_comment) {
781 continue;
782 }
783
784 if start_line > 1 {
786 let (content_line, has_blank_separation) = Self::find_preceding_content(ctx, start_line);
788
789 if !has_blank_separation && content_line > 0 {
791 let prev_line_str = lines[content_line - 1];
792 let is_prev_excluded = ctx
793 .line_info(content_line)
794 .is_some_and(|info| info.in_code_block || info.in_front_matter);
795 let prev_prefix = BLOCKQUOTE_PREFIX_RE
796 .find(prev_line_str)
797 .map_or(String::new(), |m| m.as_str().to_string());
798
799 let should_require = Self::should_require_blank_line_before(ctx, content_line, start_line);
800 if !is_prev_excluded && prev_prefix.trim() == prefix.trim() && should_require {
802 let bq_prefix = ctx.blockquote_prefix_for_blank_line(start_line - 1);
804 insertions.insert(start_line, bq_prefix);
805 }
806 }
807 }
808
809 if end_line < num_lines {
811 let (content_line, has_blank_separation) = Self::find_following_content(ctx, end_line);
813
814 if !has_blank_separation && content_line > 0 {
816 let next_line_str = lines[content_line - 1];
817 let is_next_excluded = ctx
819 .line_info(content_line)
820 .is_some_and(|info| info.in_code_block || info.in_front_matter)
821 || (content_line <= ctx.lines.len()
822 && ctx.lines[content_line - 1].in_code_block
823 && ctx.lines[content_line - 1].indent >= 2
824 && (ctx.lines[content_line - 1]
825 .content(ctx.content)
826 .trim()
827 .starts_with("```")
828 || ctx.lines[content_line - 1]
829 .content(ctx.content)
830 .trim()
831 .starts_with("~~~")));
832 let next_prefix = BLOCKQUOTE_PREFIX_RE
833 .find(next_line_str)
834 .map_or(String::new(), |m| m.as_str().to_string());
835
836 let end_line_str = lines[end_line - 1];
838 let end_line_prefix = BLOCKQUOTE_PREFIX_RE
839 .find(end_line_str)
840 .map_or(String::new(), |m| m.as_str().to_string());
841 let end_line_bq_level = end_line_prefix.chars().filter(|&c| c == '>').count();
842 let next_line_bq_level = next_prefix.chars().filter(|&c| c == '>').count();
843 let exits_blockquote = end_line_bq_level > 0 && next_line_bq_level < end_line_bq_level;
844
845 if !is_next_excluded && next_prefix.trim() == prefix.trim() && !exits_blockquote {
848 let bq_prefix = ctx.blockquote_prefix_for_blank_line(end_line - 1);
850 insertions.insert(end_line + 1, bq_prefix);
851 }
852 }
853 }
854 }
855
856 let mut result_lines: Vec<String> = Vec::with_capacity(num_lines + insertions.len());
858 for (i, line) in lines.iter().enumerate() {
859 let current_line_num = i + 1;
860 if let Some(prefix_to_insert) = insertions.get(¤t_line_num)
861 && (result_lines.is_empty() || result_lines.last().unwrap() != prefix_to_insert)
862 {
863 result_lines.push(prefix_to_insert.clone());
864 }
865 result_lines.push(line.to_string());
866 }
867
868 let mut result = result_lines.join("\n");
870 if ctx.content.ends_with('\n') {
871 result.push('\n');
872 }
873 Ok(result)
874 }
875}
876
877fn is_blank_in_context(line: &str) -> bool {
879 if let Some(m) = BLOCKQUOTE_PREFIX_RE.find(line) {
882 line[m.end()..].trim().is_empty()
884 } else {
885 line.trim().is_empty()
887 }
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893 use crate::lint_context::LintContext;
894 use crate::rule::Rule;
895
896 fn lint(content: &str) -> Vec<LintWarning> {
897 let rule = MD032BlanksAroundLists::default();
898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899 rule.check(&ctx).expect("Lint check failed")
900 }
901
902 fn fix(content: &str) -> String {
903 let rule = MD032BlanksAroundLists::default();
904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905 rule.fix(&ctx).expect("Lint fix failed")
906 }
907
908 fn check_warnings_have_fixes(content: &str) {
910 let warnings = lint(content);
911 for warning in &warnings {
912 assert!(warning.fix.is_some(), "Warning should have fix: {warning:?}");
913 }
914 }
915
916 #[test]
917 fn test_list_at_start() {
918 let content = "- Item 1\n- Item 2\nText";
921 let warnings = lint(content);
922 assert_eq!(
923 warnings.len(),
924 0,
925 "Trailing text is lazy continuation per CommonMark - no warning expected"
926 );
927 }
928
929 #[test]
930 fn test_list_at_end() {
931 let content = "Text\n- Item 1\n- Item 2";
932 let warnings = lint(content);
933 assert_eq!(
934 warnings.len(),
935 1,
936 "Expected 1 warning for list at end without preceding blank line"
937 );
938 assert_eq!(
939 warnings[0].line, 2,
940 "Warning should be on the first line of the list (line 2)"
941 );
942 assert!(warnings[0].message.contains("preceded by blank line"));
943
944 check_warnings_have_fixes(content);
946
947 let fixed_content = fix(content);
948 assert_eq!(fixed_content, "Text\n\n- Item 1\n- Item 2");
949
950 let warnings_after_fix = lint(&fixed_content);
952 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
953 }
954
955 #[test]
956 fn test_list_in_middle() {
957 let content = "Text 1\n- Item 1\n- Item 2\nText 2";
960 let warnings = lint(content);
961 assert_eq!(
962 warnings.len(),
963 1,
964 "Expected 1 warning for list needing preceding blank line (trailing text is lazy continuation)"
965 );
966 assert_eq!(warnings[0].line, 2, "Warning on line 2 (start)");
967 assert!(warnings[0].message.contains("preceded by blank line"));
968
969 check_warnings_have_fixes(content);
971
972 let fixed_content = fix(content);
973 assert_eq!(fixed_content, "Text 1\n\n- Item 1\n- Item 2\nText 2");
974
975 let warnings_after_fix = lint(&fixed_content);
977 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
978 }
979
980 #[test]
981 fn test_correct_spacing() {
982 let content = "Text 1\n\n- Item 1\n- Item 2\n\nText 2";
983 let warnings = lint(content);
984 assert_eq!(warnings.len(), 0, "Expected no warnings for correctly spaced list");
985
986 let fixed_content = fix(content);
987 assert_eq!(fixed_content, content, "Fix should not change correctly spaced content");
988 }
989
990 #[test]
991 fn test_list_with_content() {
992 let content = "Text\n* Item 1\n Content\n* Item 2\n More content\nText";
995 let warnings = lint(content);
996 assert_eq!(
997 warnings.len(),
998 1,
999 "Expected 1 warning for list needing preceding blank line. Got: {warnings:?}"
1000 );
1001 assert_eq!(warnings[0].line, 2, "Warning should be on line 2 (start)");
1002 assert!(warnings[0].message.contains("preceded by blank line"));
1003
1004 check_warnings_have_fixes(content);
1006
1007 let fixed_content = fix(content);
1008 let expected_fixed = "Text\n\n* Item 1\n Content\n* Item 2\n More content\nText";
1009 assert_eq!(
1010 fixed_content, expected_fixed,
1011 "Fix did not produce the expected output. Got:\n{fixed_content}"
1012 );
1013
1014 let warnings_after_fix = lint(&fixed_content);
1016 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1017 }
1018
1019 #[test]
1020 fn test_nested_list() {
1021 let content = "Text\n- Item 1\n - Nested 1\n- Item 2\nText";
1023 let warnings = lint(content);
1024 assert_eq!(
1025 warnings.len(),
1026 1,
1027 "Nested list block needs preceding blank only. Got: {warnings:?}"
1028 );
1029 assert_eq!(warnings[0].line, 2);
1030 assert!(warnings[0].message.contains("preceded by blank line"));
1031
1032 check_warnings_have_fixes(content);
1034
1035 let fixed_content = fix(content);
1036 assert_eq!(fixed_content, "Text\n\n- Item 1\n - Nested 1\n- Item 2\nText");
1037
1038 let warnings_after_fix = lint(&fixed_content);
1040 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1041 }
1042
1043 #[test]
1044 fn test_list_with_internal_blanks() {
1045 let content = "Text\n* Item 1\n\n More Item 1 Content\n* Item 2\nText";
1047 let warnings = lint(content);
1048 assert_eq!(
1049 warnings.len(),
1050 1,
1051 "List with internal blanks needs preceding blank only. Got: {warnings:?}"
1052 );
1053 assert_eq!(warnings[0].line, 2);
1054 assert!(warnings[0].message.contains("preceded by blank line"));
1055
1056 check_warnings_have_fixes(content);
1058
1059 let fixed_content = fix(content);
1060 assert_eq!(
1061 fixed_content,
1062 "Text\n\n* Item 1\n\n More Item 1 Content\n* Item 2\nText"
1063 );
1064
1065 let warnings_after_fix = lint(&fixed_content);
1067 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1068 }
1069
1070 #[test]
1071 fn test_ignore_code_blocks() {
1072 let content = "```\n- Not a list item\n```\nText";
1073 let warnings = lint(content);
1074 assert_eq!(warnings.len(), 0);
1075 let fixed_content = fix(content);
1076 assert_eq!(fixed_content, content);
1077 }
1078
1079 #[test]
1080 fn test_ignore_front_matter() {
1081 let content = "---\ntitle: Test\n---\n- List Item\nText";
1083 let warnings = lint(content);
1084 assert_eq!(
1085 warnings.len(),
1086 0,
1087 "Front matter test should have no MD032 warnings. Got: {warnings:?}"
1088 );
1089
1090 let fixed_content = fix(content);
1092 assert_eq!(fixed_content, content, "No changes when no warnings");
1093 }
1094
1095 #[test]
1096 fn test_multiple_lists() {
1097 let content = "Text\n- List 1 Item 1\n- List 1 Item 2\nText 2\n* List 2 Item 1\nText 3";
1102 let warnings = lint(content);
1103 assert!(
1105 !warnings.is_empty(),
1106 "Should have at least one warning for missing blank line. Got: {warnings:?}"
1107 );
1108
1109 check_warnings_have_fixes(content);
1111
1112 let fixed_content = fix(content);
1113 let warnings_after_fix = lint(&fixed_content);
1115 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1116 }
1117
1118 #[test]
1119 fn test_adjacent_lists() {
1120 let content = "- List 1\n\n* List 2";
1121 let warnings = lint(content);
1122 assert_eq!(warnings.len(), 0);
1123 let fixed_content = fix(content);
1124 assert_eq!(fixed_content, content);
1125 }
1126
1127 #[test]
1128 fn test_list_in_blockquote() {
1129 let content = "> Quote line 1\n> - List item 1\n> - List item 2\n> Quote line 2";
1131 let warnings = lint(content);
1132 assert_eq!(
1133 warnings.len(),
1134 1,
1135 "Expected 1 warning for blockquoted list needing preceding blank. Got: {warnings:?}"
1136 );
1137 assert_eq!(warnings[0].line, 2);
1138
1139 check_warnings_have_fixes(content);
1141
1142 let fixed_content = fix(content);
1143 assert_eq!(
1145 fixed_content, "> Quote line 1\n>\n> - List item 1\n> - List item 2\n> Quote line 2",
1146 "Fix for blockquoted list failed. Got:\n{fixed_content}"
1147 );
1148
1149 let warnings_after_fix = lint(&fixed_content);
1151 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1152 }
1153
1154 #[test]
1155 fn test_ordered_list() {
1156 let content = "Text\n1. Item 1\n2. Item 2\nText";
1158 let warnings = lint(content);
1159 assert_eq!(warnings.len(), 1);
1160
1161 check_warnings_have_fixes(content);
1163
1164 let fixed_content = fix(content);
1165 assert_eq!(fixed_content, "Text\n\n1. Item 1\n2. Item 2\nText");
1166
1167 let warnings_after_fix = lint(&fixed_content);
1169 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1170 }
1171
1172 #[test]
1173 fn test_no_double_blank_fix() {
1174 let content = "Text\n\n- Item 1\n- Item 2\nText"; let warnings = lint(content);
1177 assert_eq!(
1178 warnings.len(),
1179 0,
1180 "Should have no warnings - properly preceded, trailing is lazy"
1181 );
1182
1183 let fixed_content = fix(content);
1184 assert_eq!(
1185 fixed_content, content,
1186 "No fix needed when no warnings. Got:\n{fixed_content}"
1187 );
1188
1189 let content2 = "Text\n- Item 1\n- Item 2\n\nText"; let warnings2 = lint(content2);
1191 assert_eq!(warnings2.len(), 1);
1192 if !warnings2.is_empty() {
1193 assert_eq!(
1194 warnings2[0].line, 2,
1195 "Warning line for missing blank before should be the first line of the block"
1196 );
1197 }
1198
1199 check_warnings_have_fixes(content2);
1201
1202 let fixed_content2 = fix(content2);
1203 assert_eq!(
1204 fixed_content2, "Text\n\n- Item 1\n- Item 2\n\nText",
1205 "Fix added extra blank before. Got:\n{fixed_content2}"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_empty_input() {
1211 let content = "";
1212 let warnings = lint(content);
1213 assert_eq!(warnings.len(), 0);
1214 let fixed_content = fix(content);
1215 assert_eq!(fixed_content, "");
1216 }
1217
1218 #[test]
1219 fn test_only_list() {
1220 let content = "- Item 1\n- Item 2";
1221 let warnings = lint(content);
1222 assert_eq!(warnings.len(), 0);
1223 let fixed_content = fix(content);
1224 assert_eq!(fixed_content, content);
1225 }
1226
1227 #[test]
1230 fn test_fix_complex_nested_blockquote() {
1231 let content = "> Text before\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1233 let warnings = lint(content);
1234 assert_eq!(
1235 warnings.len(),
1236 1,
1237 "Should warn for missing preceding blank only. Got: {warnings:?}"
1238 );
1239
1240 check_warnings_have_fixes(content);
1242
1243 let fixed_content = fix(content);
1244 let expected = "> Text before\n>\n> - Item 1\n> - Nested item\n> - Item 2\n> Text after";
1246 assert_eq!(fixed_content, expected, "Fix should preserve blockquote structure");
1247
1248 let warnings_after_fix = lint(&fixed_content);
1249 assert_eq!(warnings_after_fix.len(), 0, "Fix should eliminate all warnings");
1250 }
1251
1252 #[test]
1253 fn test_fix_mixed_list_markers() {
1254 let content = "Text\n- Item 1\n* Item 2\n+ Item 3\nText";
1257 let warnings = lint(content);
1258 assert!(
1260 !warnings.is_empty(),
1261 "Should have at least 1 warning for mixed marker list. Got: {warnings:?}"
1262 );
1263
1264 check_warnings_have_fixes(content);
1266
1267 let fixed_content = fix(content);
1268 assert!(
1270 fixed_content.contains("Text\n\n-"),
1271 "Fix should add blank line before first list item"
1272 );
1273
1274 let warnings_after_fix = lint(&fixed_content);
1276 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1277 }
1278
1279 #[test]
1280 fn test_fix_ordered_list_with_different_numbers() {
1281 let content = "Text\n1. First\n3. Third\n2. Second\nText";
1283 let warnings = lint(content);
1284 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1285
1286 check_warnings_have_fixes(content);
1288
1289 let fixed_content = fix(content);
1290 let expected = "Text\n\n1. First\n3. Third\n2. Second\nText";
1291 assert_eq!(
1292 fixed_content, expected,
1293 "Fix should handle ordered lists with non-sequential numbers"
1294 );
1295
1296 let warnings_after_fix = lint(&fixed_content);
1298 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1299 }
1300
1301 #[test]
1302 fn test_fix_list_with_code_blocks_inside() {
1303 let content = "Text\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1305 let warnings = lint(content);
1306 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1307
1308 check_warnings_have_fixes(content);
1310
1311 let fixed_content = fix(content);
1312 let expected = "Text\n\n- Item 1\n ```\n code\n ```\n- Item 2\nText";
1313 assert_eq!(
1314 fixed_content, expected,
1315 "Fix should handle lists with internal code blocks"
1316 );
1317
1318 let warnings_after_fix = lint(&fixed_content);
1320 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1321 }
1322
1323 #[test]
1324 fn test_fix_deeply_nested_lists() {
1325 let content = "Text\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1327 let warnings = lint(content);
1328 assert_eq!(warnings.len(), 1, "Should warn for missing preceding blank only");
1329
1330 check_warnings_have_fixes(content);
1332
1333 let fixed_content = fix(content);
1334 let expected = "Text\n\n- Level 1\n - Level 2\n - Level 3\n - Level 4\n- Back to Level 1\nText";
1335 assert_eq!(fixed_content, expected, "Fix should handle deeply nested lists");
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_list_with_multiline_items() {
1344 let content = "Text\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1347 let warnings = lint(content);
1348 assert_eq!(
1349 warnings.len(),
1350 1,
1351 "Should only warn for missing blank before list (trailing text is lazy continuation)"
1352 );
1353
1354 check_warnings_have_fixes(content);
1356
1357 let fixed_content = fix(content);
1358 let expected = "Text\n\n- Item 1\n continues here\n and here\n- Item 2\n also continues\nText";
1359 assert_eq!(fixed_content, expected, "Fix should add blank before list only");
1360
1361 let warnings_after_fix = lint(&fixed_content);
1363 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1364 }
1365
1366 #[test]
1367 fn test_fix_list_at_document_boundaries() {
1368 let content1 = "- Item 1\n- Item 2";
1370 let warnings1 = lint(content1);
1371 assert_eq!(
1372 warnings1.len(),
1373 0,
1374 "List at document start should not need blank before"
1375 );
1376 let fixed1 = fix(content1);
1377 assert_eq!(fixed1, content1, "No fix needed for list at start");
1378
1379 let content2 = "Text\n- Item 1\n- Item 2";
1381 let warnings2 = lint(content2);
1382 assert_eq!(warnings2.len(), 1, "List at document end should need blank before");
1383 check_warnings_have_fixes(content2);
1384 let fixed2 = fix(content2);
1385 assert_eq!(
1386 fixed2, "Text\n\n- Item 1\n- Item 2",
1387 "Should add blank before list at end"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_fix_preserves_existing_blank_lines() {
1393 let content = "Text\n\n\n- Item 1\n- Item 2\n\n\nText";
1394 let warnings = lint(content);
1395 assert_eq!(warnings.len(), 0, "Multiple blank lines should be preserved");
1396 let fixed_content = fix(content);
1397 assert_eq!(fixed_content, content, "Fix should not modify already correct content");
1398 }
1399
1400 #[test]
1401 fn test_fix_handles_tabs_and_spaces() {
1402 let content = "Text\n\t- Item with tab\n - Item with spaces\nText";
1405 let warnings = lint(content);
1406 assert!(!warnings.is_empty(), "Should warn for missing blank before list");
1408
1409 check_warnings_have_fixes(content);
1411
1412 let fixed_content = fix(content);
1413 let expected = "Text\n\t- Item with tab\n\n - Item with spaces\nText";
1416 assert_eq!(fixed_content, expected, "Fix should add blank before list item");
1417
1418 let warnings_after_fix = lint(&fixed_content);
1420 assert_eq!(warnings_after_fix.len(), 0, "Fix should resolve all warnings");
1421 }
1422
1423 #[test]
1424 fn test_fix_warning_objects_have_correct_ranges() {
1425 let content = "Text\n- Item 1\n- Item 2\nText";
1427 let warnings = lint(content);
1428 assert_eq!(warnings.len(), 1, "Only preceding blank warning expected");
1429
1430 for warning in &warnings {
1432 assert!(warning.fix.is_some(), "Warning should have fix");
1433 let fix = warning.fix.as_ref().unwrap();
1434 assert!(fix.range.start <= fix.range.end, "Fix range should be valid");
1435 assert!(
1436 !fix.replacement.is_empty() || fix.range.start == fix.range.end,
1437 "Fix should have replacement or be insertion"
1438 );
1439 }
1440 }
1441
1442 #[test]
1443 fn test_fix_idempotent() {
1444 let content = "Text\n- Item 1\n- Item 2\nText";
1446
1447 let fixed_once = fix(content);
1449 assert_eq!(fixed_once, "Text\n\n- Item 1\n- Item 2\nText");
1450
1451 let fixed_twice = fix(&fixed_once);
1453 assert_eq!(fixed_twice, fixed_once, "Fix should be idempotent");
1454
1455 let warnings_after_fix = lint(&fixed_once);
1457 assert_eq!(warnings_after_fix.len(), 0, "No warnings should remain after fix");
1458 }
1459
1460 #[test]
1461 fn test_fix_with_normalized_line_endings() {
1462 let content = "Text\n- Item 1\n- Item 2\nText";
1466 let warnings = lint(content);
1467 assert_eq!(warnings.len(), 1, "Should detect missing blank before list");
1468
1469 check_warnings_have_fixes(content);
1471
1472 let fixed_content = fix(content);
1473 let expected = "Text\n\n- Item 1\n- Item 2\nText";
1475 assert_eq!(fixed_content, expected, "Fix should work with normalized LF content");
1476 }
1477
1478 #[test]
1479 fn test_fix_preserves_final_newline() {
1480 let content_with_newline = "Text\n- Item 1\n- Item 2\nText\n";
1483 let fixed_with_newline = fix(content_with_newline);
1484 assert!(
1485 fixed_with_newline.ends_with('\n'),
1486 "Fix should preserve final newline when present"
1487 );
1488 assert_eq!(fixed_with_newline, "Text\n\n- Item 1\n- Item 2\nText\n");
1490
1491 let content_without_newline = "Text\n- Item 1\n- Item 2\nText";
1493 let fixed_without_newline = fix(content_without_newline);
1494 assert!(
1495 !fixed_without_newline.ends_with('\n'),
1496 "Fix should not add final newline when not present"
1497 );
1498 assert_eq!(fixed_without_newline, "Text\n\n- Item 1\n- Item 2\nText");
1500 }
1501
1502 #[test]
1503 fn test_fix_multiline_list_items_no_indent() {
1504 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";
1505
1506 let warnings = lint(content);
1507 assert_eq!(
1509 warnings.len(),
1510 0,
1511 "Should not warn for properly formatted list with multi-line items. Got: {warnings:?}"
1512 );
1513
1514 let fixed_content = fix(content);
1515 assert_eq!(
1517 fixed_content, content,
1518 "Should not modify correctly formatted multi-line list items"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_nested_list_with_lazy_continuation() {
1524 let content = r#"# Test
1530
1531- **Token Dispatch (Phase 3.2)**: COMPLETE. Extracts tokens from both:
1532 1. Switch/case dispatcher statements (original Phase 3.2)
1533 2. Inline conditionals - if/else, bitwise checks (`&`, `|`), comparison (`==`,
1534`!=`), ternary operators (`?:`), macros (`ISTOK`, `ISUNSET`), compound conditions (`&&`, `||`) (Phase 3.2.1)
1535 - 30 explicit tokens extracted, 23 dispatcher rules with embedded token
1536 references"#;
1537
1538 let warnings = lint(content);
1539 let md032_warnings: Vec<_> = warnings
1542 .iter()
1543 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1544 .collect();
1545 assert_eq!(
1546 md032_warnings.len(),
1547 0,
1548 "Should not warn for nested list with lazy continuation. Got: {md032_warnings:?}"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_pipes_in_code_spans_not_detected_as_table() {
1554 let content = r#"# Test
1556
1557- Item with `a | b` inline code
1558 - Nested item should work
1559
1560"#;
1561
1562 let warnings = lint(content);
1563 let md032_warnings: Vec<_> = warnings
1564 .iter()
1565 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1566 .collect();
1567 assert_eq!(
1568 md032_warnings.len(),
1569 0,
1570 "Pipes in code spans should not break lists. Got: {md032_warnings:?}"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_multiple_code_spans_with_pipes() {
1576 let content = r#"# Test
1578
1579- Item with `a | b` and `c || d` operators
1580 - Nested item should work
1581
1582"#;
1583
1584 let warnings = lint(content);
1585 let md032_warnings: Vec<_> = warnings
1586 .iter()
1587 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1588 .collect();
1589 assert_eq!(
1590 md032_warnings.len(),
1591 0,
1592 "Multiple code spans with pipes should not break lists. Got: {md032_warnings:?}"
1593 );
1594 }
1595
1596 #[test]
1597 fn test_actual_table_breaks_list() {
1598 let content = r#"# Test
1600
1601- Item before table
1602
1603| Col1 | Col2 |
1604|------|------|
1605| A | B |
1606
1607- Item after table
1608
1609"#;
1610
1611 let warnings = lint(content);
1612 let md032_warnings: Vec<_> = warnings
1614 .iter()
1615 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1616 .collect();
1617 assert_eq!(
1618 md032_warnings.len(),
1619 0,
1620 "Both lists should be properly separated by blank lines. Got: {md032_warnings:?}"
1621 );
1622 }
1623
1624 #[test]
1625 fn test_thematic_break_not_lazy_continuation() {
1626 let content = r#"- Item 1
1629- Item 2
1630***
1631
1632More text.
1633"#;
1634
1635 let warnings = lint(content);
1636 let md032_warnings: Vec<_> = warnings
1637 .iter()
1638 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1639 .collect();
1640 assert_eq!(
1641 md032_warnings.len(),
1642 1,
1643 "Should warn for list not followed by blank line before thematic break. Got: {md032_warnings:?}"
1644 );
1645 assert!(
1646 md032_warnings[0].message.contains("followed by blank line"),
1647 "Warning should be about missing blank after list"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_thematic_break_with_blank_line() {
1653 let content = r#"- Item 1
1655- Item 2
1656
1657***
1658
1659More text.
1660"#;
1661
1662 let warnings = lint(content);
1663 let md032_warnings: Vec<_> = warnings
1664 .iter()
1665 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1666 .collect();
1667 assert_eq!(
1668 md032_warnings.len(),
1669 0,
1670 "Should not warn when list is properly followed by blank line. Got: {md032_warnings:?}"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_various_thematic_break_styles() {
1676 for hr in ["---", "***", "___"] {
1681 let content = format!(
1682 r#"- Item 1
1683- Item 2
1684{hr}
1685
1686More text.
1687"#
1688 );
1689
1690 let warnings = lint(&content);
1691 let md032_warnings: Vec<_> = warnings
1692 .iter()
1693 .filter(|w| w.rule_name.as_deref() == Some("MD032"))
1694 .collect();
1695 assert_eq!(
1696 md032_warnings.len(),
1697 1,
1698 "Should warn for HR style '{hr}' without blank line. Got: {md032_warnings:?}"
1699 );
1700 }
1701 }
1702
1703 fn lint_with_config(content: &str, config: MD032Config) -> Vec<LintWarning> {
1706 let rule = MD032BlanksAroundLists::from_config_struct(config);
1707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 rule.check(&ctx).expect("Lint check failed")
1709 }
1710
1711 fn fix_with_config(content: &str, config: MD032Config) -> String {
1712 let rule = MD032BlanksAroundLists::from_config_struct(config);
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 rule.fix(&ctx).expect("Lint fix failed")
1715 }
1716
1717 #[test]
1718 fn test_lazy_continuation_allowed_by_default() {
1719 let content = "# Heading\n\n1. List\nSome text.";
1721 let warnings = lint(content);
1722 assert_eq!(
1723 warnings.len(),
1724 0,
1725 "Default behavior should allow lazy continuation. Got: {warnings:?}"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_lazy_continuation_disallowed() {
1731 let content = "# Heading\n\n1. List\nSome text.";
1733 let config = MD032Config {
1734 allow_lazy_continuation: false,
1735 };
1736 let warnings = lint_with_config(content, config);
1737 assert_eq!(
1738 warnings.len(),
1739 1,
1740 "Should warn when lazy continuation is disallowed. Got: {warnings:?}"
1741 );
1742 assert!(
1743 warnings[0].message.contains("followed by blank line"),
1744 "Warning message should mention blank line"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_lazy_continuation_fix() {
1750 let content = "# Heading\n\n1. List\nSome text.";
1752 let config = MD032Config {
1753 allow_lazy_continuation: false,
1754 };
1755 let fixed = fix_with_config(content, config.clone());
1756 assert_eq!(
1757 fixed, "# Heading\n\n1. List\n\nSome text.",
1758 "Fix should insert blank line before lazy continuation"
1759 );
1760
1761 let warnings_after = lint_with_config(&fixed, config);
1763 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1764 }
1765
1766 #[test]
1767 fn test_lazy_continuation_multiple_lines() {
1768 let content = "- Item 1\nLine 2\nLine 3";
1770 let config = MD032Config {
1771 allow_lazy_continuation: false,
1772 };
1773 let warnings = lint_with_config(content, config.clone());
1774 assert_eq!(
1775 warnings.len(),
1776 1,
1777 "Should warn for lazy continuation. Got: {warnings:?}"
1778 );
1779
1780 let fixed = fix_with_config(content, config.clone());
1781 assert_eq!(
1782 fixed, "- Item 1\n\nLine 2\nLine 3",
1783 "Fix should insert blank line after list"
1784 );
1785
1786 let warnings_after = lint_with_config(&fixed, config);
1788 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1789 }
1790
1791 #[test]
1792 fn test_lazy_continuation_with_indented_content() {
1793 let content = "- Item 1\n Indented content\nLazy text";
1795 let config = MD032Config {
1796 allow_lazy_continuation: false,
1797 };
1798 let warnings = lint_with_config(content, config);
1799 assert_eq!(
1800 warnings.len(),
1801 1,
1802 "Should warn for lazy text after indented content. Got: {warnings:?}"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_lazy_continuation_properly_separated() {
1808 let content = "- Item 1\n\nSome text.";
1810 let config = MD032Config {
1811 allow_lazy_continuation: false,
1812 };
1813 let warnings = lint_with_config(content, config);
1814 assert_eq!(
1815 warnings.len(),
1816 0,
1817 "Should not warn when list is properly followed by blank line. Got: {warnings:?}"
1818 );
1819 }
1820
1821 #[test]
1824 fn test_lazy_continuation_ordered_list_parenthesis_marker() {
1825 let content = "1) First item\nLazy continuation";
1827 let config = MD032Config {
1828 allow_lazy_continuation: false,
1829 };
1830 let warnings = lint_with_config(content, config.clone());
1831 assert_eq!(
1832 warnings.len(),
1833 1,
1834 "Should warn for lazy continuation with parenthesis marker"
1835 );
1836
1837 let fixed = fix_with_config(content, config);
1838 assert_eq!(fixed, "1) First item\n\nLazy continuation");
1839 }
1840
1841 #[test]
1842 fn test_lazy_continuation_followed_by_another_list() {
1843 let content = "- Item 1\nSome text\n- Item 2";
1849 let config = MD032Config {
1850 allow_lazy_continuation: false,
1851 };
1852 let warnings = lint_with_config(content, config);
1853 assert_eq!(
1855 warnings.len(),
1856 1,
1857 "Should warn about lazy continuation within list. Got: {warnings:?}"
1858 );
1859 assert!(
1860 warnings[0].message.contains("Lazy continuation"),
1861 "Warning should be about lazy continuation"
1862 );
1863 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
1864 }
1865
1866 #[test]
1867 fn test_lazy_continuation_multiple_in_document() {
1868 let content = "- Item 1\nLazy 1\n\n- Item 2\nLazy 2";
1873 let config = MD032Config {
1874 allow_lazy_continuation: false,
1875 };
1876 let warnings = lint_with_config(content, config.clone());
1877 assert_eq!(
1881 warnings.len(),
1882 2,
1883 "Should warn for both lazy continuations and list end. Got: {warnings:?}"
1884 );
1885
1886 let fixed = fix_with_config(content, config.clone());
1887 let warnings_after = lint_with_config(&fixed, config);
1888 assert_eq!(
1891 warnings_after.len(),
1892 1,
1893 "Within-list lazy continuation warning should remain (no auto-fix)"
1894 );
1895 }
1896
1897 #[test]
1898 fn test_lazy_continuation_end_of_document_no_newline() {
1899 let content = "- Item\nNo trailing newline";
1901 let config = MD032Config {
1902 allow_lazy_continuation: false,
1903 };
1904 let warnings = lint_with_config(content, config.clone());
1905 assert_eq!(warnings.len(), 1, "Should warn even at end of document");
1906
1907 let fixed = fix_with_config(content, config);
1908 assert_eq!(fixed, "- Item\n\nNo trailing newline");
1909 }
1910
1911 #[test]
1912 fn test_lazy_continuation_thematic_break_still_needs_blank() {
1913 let content = "- Item 1\n---";
1916 let config = MD032Config {
1917 allow_lazy_continuation: false,
1918 };
1919 let warnings = lint_with_config(content, config.clone());
1920 assert_eq!(
1922 warnings.len(),
1923 1,
1924 "List should need blank line before thematic break. Got: {warnings:?}"
1925 );
1926
1927 let fixed = fix_with_config(content, config);
1929 assert_eq!(fixed, "- Item 1\n\n---");
1930 }
1931
1932 #[test]
1933 fn test_lazy_continuation_heading_not_flagged() {
1934 let content = "- Item 1\n# Heading";
1937 let config = MD032Config {
1938 allow_lazy_continuation: false,
1939 };
1940 let warnings = lint_with_config(content, config);
1941 assert!(
1944 warnings.iter().all(|w| !w.message.contains("lazy")),
1945 "Heading should not trigger lazy continuation warning"
1946 );
1947 }
1948
1949 #[test]
1950 fn test_lazy_continuation_mixed_list_types() {
1951 let content = "- Unordered\n1. Ordered\nLazy text";
1953 let config = MD032Config {
1954 allow_lazy_continuation: false,
1955 };
1956 let warnings = lint_with_config(content, config.clone());
1957 assert!(!warnings.is_empty(), "Should warn about structure issues");
1958 }
1959
1960 #[test]
1961 fn test_lazy_continuation_deep_nesting() {
1962 let content = "- Level 1\n - Level 2\n - Level 3\nLazy at root";
1964 let config = MD032Config {
1965 allow_lazy_continuation: false,
1966 };
1967 let warnings = lint_with_config(content, config.clone());
1968 assert!(
1969 !warnings.is_empty(),
1970 "Should warn about lazy continuation after nested list"
1971 );
1972
1973 let fixed = fix_with_config(content, config.clone());
1974 let warnings_after = lint_with_config(&fixed, config);
1975 assert_eq!(warnings_after.len(), 0, "No warnings should remain after fix");
1976 }
1977
1978 #[test]
1979 fn test_lazy_continuation_with_emphasis_in_text() {
1980 let content = "- Item\n*emphasized* continuation";
1982 let config = MD032Config {
1983 allow_lazy_continuation: false,
1984 };
1985 let warnings = lint_with_config(content, config.clone());
1986 assert_eq!(warnings.len(), 1, "Should warn even with emphasis in continuation");
1987
1988 let fixed = fix_with_config(content, config);
1989 assert_eq!(fixed, "- Item\n\n*emphasized* continuation");
1990 }
1991
1992 #[test]
1993 fn test_lazy_continuation_with_code_span() {
1994 let content = "- Item\n`code` continuation";
1996 let config = MD032Config {
1997 allow_lazy_continuation: false,
1998 };
1999 let warnings = lint_with_config(content, config.clone());
2000 assert_eq!(warnings.len(), 1, "Should warn even with code span in continuation");
2001
2002 let fixed = fix_with_config(content, config);
2003 assert_eq!(fixed, "- Item\n\n`code` continuation");
2004 }
2005
2006 #[test]
2013 fn test_issue295_case1_nested_bullets_then_continuation_then_item() {
2014 let content = r#"1. Create a new Chat conversation:
2017 - On the sidebar, select **New Chat**.
2018 - In the box, type `/new`.
2019 A new Chat conversation replaces the previous one.
20201. Under the Chat text box, turn off the toggle."#;
2021 let config = MD032Config {
2022 allow_lazy_continuation: false,
2023 };
2024 let warnings = lint_with_config(content, config);
2025 let lazy_warnings: Vec<_> = warnings
2027 .iter()
2028 .filter(|w| w.message.contains("Lazy continuation"))
2029 .collect();
2030 assert!(
2031 !lazy_warnings.is_empty(),
2032 "Should detect lazy continuation after nested bullets. Got: {warnings:?}"
2033 );
2034 assert!(
2035 lazy_warnings.iter().any(|w| w.line == 4),
2036 "Should warn on line 4. Got: {lazy_warnings:?}"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_issue295_case3_code_span_starts_lazy_continuation() {
2042 let content = r#"- `field`: Is the specific key:
2045 - `password`: Accesses the password.
2046 - `api_key`: Accesses the api_key.
2047 `token`: Specifies which ID token to use.
2048- `version_id`: Is the unique identifier."#;
2049 let config = MD032Config {
2050 allow_lazy_continuation: false,
2051 };
2052 let warnings = lint_with_config(content, config);
2053 let lazy_warnings: Vec<_> = warnings
2055 .iter()
2056 .filter(|w| w.message.contains("Lazy continuation"))
2057 .collect();
2058 assert!(
2059 !lazy_warnings.is_empty(),
2060 "Should detect lazy continuation starting with code span. Got: {warnings:?}"
2061 );
2062 assert!(
2063 lazy_warnings.iter().any(|w| w.line == 4),
2064 "Should warn on line 4 (code span start). Got: {lazy_warnings:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_issue295_case4_deep_nesting_with_continuation_then_item() {
2070 let content = r#"- Check out the branch, and test locally.
2072 - If the MR requires significant modifications:
2073 - **Skip local testing** and review instead.
2074 - **Request verification** from the author.
2075 - **Identify the minimal change** needed.
2076 Your testing might result in opportunities.
2077- If you don't understand, _say so_."#;
2078 let config = MD032Config {
2079 allow_lazy_continuation: false,
2080 };
2081 let warnings = lint_with_config(content, config);
2082 let lazy_warnings: Vec<_> = warnings
2084 .iter()
2085 .filter(|w| w.message.contains("Lazy continuation"))
2086 .collect();
2087 assert!(
2088 !lazy_warnings.is_empty(),
2089 "Should detect lazy continuation after deep nesting. Got: {warnings:?}"
2090 );
2091 assert!(
2092 lazy_warnings.iter().any(|w| w.line == 6),
2093 "Should warn on line 6. Got: {lazy_warnings:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_issue295_ordered_list_nested_bullets_continuation() {
2099 let content = r#"# Test
2102
21031. First item.
2104 - Nested A.
2105 - Nested B.
2106 Continuation at outer level.
21071. Second item."#;
2108 let config = MD032Config {
2109 allow_lazy_continuation: false,
2110 };
2111 let warnings = lint_with_config(content, config);
2112 let lazy_warnings: Vec<_> = warnings
2114 .iter()
2115 .filter(|w| w.message.contains("Lazy continuation"))
2116 .collect();
2117 assert!(
2118 !lazy_warnings.is_empty(),
2119 "Should detect lazy continuation at outer level after nested. Got: {warnings:?}"
2120 );
2121 assert!(
2123 lazy_warnings.iter().any(|w| w.line == 6),
2124 "Should warn on line 6. Got: {lazy_warnings:?}"
2125 );
2126 }
2127
2128 #[test]
2129 fn test_issue295_multiple_lazy_lines_after_nested() {
2130 let content = r#"1. The device client receives a response.
2132 - Those defined by OAuth Framework.
2133 - Those specific to device authorization.
2134 Those error responses are described below.
2135 For more information on each response,
2136 see the documentation.
21371. Next step in the process."#;
2138 let config = MD032Config {
2139 allow_lazy_continuation: false,
2140 };
2141 let warnings = lint_with_config(content, config);
2142 let lazy_warnings: Vec<_> = warnings
2144 .iter()
2145 .filter(|w| w.message.contains("Lazy continuation"))
2146 .collect();
2147 assert!(
2148 lazy_warnings.len() >= 3,
2149 "Should detect multiple lazy continuation lines. Got {} warnings: {lazy_warnings:?}",
2150 lazy_warnings.len()
2151 );
2152 }
2153
2154 #[test]
2155 fn test_issue295_properly_indented_not_lazy() {
2156 let content = r#"1. First item.
2158 - Nested A.
2159 - Nested B.
2160
2161 Properly indented continuation.
21621. Second item."#;
2163 let config = MD032Config {
2164 allow_lazy_continuation: false,
2165 };
2166 let warnings = lint_with_config(content, config);
2167 let lazy_warnings: Vec<_> = warnings
2169 .iter()
2170 .filter(|w| w.message.contains("Lazy continuation"))
2171 .collect();
2172 assert_eq!(
2173 lazy_warnings.len(),
2174 0,
2175 "Should NOT warn when blank line separates continuation. Got: {lazy_warnings:?}"
2176 );
2177 }
2178
2179 #[test]
2186 fn test_html_comment_before_list_with_preceding_blank() {
2187 let content = "Some text.\n\n<!-- comment -->\n- List item";
2190 let warnings = lint(content);
2191 assert_eq!(
2192 warnings.len(),
2193 0,
2194 "Should not warn when blank line exists before HTML comment. Got: {warnings:?}"
2195 );
2196 }
2197
2198 #[test]
2199 fn test_html_comment_after_list_with_following_blank() {
2200 let content = "- List item\n<!-- comment -->\n\nSome text.";
2202 let warnings = lint(content);
2203 assert_eq!(
2204 warnings.len(),
2205 0,
2206 "Should not warn when blank line exists after HTML comment. Got: {warnings:?}"
2207 );
2208 }
2209
2210 #[test]
2211 fn test_list_inside_html_comment_ignored() {
2212 let content = "<!--\n1. First\n2. Second\n3. Third\n-->";
2214 let warnings = lint(content);
2215 assert_eq!(
2216 warnings.len(),
2217 0,
2218 "Should not analyze lists inside HTML comments. Got: {warnings:?}"
2219 );
2220 }
2221
2222 #[test]
2223 fn test_multiline_html_comment_before_list() {
2224 let content = "Text\n\n<!--\nThis is a\nmulti-line\ncomment\n-->\n- Item";
2226 let warnings = lint(content);
2227 assert_eq!(
2228 warnings.len(),
2229 0,
2230 "Multi-line HTML comment should be transparent. Got: {warnings:?}"
2231 );
2232 }
2233
2234 #[test]
2235 fn test_no_blank_before_html_comment_still_warns() {
2236 let content = "Some text.\n<!-- comment -->\n- List item";
2238 let warnings = lint(content);
2239 assert_eq!(
2240 warnings.len(),
2241 1,
2242 "Should warn when no blank line exists (even with HTML comment). Got: {warnings:?}"
2243 );
2244 assert!(
2245 warnings[0].message.contains("preceded by blank line"),
2246 "Should be 'preceded by blank line' warning"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_no_blank_after_html_comment_no_warn_lazy_continuation() {
2252 let content = "- List item\n<!-- comment -->\nSome text.";
2255 let warnings = lint(content);
2256 assert_eq!(
2257 warnings.len(),
2258 0,
2259 "Should not warn - text after comment becomes lazy continuation. Got: {warnings:?}"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_list_followed_by_heading_through_comment_should_warn() {
2265 let content = "- List item\n<!-- comment -->\n# Heading";
2267 let warnings = lint(content);
2268 assert!(
2271 warnings.len() <= 1,
2272 "Should handle heading after comment gracefully. Got: {warnings:?}"
2273 );
2274 }
2275
2276 #[test]
2277 fn test_html_comment_between_list_and_text_both_directions() {
2278 let content = "Text before.\n\n<!-- comment -->\n- Item 1\n- Item 2\n<!-- another -->\n\nText after.";
2280 let warnings = lint(content);
2281 assert_eq!(
2282 warnings.len(),
2283 0,
2284 "Should not warn with proper separation through comments. Got: {warnings:?}"
2285 );
2286 }
2287
2288 #[test]
2289 fn test_html_comment_fix_does_not_insert_unnecessary_blank() {
2290 let content = "Text.\n\n<!-- comment -->\n- Item";
2292 let fixed = fix(content);
2293 assert_eq!(fixed, content, "Fix should not modify already-correct content");
2294 }
2295
2296 #[test]
2297 fn test_html_comment_fix_adds_blank_when_needed() {
2298 let content = "Text.\n<!-- comment -->\n- Item";
2301 let fixed = fix(content);
2302 assert!(
2303 fixed.contains("<!-- comment -->\n\n- Item"),
2304 "Fix should add blank line before list. Got: {fixed}"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_ordered_list_inside_html_comment() {
2310 let content = "<!--\n3. Starting at 3\n4. Next item\n-->";
2312 let warnings = lint(content);
2313 assert_eq!(
2314 warnings.len(),
2315 0,
2316 "Should not warn about ordered list inside HTML comment. Got: {warnings:?}"
2317 );
2318 }
2319
2320 #[test]
2327 fn test_blockquote_list_exit_no_warning() {
2328 let content = "- outer item\n > - blockquote list 1\n > - blockquote list 2\n- next outer item";
2330 let warnings = lint(content);
2331 assert_eq!(
2332 warnings.len(),
2333 0,
2334 "Should not warn when exiting blockquote. Got: {warnings:?}"
2335 );
2336 }
2337
2338 #[test]
2339 fn test_nested_blockquote_list_exit() {
2340 let content = "- outer\n - nested\n > - bq list 1\n > - bq list 2\n - back to nested\n- outer again";
2342 let warnings = lint(content);
2343 assert_eq!(
2344 warnings.len(),
2345 0,
2346 "Should not warn when exiting nested blockquote list. Got: {warnings:?}"
2347 );
2348 }
2349
2350 #[test]
2351 fn test_blockquote_same_level_no_warning() {
2352 let content = "> - item 1\n> - item 2\n> Text after";
2355 let warnings = lint(content);
2356 assert_eq!(
2357 warnings.len(),
2358 0,
2359 "Should not warn - text is lazy continuation in blockquote. Got: {warnings:?}"
2360 );
2361 }
2362
2363 #[test]
2364 fn test_blockquote_list_with_special_chars() {
2365 let content = "- Item with <>&\n > - blockquote item\n- Back to outer";
2367 let warnings = lint(content);
2368 assert_eq!(
2369 warnings.len(),
2370 0,
2371 "Special chars in content should not affect blockquote detection. Got: {warnings:?}"
2372 );
2373 }
2374
2375 #[test]
2376 fn test_lazy_continuation_whitespace_only_line() {
2377 let content = "- Item\n \nText after whitespace-only line";
2380 let config = MD032Config {
2381 allow_lazy_continuation: false,
2382 };
2383 let warnings = lint_with_config(content, config.clone());
2384 assert_eq!(
2386 warnings.len(),
2387 1,
2388 "Whitespace-only line should NOT count as separator. Got: {warnings:?}"
2389 );
2390
2391 let fixed = fix_with_config(content, config);
2393 assert!(fixed.contains("\n\nText"), "Fix should add blank line separator");
2394 }
2395
2396 #[test]
2397 fn test_lazy_continuation_blockquote_context() {
2398 let content = "> - Item\n> Lazy in quote";
2400 let config = MD032Config {
2401 allow_lazy_continuation: false,
2402 };
2403 let warnings = lint_with_config(content, config);
2404 assert!(warnings.len() <= 1, "Should handle blockquote context gracefully");
2407 }
2408
2409 #[test]
2410 fn test_lazy_continuation_fix_preserves_content() {
2411 let content = "- Item with special chars: <>&\nContinuation with: \"quotes\"";
2413 let config = MD032Config {
2414 allow_lazy_continuation: false,
2415 };
2416 let fixed = fix_with_config(content, config);
2417 assert!(fixed.contains("<>&"), "Should preserve special chars");
2418 assert!(fixed.contains("\"quotes\""), "Should preserve quotes");
2419 assert_eq!(fixed, "- Item with special chars: <>&\n\nContinuation with: \"quotes\"");
2420 }
2421
2422 #[test]
2423 fn test_lazy_continuation_fix_idempotent() {
2424 let content = "- Item\nLazy";
2426 let config = MD032Config {
2427 allow_lazy_continuation: false,
2428 };
2429 let fixed_once = fix_with_config(content, config.clone());
2430 let fixed_twice = fix_with_config(&fixed_once, config);
2431 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2432 }
2433
2434 #[test]
2435 fn test_lazy_continuation_config_default_allows() {
2436 let content = "- Item\nLazy text that continues";
2438 let default_config = MD032Config::default();
2439 assert!(
2440 default_config.allow_lazy_continuation,
2441 "Default should allow lazy continuation"
2442 );
2443 let warnings = lint_with_config(content, default_config);
2444 assert_eq!(warnings.len(), 0, "Default config should not warn on lazy continuation");
2445 }
2446
2447 #[test]
2448 fn test_lazy_continuation_after_multi_line_item() {
2449 let content = "- Item line 1\n Item line 2 (indented)\nLazy (not indented)";
2451 let config = MD032Config {
2452 allow_lazy_continuation: false,
2453 };
2454 let warnings = lint_with_config(content, config.clone());
2455 assert_eq!(
2456 warnings.len(),
2457 1,
2458 "Should warn only for the lazy line, not the indented line"
2459 );
2460 }
2461
2462 #[test]
2464 fn test_blockquote_list_with_continuation_and_nested() {
2465 let content = "> - item 1\n> continuation\n> - nested\n> - item 2";
2468 let warnings = lint(content);
2469 assert_eq!(
2470 warnings.len(),
2471 0,
2472 "Blockquoted list with continuation and nested items should have no warnings. Got: {warnings:?}"
2473 );
2474 }
2475
2476 #[test]
2477 fn test_blockquote_list_simple() {
2478 let content = "> - item 1\n> - item 2";
2480 let warnings = lint(content);
2481 assert_eq!(warnings.len(), 0, "Simple blockquoted list should have no warnings");
2482 }
2483
2484 #[test]
2485 fn test_blockquote_list_with_continuation_only() {
2486 let content = "> - item 1\n> continuation\n> - item 2";
2488 let warnings = lint(content);
2489 assert_eq!(
2490 warnings.len(),
2491 0,
2492 "Blockquoted list with continuation should have no warnings"
2493 );
2494 }
2495
2496 #[test]
2497 fn test_blockquote_list_with_lazy_continuation() {
2498 let content = "> - item 1\n> lazy continuation\n> - item 2";
2500 let warnings = lint(content);
2501 assert_eq!(
2502 warnings.len(),
2503 0,
2504 "Blockquoted list with lazy continuation should have no warnings"
2505 );
2506 }
2507
2508 #[test]
2509 fn test_nested_blockquote_list() {
2510 let content = ">> - item 1\n>> continuation\n>> - nested\n>> - item 2";
2512 let warnings = lint(content);
2513 assert_eq!(warnings.len(), 0, "Nested blockquote list should have no warnings");
2514 }
2515
2516 #[test]
2517 fn test_blockquote_list_needs_preceding_blank() {
2518 let content = "> Text before\n> - item 1\n> - item 2";
2520 let warnings = lint(content);
2521 assert_eq!(
2522 warnings.len(),
2523 1,
2524 "Should warn for missing blank before blockquoted list"
2525 );
2526 }
2527
2528 #[test]
2529 fn test_blockquote_list_properly_separated() {
2530 let content = "> Text before\n>\n> - item 1\n> - item 2\n>\n> Text after";
2532 let warnings = lint(content);
2533 assert_eq!(
2534 warnings.len(),
2535 0,
2536 "Properly separated blockquoted list should have no warnings"
2537 );
2538 }
2539
2540 #[test]
2541 fn test_blockquote_ordered_list() {
2542 let content = "> 1. item 1\n> continuation\n> 2. item 2";
2544 let warnings = lint(content);
2545 assert_eq!(warnings.len(), 0, "Ordered list in blockquote should have no warnings");
2546 }
2547
2548 #[test]
2549 fn test_blockquote_list_with_empty_blockquote_line() {
2550 let content = "> - item 1\n>\n> - item 2";
2552 let warnings = lint(content);
2553 assert_eq!(warnings.len(), 0, "Empty blockquote line should not break list");
2554 }
2555
2556 #[test]
2558 fn test_blockquote_list_multi_paragraph_items() {
2559 let content = "# Test\n\n> Some intro text\n> \n> * List item 1\n> \n> Continuation\n> * List item 2\n";
2562 let warnings = lint(content);
2563 assert_eq!(
2564 warnings.len(),
2565 0,
2566 "Multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2567 );
2568 }
2569
2570 #[test]
2572 fn test_blockquote_ordered_list_multi_paragraph_items() {
2573 let content = "> 1. First item\n> \n> Continuation of first\n> 2. Second item\n";
2574 let warnings = lint(content);
2575 assert_eq!(
2576 warnings.len(),
2577 0,
2578 "Ordered multi-paragraph list items in blockquotes should have no warnings. Got: {warnings:?}"
2579 );
2580 }
2581
2582 #[test]
2584 fn test_blockquote_list_multiple_continuations() {
2585 let content = "> - Item 1\n> \n> First continuation\n> \n> Second continuation\n> - Item 2\n";
2586 let warnings = lint(content);
2587 assert_eq!(
2588 warnings.len(),
2589 0,
2590 "Multiple continuation paragraphs should not break blockquote list. Got: {warnings:?}"
2591 );
2592 }
2593
2594 #[test]
2596 fn test_nested_blockquote_multi_paragraph_list() {
2597 let content = ">> - Item 1\n>> \n>> Continuation\n>> - Item 2\n";
2598 let warnings = lint(content);
2599 assert_eq!(
2600 warnings.len(),
2601 0,
2602 "Nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2603 );
2604 }
2605
2606 #[test]
2608 fn test_triple_nested_blockquote_multi_paragraph_list() {
2609 let content = ">>> - Item 1\n>>> \n>>> Continuation\n>>> - Item 2\n";
2610 let warnings = lint(content);
2611 assert_eq!(
2612 warnings.len(),
2613 0,
2614 "Triple-nested blockquote multi-paragraph list should have no warnings. Got: {warnings:?}"
2615 );
2616 }
2617
2618 #[test]
2620 fn test_blockquote_list_last_item_continuation() {
2621 let content = "> - Item 1\n> - Item 2\n> \n> Continuation of item 2\n";
2622 let warnings = lint(content);
2623 assert_eq!(
2624 warnings.len(),
2625 0,
2626 "Last item with continuation should have no warnings. Got: {warnings:?}"
2627 );
2628 }
2629
2630 #[test]
2632 fn test_blockquote_list_first_item_only_continuation() {
2633 let content = "> - Item 1\n> \n> Continuation of item 1\n";
2634 let warnings = lint(content);
2635 assert_eq!(
2636 warnings.len(),
2637 0,
2638 "Single item with continuation should have no warnings. Got: {warnings:?}"
2639 );
2640 }
2641
2642 #[test]
2646 fn test_blockquote_level_change_breaks_list() {
2647 let content = "> - Item in single blockquote\n>> - Item in nested blockquote\n";
2649 let warnings = lint(content);
2650 assert!(
2654 warnings.len() <= 2,
2655 "Blockquote level change warnings should be reasonable. Got: {warnings:?}"
2656 );
2657 }
2658
2659 #[test]
2661 fn test_exit_blockquote_needs_blank_before_list() {
2662 let content = "> Blockquote text\n\n- List outside blockquote\n";
2664 let warnings = lint(content);
2665 assert_eq!(
2666 warnings.len(),
2667 0,
2668 "List after blank line outside blockquote should be fine. Got: {warnings:?}"
2669 );
2670
2671 let content2 = "> Blockquote text\n- List outside blockquote\n";
2675 let warnings2 = lint(content2);
2676 assert!(
2678 warnings2.len() <= 1,
2679 "List after blockquote warnings should be reasonable. Got: {warnings2:?}"
2680 );
2681 }
2682
2683 #[test]
2685 fn test_blockquote_multi_paragraph_all_unordered_markers() {
2686 let content_dash = "> - Item 1\n> \n> Continuation\n> - Item 2\n";
2688 let warnings = lint(content_dash);
2689 assert_eq!(warnings.len(), 0, "Dash marker should work. Got: {warnings:?}");
2690
2691 let content_asterisk = "> * Item 1\n> \n> Continuation\n> * Item 2\n";
2693 let warnings = lint(content_asterisk);
2694 assert_eq!(warnings.len(), 0, "Asterisk marker should work. Got: {warnings:?}");
2695
2696 let content_plus = "> + Item 1\n> \n> Continuation\n> + Item 2\n";
2698 let warnings = lint(content_plus);
2699 assert_eq!(warnings.len(), 0, "Plus marker should work. Got: {warnings:?}");
2700 }
2701
2702 #[test]
2704 fn test_blockquote_multi_paragraph_parenthesis_marker() {
2705 let content = "> 1) Item 1\n> \n> Continuation\n> 2) Item 2\n";
2706 let warnings = lint(content);
2707 assert_eq!(
2708 warnings.len(),
2709 0,
2710 "Parenthesis ordered markers should work. Got: {warnings:?}"
2711 );
2712 }
2713
2714 #[test]
2716 fn test_blockquote_multi_paragraph_multi_digit_numbers() {
2717 let content = "> 10. Item 10\n> \n> Continuation of item 10\n> 11. Item 11\n";
2719 let warnings = lint(content);
2720 assert_eq!(
2721 warnings.len(),
2722 0,
2723 "Multi-digit ordered list should work. Got: {warnings:?}"
2724 );
2725 }
2726
2727 #[test]
2729 fn test_blockquote_multi_paragraph_with_formatting() {
2730 let content = "> - Item with **bold**\n> \n> Continuation with *emphasis* and `code`\n> - Item 2\n";
2731 let warnings = lint(content);
2732 assert_eq!(
2733 warnings.len(),
2734 0,
2735 "Continuation with inline formatting should work. Got: {warnings:?}"
2736 );
2737 }
2738
2739 #[test]
2741 fn test_blockquote_multi_paragraph_all_items_have_continuation() {
2742 let content = "> - Item 1\n> \n> Continuation 1\n> - Item 2\n> \n> Continuation 2\n> - Item 3\n> \n> Continuation 3\n";
2743 let warnings = lint(content);
2744 assert_eq!(
2745 warnings.len(),
2746 0,
2747 "All items with continuations should work. Got: {warnings:?}"
2748 );
2749 }
2750
2751 #[test]
2753 fn test_blockquote_multi_paragraph_lowercase_continuation() {
2754 let content = "> - Item 1\n> \n> and this continues the item\n> - Item 2\n";
2755 let warnings = lint(content);
2756 assert_eq!(
2757 warnings.len(),
2758 0,
2759 "Lowercase continuation should work. Got: {warnings:?}"
2760 );
2761 }
2762
2763 #[test]
2765 fn test_blockquote_multi_paragraph_uppercase_continuation() {
2766 let content = "> - Item 1\n> \n> This continues the item with uppercase\n> - Item 2\n";
2767 let warnings = lint(content);
2768 assert_eq!(
2769 warnings.len(),
2770 0,
2771 "Uppercase continuation with proper indent should work. Got: {warnings:?}"
2772 );
2773 }
2774
2775 #[test]
2777 fn test_blockquote_separate_ordered_unordered_multi_paragraph() {
2778 let content = "> - Unordered item\n> \n> Continuation\n> \n> 1. Ordered item\n> \n> Continuation\n";
2780 let warnings = lint(content);
2781 assert!(
2783 warnings.len() <= 1,
2784 "Separate lists with continuations should be reasonable. Got: {warnings:?}"
2785 );
2786 }
2787
2788 #[test]
2790 fn test_blockquote_multi_paragraph_bare_marker_blank() {
2791 let content = "> - Item 1\n>\n> Continuation\n> - Item 2\n";
2793 let warnings = lint(content);
2794 assert_eq!(warnings.len(), 0, "Bare > as blank line should work. Got: {warnings:?}");
2795 }
2796
2797 #[test]
2798 fn test_blockquote_list_varying_spaces_after_marker() {
2799 let content = "> - item 1\n> continuation with more indent\n> - item 2";
2801 let warnings = lint(content);
2802 assert_eq!(warnings.len(), 0, "Varying spaces after > should not break list");
2803 }
2804
2805 #[test]
2806 fn test_deeply_nested_blockquote_list() {
2807 let content = ">>> - item 1\n>>> continuation\n>>> - item 2";
2809 let warnings = lint(content);
2810 assert_eq!(
2811 warnings.len(),
2812 0,
2813 "Deeply nested blockquote list should have no warnings"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_blockquote_level_change_in_list() {
2819 let content = "> - item 1\n>> - deeper item\n> - item 2";
2821 let warnings = lint(content);
2824 assert!(
2825 !warnings.is_empty(),
2826 "Blockquote level change should break list and trigger warnings"
2827 );
2828 }
2829
2830 #[test]
2831 fn test_blockquote_list_with_code_span() {
2832 let content = "> - item with `code`\n> continuation\n> - item 2";
2834 let warnings = lint(content);
2835 assert_eq!(
2836 warnings.len(),
2837 0,
2838 "Blockquote list with code span should have no warnings"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_blockquote_list_at_document_end() {
2844 let content = "> Some text\n>\n> - item 1\n> - item 2";
2846 let warnings = lint(content);
2847 assert_eq!(
2848 warnings.len(),
2849 0,
2850 "Blockquote list at document end should have no warnings"
2851 );
2852 }
2853
2854 #[test]
2855 fn test_fix_preserves_blockquote_prefix_before_list() {
2856 let content = "> Text before
2858> - Item 1
2859> - Item 2";
2860 let fixed = fix(content);
2861
2862 let expected = "> Text before
2864>
2865> - Item 1
2866> - Item 2";
2867 assert_eq!(
2868 fixed, expected,
2869 "Fix should insert '>' blank line, not plain blank line"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_fix_preserves_triple_nested_blockquote_prefix_for_list() {
2875 let content = ">>> Triple nested
2878>>> - Item 1
2879>>> - Item 2
2880>>> More text";
2881 let fixed = fix(content);
2882
2883 let expected = ">>> Triple nested
2885>>>
2886>>> - Item 1
2887>>> - Item 2
2888>>> More text";
2889 assert_eq!(
2890 fixed, expected,
2891 "Fix should preserve triple-nested blockquote prefix '>>>'"
2892 );
2893 }
2894}