1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::element_cache::ElementCache;
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::range_utils::calculate_line_range;
10use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "kebab-case")]
16pub struct MD031Config {
17 #[serde(default = "default_list_items")]
19 pub list_items: bool,
20}
21
22impl Default for MD031Config {
23 fn default() -> Self {
24 Self {
25 list_items: default_list_items(),
26 }
27 }
28}
29
30fn default_list_items() -> bool {
31 true
32}
33
34impl RuleConfig for MD031Config {
35 const RULE_NAME: &'static str = "MD031";
36}
37
38#[derive(Clone, Default)]
40pub struct MD031BlanksAroundFences {
41 config: MD031Config,
42}
43
44impl MD031BlanksAroundFences {
45 pub fn new(list_items: bool) -> Self {
46 Self {
47 config: MD031Config { list_items },
48 }
49 }
50
51 pub fn from_config_struct(config: MD031Config) -> Self {
52 Self { config }
53 }
54
55 fn is_empty_line(line: &str) -> bool {
56 line.trim().is_empty()
57 }
58
59 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
61 for i in (0..=line_index).rev() {
63 let line = lines[i];
64 let trimmed = line.trim_start();
65
66 if trimmed.is_empty() {
68 return false;
69 }
70
71 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
73 let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
74 if let Some(next) = chars.next()
75 && (next == '.' || next == ')')
76 && chars.next() == Some(' ')
77 {
78 return true;
79 }
80 }
81
82 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
84 return true;
85 }
86
87 let is_indented = ElementCache::calculate_indentation_width_default(line) >= 3;
89 if is_indented {
90 continue; }
92
93 return false;
96 }
97
98 false
99 }
100
101 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
103 if self.config.list_items {
104 true
106 } else {
107 !self.is_in_list(line_index, lines)
109 }
110 }
111
112 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
114 line_index > 0
115 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
116 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
117 }
118
119 fn detect_fenced_code_blocks_pulldown(
124 content: &str,
125 line_offsets: &[usize],
126 lines: &[&str],
127 ) -> Vec<(usize, usize)> {
128 let mut fenced_blocks = Vec::new();
129 let options = Options::all();
130 let parser = Parser::new_ext(content, options).into_offset_iter();
131
132 let mut current_block_start: Option<usize> = None;
133
134 let byte_to_line = |byte_offset: usize| -> usize {
136 line_offsets
137 .iter()
138 .enumerate()
139 .rev()
140 .find(|&(_, &offset)| offset <= byte_offset)
141 .map(|(idx, _)| idx)
142 .unwrap_or(0)
143 };
144
145 for (event, range) in parser {
146 match event {
147 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_))) => {
148 let line_idx = byte_to_line(range.start);
149 current_block_start = Some(line_idx);
150 }
151 Event::End(TagEnd::CodeBlock) => {
152 if let Some(start_line) = current_block_start.take() {
153 let end_byte = if range.end > 0 { range.end - 1 } else { 0 };
157 let end_line = byte_to_line(end_byte);
158
159 let end_line_content = lines.get(end_line).unwrap_or(&"");
162 let trimmed = end_line_content.trim();
163 let is_closing_fence = (trimmed.starts_with("```") || trimmed.starts_with("~~~"))
164 && trimmed
165 .chars()
166 .skip_while(|&c| c == '`' || c == '~')
167 .all(|c| c.is_whitespace());
168
169 if is_closing_fence {
170 fenced_blocks.push((start_line, end_line));
171 } else {
172 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
175 }
176 }
177 }
178 _ => {}
179 }
180 }
181
182 if let Some(start_line) = current_block_start {
184 fenced_blocks.push((start_line, lines.len().saturating_sub(1)));
185 }
186
187 fenced_blocks
188 }
189}
190
191impl Rule for MD031BlanksAroundFences {
192 fn name(&self) -> &'static str {
193 "MD031"
194 }
195
196 fn description(&self) -> &'static str {
197 "Fenced code blocks should be surrounded by blank lines"
198 }
199
200 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
201 let content = ctx.content;
202 let line_index = &ctx.line_index;
203
204 let mut warnings = Vec::new();
205 let lines: Vec<&str> = content.lines().collect();
206 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
207
208 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
210
211 for (opening_line, closing_line) in &fenced_blocks {
213 if *opening_line > 0
216 && !Self::is_empty_line(lines[*opening_line - 1])
217 && !Self::is_right_after_frontmatter(*opening_line, ctx)
218 && self.should_require_blank_line(*opening_line, &lines)
219 {
220 let (start_line, start_col, end_line, end_col) =
221 calculate_line_range(*opening_line + 1, lines[*opening_line]);
222
223 warnings.push(LintWarning {
224 rule_name: Some(self.name().to_string()),
225 line: start_line,
226 column: start_col,
227 end_line,
228 end_column: end_col,
229 message: "No blank line before fenced code block".to_string(),
230 severity: Severity::Warning,
231 fix: Some(Fix {
232 range: line_index.line_col_to_byte_range_with_length(*opening_line + 1, 1, 0),
233 replacement: "\n".to_string(),
234 }),
235 });
236 }
237
238 if *closing_line + 1 < lines.len()
241 && !Self::is_empty_line(lines[*closing_line + 1])
242 && !is_kramdown_block_attribute(lines[*closing_line + 1])
243 && self.should_require_blank_line(*closing_line, &lines)
244 {
245 let (start_line, start_col, end_line, end_col) =
246 calculate_line_range(*closing_line + 1, lines[*closing_line]);
247
248 warnings.push(LintWarning {
249 rule_name: Some(self.name().to_string()),
250 line: start_line,
251 column: start_col,
252 end_line,
253 end_column: end_col,
254 message: "No blank line after fenced code block".to_string(),
255 severity: Severity::Warning,
256 fix: Some(Fix {
257 range: line_index.line_col_to_byte_range_with_length(
258 *closing_line + 1,
259 lines[*closing_line].len() + 1,
260 0,
261 ),
262 replacement: "\n".to_string(),
263 }),
264 });
265 }
266 }
267
268 if is_mkdocs {
270 let mut in_admonition = false;
271 let mut admonition_indent = 0;
272 let mut i = 0;
273
274 while i < lines.len() {
275 let line = lines[i];
276
277 let in_fenced_block = fenced_blocks.iter().any(|(start, end)| i >= *start && i <= *end);
279 if in_fenced_block {
280 i += 1;
281 continue;
282 }
283
284 if mkdocs_admonitions::is_admonition_start(line) {
286 if i > 0
288 && !Self::is_empty_line(lines[i - 1])
289 && !Self::is_right_after_frontmatter(i, ctx)
290 && self.should_require_blank_line(i, &lines)
291 {
292 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
293
294 warnings.push(LintWarning {
295 rule_name: Some(self.name().to_string()),
296 line: start_line,
297 column: start_col,
298 end_line,
299 end_column: end_col,
300 message: "No blank line before admonition block".to_string(),
301 severity: Severity::Warning,
302 fix: Some(Fix {
303 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
304 replacement: "\n".to_string(),
305 }),
306 });
307 }
308
309 in_admonition = true;
310 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
311 i += 1;
312 continue;
313 }
314
315 if in_admonition
317 && !line.trim().is_empty()
318 && !mkdocs_admonitions::is_admonition_content(line, admonition_indent)
319 {
320 in_admonition = false;
321
322 if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
324 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
325
326 warnings.push(LintWarning {
327 rule_name: Some(self.name().to_string()),
328 line: start_line,
329 column: start_col,
330 end_line,
331 end_column: end_col,
332 message: "No blank line after admonition block".to_string(),
333 severity: Severity::Warning,
334 fix: Some(Fix {
335 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
336 replacement: "\n".to_string(),
337 }),
338 });
339 }
340
341 admonition_indent = 0;
342 }
343
344 i += 1;
345 }
346 }
347
348 Ok(warnings)
349 }
350
351 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
352 let content = ctx.content;
353
354 let had_trailing_newline = content.ends_with('\n');
356
357 let lines: Vec<&str> = content.lines().collect();
358
359 let fenced_blocks = Self::detect_fenced_code_blocks_pulldown(content, &ctx.line_offsets, &lines);
361
362 let mut needs_blank_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
364 let mut needs_blank_after: std::collections::HashSet<usize> = std::collections::HashSet::new();
365
366 for (opening_line, closing_line) in &fenced_blocks {
367 if *opening_line > 0
369 && !Self::is_empty_line(lines[*opening_line - 1])
370 && !Self::is_right_after_frontmatter(*opening_line, ctx)
371 && self.should_require_blank_line(*opening_line, &lines)
372 {
373 needs_blank_before.insert(*opening_line);
374 }
375
376 if *closing_line + 1 < lines.len()
378 && !Self::is_empty_line(lines[*closing_line + 1])
379 && !is_kramdown_block_attribute(lines[*closing_line + 1])
380 && self.should_require_blank_line(*closing_line, &lines)
381 {
382 needs_blank_after.insert(*closing_line);
383 }
384 }
385
386 let mut result = Vec::new();
388 for (i, line) in lines.iter().enumerate() {
389 if needs_blank_before.contains(&i) {
391 result.push(String::new());
392 }
393
394 result.push((*line).to_string());
395
396 if needs_blank_after.contains(&i) {
398 result.push(String::new());
399 }
400 }
401
402 let fixed = result.join("\n");
403
404 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
406 format!("{fixed}\n")
407 } else {
408 fixed
409 };
410
411 Ok(final_result)
412 }
413
414 fn category(&self) -> RuleCategory {
416 RuleCategory::CodeBlock
417 }
418
419 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
421 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
423 }
424
425 fn as_any(&self) -> &dyn std::any::Any {
426 self
427 }
428
429 fn default_config_section(&self) -> Option<(String, toml::Value)> {
430 let default_config = MD031Config::default();
431 let json_value = serde_json::to_value(&default_config).ok()?;
432 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
433 if let toml::Value::Table(table) = toml_value {
434 if !table.is_empty() {
435 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
436 } else {
437 None
438 }
439 } else {
440 None
441 }
442 }
443
444 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
445 where
446 Self: Sized,
447 {
448 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
449 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use crate::lint_context::LintContext;
457
458 #[test]
459 fn test_basic_functionality() {
460 let rule = MD031BlanksAroundFences::default();
461
462 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
465 let warnings = rule.check(&ctx).unwrap();
466 assert!(
467 warnings.is_empty(),
468 "Expected no warnings for properly formatted code blocks"
469 );
470
471 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let warnings = rule.check(&ctx).unwrap();
475 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
476 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
477 assert!(
478 warnings[0].message.contains("before"),
479 "Warning should be about blank line before"
480 );
481
482 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let warnings = rule.check(&ctx).unwrap();
486 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
487 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
488 assert!(
489 warnings[0].message.contains("after"),
490 "Warning should be about blank line after"
491 );
492
493 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let warnings = rule.check(&ctx).unwrap();
497 assert_eq!(
498 warnings.len(),
499 2,
500 "Expected 2 warnings for missing blank lines before and after"
501 );
502 }
503
504 #[test]
505 fn test_nested_code_blocks() {
506 let rule = MD031BlanksAroundFences::default();
507
508 let content = r#"````markdown
510```
511content
512```
513````"#;
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let warnings = rule.check(&ctx).unwrap();
516 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
517
518 let fixed = rule.fix(&ctx).unwrap();
520 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
521 }
522
523 #[test]
524 fn test_nested_code_blocks_complex() {
525 let rule = MD031BlanksAroundFences::default();
526
527 let content = r#"# Documentation
529
530## Examples
531
532````markdown
533```python
534def hello():
535 print("Hello, world!")
536```
537
538```javascript
539console.log("Hello, world!");
540```
541````
542
543More text here."#;
544
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let warnings = rule.check(&ctx).unwrap();
547 assert_eq!(
548 warnings.len(),
549 0,
550 "Should not flag any issues in properly formatted nested code blocks"
551 );
552
553 let content_5 = r#"`````markdown
555````python
556```bash
557echo "nested"
558```
559````
560`````"#;
561
562 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
563 let warnings_5 = rule.check(&ctx_5).unwrap();
564 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
565 }
566
567 #[test]
568 fn test_fix_preserves_trailing_newline() {
569 let rule = MD031BlanksAroundFences::default();
570
571 let content = "Some text\n```\ncode\n```\nMore text\n";
573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574 let fixed = rule.fix(&ctx).unwrap();
575
576 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
578 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
579 }
580
581 #[test]
582 fn test_fix_preserves_no_trailing_newline() {
583 let rule = MD031BlanksAroundFences::default();
584
585 let content = "Some text\n```\ncode\n```\nMore text";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let fixed = rule.fix(&ctx).unwrap();
589
590 assert!(
592 !fixed.ends_with('\n'),
593 "Fix should not add trailing newline if original didn't have one"
594 );
595 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
596 }
597
598 #[test]
599 fn test_list_items_config_true() {
600 let rule = MD031BlanksAroundFences::new(true);
602
603 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let warnings = rule.check(&ctx).unwrap();
606
607 assert_eq!(warnings.len(), 2);
609 assert!(warnings[0].message.contains("before"));
610 assert!(warnings[1].message.contains("after"));
611 }
612
613 #[test]
614 fn test_list_items_config_false() {
615 let rule = MD031BlanksAroundFences::new(false);
617
618 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620 let warnings = rule.check(&ctx).unwrap();
621
622 assert_eq!(warnings.len(), 0);
624 }
625
626 #[test]
627 fn test_list_items_config_false_outside_list() {
628 let rule = MD031BlanksAroundFences::new(false);
630
631 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
632 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let warnings = rule.check(&ctx).unwrap();
634
635 assert_eq!(warnings.len(), 2);
637 assert!(warnings[0].message.contains("before"));
638 assert!(warnings[1].message.contains("after"));
639 }
640
641 #[test]
642 fn test_default_config_section() {
643 let rule = MD031BlanksAroundFences::default();
644 let config_section = rule.default_config_section();
645
646 assert!(config_section.is_some());
647 let (name, value) = config_section.unwrap();
648 assert_eq!(name, "MD031");
649
650 if let toml::Value::Table(table) = value {
652 assert!(table.contains_key("list-items"));
653 assert_eq!(table["list-items"], toml::Value::Boolean(true));
654 } else {
655 panic!("Expected TOML table");
656 }
657 }
658
659 #[test]
660 fn test_fix_list_items_config_false() {
661 let rule = MD031BlanksAroundFences::new(false);
663
664 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let fixed = rule.fix(&ctx).unwrap();
667
668 assert_eq!(fixed, content);
670 }
671
672 #[test]
673 fn test_fix_list_items_config_true() {
674 let rule = MD031BlanksAroundFences::new(true);
676
677 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let fixed = rule.fix(&ctx).unwrap();
680
681 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
683 assert_eq!(fixed, expected);
684 }
685
686 #[test]
687 fn test_no_warning_after_frontmatter() {
688 let rule = MD031BlanksAroundFences::default();
691
692 let content = "---\ntitle: Test\n---\n```\ncode\n```";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let warnings = rule.check(&ctx).unwrap();
695
696 assert!(
698 warnings.is_empty(),
699 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
700 );
701 }
702
703 #[test]
704 fn test_fix_does_not_add_blank_after_frontmatter() {
705 let rule = MD031BlanksAroundFences::default();
707
708 let content = "---\ntitle: Test\n---\n```\ncode\n```";
709 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710 let fixed = rule.fix(&ctx).unwrap();
711
712 assert_eq!(fixed, content);
714 }
715
716 #[test]
717 fn test_frontmatter_with_blank_line_before_code() {
718 let rule = MD031BlanksAroundFences::default();
720
721 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let warnings = rule.check(&ctx).unwrap();
724
725 assert!(warnings.is_empty());
726 }
727
728 #[test]
729 fn test_no_warning_for_admonition_after_frontmatter() {
730 let rule = MD031BlanksAroundFences::default();
732
733 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
735 let warnings = rule.check(&ctx).unwrap();
736
737 assert!(
738 warnings.is_empty(),
739 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
740 );
741 }
742
743 #[test]
744 fn test_toml_frontmatter_before_code() {
745 let rule = MD031BlanksAroundFences::default();
747
748 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let warnings = rule.check(&ctx).unwrap();
751
752 assert!(
753 warnings.is_empty(),
754 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
755 );
756 }
757
758 #[test]
759 fn test_fenced_code_in_list_with_4_space_indent_issue_276() {
760 let rule = MD031BlanksAroundFences::new(true);
764
765 let content =
767 "1. First item\n2. Second item with code:\n ```python\n print(\"Hello\")\n ```\n3. Third item";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let warnings = rule.check(&ctx).unwrap();
770
771 assert_eq!(
773 warnings.len(),
774 2,
775 "Should detect fenced code in list with 4-space indent, got: {warnings:?}"
776 );
777 assert!(warnings[0].message.contains("before"));
778 assert!(warnings[1].message.contains("after"));
779
780 let fixed = rule.fix(&ctx).unwrap();
782 let expected =
783 "1. First item\n2. Second item with code:\n\n ```python\n print(\"Hello\")\n ```\n\n3. Third item";
784 assert_eq!(
785 fixed, expected,
786 "Fix should add blank lines around list-indented fenced code"
787 );
788 }
789
790 #[test]
791 fn test_fenced_code_in_list_with_mixed_indentation() {
792 let rule = MD031BlanksAroundFences::new(true);
794
795 let content = r#"# Test
796
7973-space indent:
7981. First item
799 ```python
800 code
801 ```
8022. Second item
803
8044-space indent:
8051. First item
806 ```python
807 code
808 ```
8092. Second item"#;
810
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let warnings = rule.check(&ctx).unwrap();
813
814 assert_eq!(
816 warnings.len(),
817 4,
818 "Should detect all fenced code blocks regardless of indentation, got: {warnings:?}"
819 );
820 }
821}