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 serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "kebab-case")]
15pub struct MD031Config {
16 #[serde(default = "default_list_items")]
18 pub list_items: bool,
19}
20
21impl Default for MD031Config {
22 fn default() -> Self {
23 Self {
24 list_items: default_list_items(),
25 }
26 }
27}
28
29fn default_list_items() -> bool {
30 true
31}
32
33impl RuleConfig for MD031Config {
34 const RULE_NAME: &'static str = "MD031";
35}
36
37#[derive(Clone, Default)]
39pub struct MD031BlanksAroundFences {
40 config: MD031Config,
41}
42
43impl MD031BlanksAroundFences {
44 pub fn new(list_items: bool) -> Self {
45 Self {
46 config: MD031Config { list_items },
47 }
48 }
49
50 pub fn from_config_struct(config: MD031Config) -> Self {
51 Self { config }
52 }
53
54 fn is_empty_line(line: &str) -> bool {
55 line.trim().is_empty()
56 }
57
58 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
60 for i in (0..=line_index).rev() {
62 let line = lines[i];
63 let trimmed = line.trim_start();
64
65 if trimmed.is_empty() {
67 return false;
68 }
69
70 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
72 let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
73 if let Some(next) = chars.next()
74 && (next == '.' || next == ')')
75 && chars.next() == Some(' ')
76 {
77 return true;
78 }
79 }
80
81 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
83 return true;
84 }
85
86 let is_indented = ElementCache::calculate_indentation_width_default(line) >= 3;
88 if is_indented {
89 continue; }
91
92 return false;
95 }
96
97 false
98 }
99
100 fn get_indentation(line: &str) -> usize {
102 line.chars().take_while(|c| *c == ' ').count()
103 }
104
105 fn get_fence_marker(line: &str) -> Option<String> {
107 let indent = Self::get_indentation(line);
108 if indent > 3 {
110 return None;
111 }
112
113 let trimmed = line.trim_start();
114 if trimmed.starts_with("```") {
115 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
116 if backtick_count >= 3 {
117 return Some("`".repeat(backtick_count));
118 }
119 } else if trimmed.starts_with("~~~") {
120 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
121 if tilde_count >= 3 {
122 return Some("~".repeat(tilde_count));
123 }
124 }
125 None
126 }
127
128 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
130 if self.config.list_items {
131 true
133 } else {
134 !self.is_in_list(line_index, lines)
136 }
137 }
138
139 fn is_right_after_frontmatter(line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
141 line_index > 0
142 && ctx.lines.get(line_index - 1).is_some_and(|info| info.in_front_matter)
143 && ctx.lines.get(line_index).is_some_and(|info| !info.in_front_matter)
144 }
145}
146
147impl Rule for MD031BlanksAroundFences {
148 fn name(&self) -> &'static str {
149 "MD031"
150 }
151
152 fn description(&self) -> &'static str {
153 "Fenced code blocks should be surrounded by blank lines"
154 }
155
156 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
157 let content = ctx.content;
158 let line_index = &ctx.line_index;
159
160 let mut warnings = Vec::new();
161 let lines: Vec<&str> = content.lines().collect();
162
163 let mut in_code_block = false;
164 let mut current_fence_marker: Option<String> = None;
165 let mut in_admonition = false;
166 let mut admonition_indent = 0;
167 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
168 let mut i = 0;
169
170 while i < lines.len() {
171 let line = lines[i];
172 let trimmed = line.trim_start();
173
174 if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
176 if i > 0
179 && !Self::is_empty_line(lines[i - 1])
180 && !Self::is_right_after_frontmatter(i, ctx)
181 && self.should_require_blank_line(i, &lines)
182 {
183 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
184
185 warnings.push(LintWarning {
186 rule_name: Some(self.name().to_string()),
187 line: start_line,
188 column: start_col,
189 end_line,
190 end_column: end_col,
191 message: "No blank line before admonition block".to_string(),
192 severity: Severity::Warning,
193 fix: Some(Fix {
194 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
195 replacement: "\n".to_string(),
196 }),
197 });
198 }
199
200 in_admonition = true;
201 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
202 i += 1;
203 continue;
204 }
205
206 if in_admonition {
208 if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
209 in_admonition = false;
211
212 if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
214 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
215
216 warnings.push(LintWarning {
217 rule_name: Some(self.name().to_string()),
218 line: start_line,
219 column: start_col,
220 end_line,
221 end_column: end_col,
222 message: "No blank line after admonition block".to_string(),
223 severity: Severity::Warning,
224 fix: Some(Fix {
225 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
226 replacement: "\n".to_string(),
227 }),
228 });
229 }
230
231 admonition_indent = 0;
232 } else {
234 i += 1;
236 continue;
237 }
238 }
239
240 let fence_marker = Self::get_fence_marker(line);
242
243 if let Some(fence_marker) = fence_marker {
244 if in_code_block {
245 if let Some(ref current_marker) = current_fence_marker {
247 let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
252 || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
253
254 if same_type
255 && fence_marker.len() >= current_marker.len()
256 && trimmed[fence_marker.len()..].trim().is_empty()
257 {
258 in_code_block = false;
260 current_fence_marker = None;
261
262 if i + 1 < lines.len()
265 && !Self::is_empty_line(lines[i + 1])
266 && !is_kramdown_block_attribute(lines[i + 1])
267 && self.should_require_blank_line(i, &lines)
268 {
269 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
270
271 warnings.push(LintWarning {
272 rule_name: Some(self.name().to_string()),
273 line: start_line,
274 column: start_col,
275 end_line,
276 end_column: end_col,
277 message: "No blank line after fenced code block".to_string(),
278 severity: Severity::Warning,
279 fix: Some(Fix {
280 range: line_index.line_col_to_byte_range_with_length(
281 i + 1,
282 lines[i].len() + 1,
283 0,
284 ),
285 replacement: "\n".to_string(),
286 }),
287 });
288 }
289 }
290 }
292 } else {
293 in_code_block = true;
295 current_fence_marker = Some(fence_marker);
296
297 if i > 0
300 && !Self::is_empty_line(lines[i - 1])
301 && !Self::is_right_after_frontmatter(i, ctx)
302 && self.should_require_blank_line(i, &lines)
303 {
304 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
305
306 warnings.push(LintWarning {
307 rule_name: Some(self.name().to_string()),
308 line: start_line,
309 column: start_col,
310 end_line,
311 end_column: end_col,
312 message: "No blank line before fenced code block".to_string(),
313 severity: Severity::Warning,
314 fix: Some(Fix {
315 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
316 replacement: "\n".to_string(),
317 }),
318 });
319 }
320 }
321 }
322 i += 1;
324 }
325
326 Ok(warnings)
327 }
328
329 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
330 let content = ctx.content;
331 let _line_index = &ctx.line_index;
332
333 let had_trailing_newline = content.ends_with('\n');
335
336 let lines: Vec<&str> = content.lines().collect();
337
338 let mut result = Vec::new();
339 let mut in_code_block = false;
340 let mut current_fence_marker: Option<String> = None;
341
342 let mut i = 0;
343
344 while i < lines.len() {
345 let line = lines[i];
346 let trimmed = line.trim_start();
347
348 let fence_marker = Self::get_fence_marker(line);
350
351 if let Some(fence_marker) = fence_marker {
352 if in_code_block {
353 if let Some(ref current_marker) = current_fence_marker {
355 if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
356 result.push(line.to_string());
358 in_code_block = false;
359 current_fence_marker = None;
360
361 if i + 1 < lines.len()
364 && !Self::is_empty_line(lines[i + 1])
365 && !is_kramdown_block_attribute(lines[i + 1])
366 && self.should_require_blank_line(i, &lines)
367 {
368 result.push(String::new());
369 }
370 } else {
371 result.push(line.to_string());
373 }
374 } else {
375 result.push(line.to_string());
377 }
378 } else {
379 in_code_block = true;
381 current_fence_marker = Some(fence_marker);
382
383 if i > 0
386 && !Self::is_empty_line(lines[i - 1])
387 && !Self::is_right_after_frontmatter(i, ctx)
388 && self.should_require_blank_line(i, &lines)
389 {
390 result.push(String::new());
391 }
392
393 result.push(line.to_string());
395 }
396 } else if in_code_block {
397 result.push(line.to_string());
399 } else {
400 result.push(line.to_string());
402 }
403 i += 1;
404 }
405
406 let fixed = result.join("\n");
407
408 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
410 format!("{fixed}\n")
411 } else {
412 fixed
413 };
414
415 Ok(final_result)
416 }
417
418 fn category(&self) -> RuleCategory {
420 RuleCategory::CodeBlock
421 }
422
423 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
425 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
427 }
428
429 fn as_any(&self) -> &dyn std::any::Any {
430 self
431 }
432
433 fn default_config_section(&self) -> Option<(String, toml::Value)> {
434 let default_config = MD031Config::default();
435 let json_value = serde_json::to_value(&default_config).ok()?;
436 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
437 if let toml::Value::Table(table) = toml_value {
438 if !table.is_empty() {
439 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
440 } else {
441 None
442 }
443 } else {
444 None
445 }
446 }
447
448 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
449 where
450 Self: Sized,
451 {
452 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
453 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::lint_context::LintContext;
461
462 #[test]
463 fn test_basic_functionality() {
464 let rule = MD031BlanksAroundFences::default();
465
466 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
469 let warnings = rule.check(&ctx).unwrap();
470 assert!(
471 warnings.is_empty(),
472 "Expected no warnings for properly formatted code blocks"
473 );
474
475 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
478 let warnings = rule.check(&ctx).unwrap();
479 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
480 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
481 assert!(
482 warnings[0].message.contains("before"),
483 "Warning should be about blank line before"
484 );
485
486 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
489 let warnings = rule.check(&ctx).unwrap();
490 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
491 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
492 assert!(
493 warnings[0].message.contains("after"),
494 "Warning should be about blank line after"
495 );
496
497 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let warnings = rule.check(&ctx).unwrap();
501 assert_eq!(
502 warnings.len(),
503 2,
504 "Expected 2 warnings for missing blank lines before and after"
505 );
506 }
507
508 #[test]
509 fn test_nested_code_blocks() {
510 let rule = MD031BlanksAroundFences::default();
511
512 let content = r#"````markdown
514```
515content
516```
517````"#;
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let warnings = rule.check(&ctx).unwrap();
520 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
521
522 let fixed = rule.fix(&ctx).unwrap();
524 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
525 }
526
527 #[test]
528 fn test_nested_code_blocks_complex() {
529 let rule = MD031BlanksAroundFences::default();
530
531 let content = r#"# Documentation
533
534## Examples
535
536````markdown
537```python
538def hello():
539 print("Hello, world!")
540```
541
542```javascript
543console.log("Hello, world!");
544```
545````
546
547More text here."#;
548
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
550 let warnings = rule.check(&ctx).unwrap();
551 assert_eq!(
552 warnings.len(),
553 0,
554 "Should not flag any issues in properly formatted nested code blocks"
555 );
556
557 let content_5 = r#"`````markdown
559````python
560```bash
561echo "nested"
562```
563````
564`````"#;
565
566 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard, None);
567 let warnings_5 = rule.check(&ctx_5).unwrap();
568 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
569 }
570
571 #[test]
572 fn test_fix_preserves_trailing_newline() {
573 let rule = MD031BlanksAroundFences::default();
574
575 let content = "Some text\n```\ncode\n```\nMore text\n";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let fixed = rule.fix(&ctx).unwrap();
579
580 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
582 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
583 }
584
585 #[test]
586 fn test_fix_preserves_no_trailing_newline() {
587 let rule = MD031BlanksAroundFences::default();
588
589 let content = "Some text\n```\ncode\n```\nMore text";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
592 let fixed = rule.fix(&ctx).unwrap();
593
594 assert!(
596 !fixed.ends_with('\n'),
597 "Fix should not add trailing newline if original didn't have one"
598 );
599 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
600 }
601
602 #[test]
603 fn test_list_items_config_true() {
604 let rule = MD031BlanksAroundFences::new(true);
606
607 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
608 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609 let warnings = rule.check(&ctx).unwrap();
610
611 assert_eq!(warnings.len(), 2);
613 assert!(warnings[0].message.contains("before"));
614 assert!(warnings[1].message.contains("after"));
615 }
616
617 #[test]
618 fn test_list_items_config_false() {
619 let rule = MD031BlanksAroundFences::new(false);
621
622 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624 let warnings = rule.check(&ctx).unwrap();
625
626 assert_eq!(warnings.len(), 0);
628 }
629
630 #[test]
631 fn test_list_items_config_false_outside_list() {
632 let rule = MD031BlanksAroundFences::new(false);
634
635 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let warnings = rule.check(&ctx).unwrap();
638
639 assert_eq!(warnings.len(), 2);
641 assert!(warnings[0].message.contains("before"));
642 assert!(warnings[1].message.contains("after"));
643 }
644
645 #[test]
646 fn test_default_config_section() {
647 let rule = MD031BlanksAroundFences::default();
648 let config_section = rule.default_config_section();
649
650 assert!(config_section.is_some());
651 let (name, value) = config_section.unwrap();
652 assert_eq!(name, "MD031");
653
654 if let toml::Value::Table(table) = value {
656 assert!(table.contains_key("list-items"));
657 assert_eq!(table["list-items"], toml::Value::Boolean(true));
658 } else {
659 panic!("Expected TOML table");
660 }
661 }
662
663 #[test]
664 fn test_fix_list_items_config_false() {
665 let rule = MD031BlanksAroundFences::new(false);
667
668 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let fixed = rule.fix(&ctx).unwrap();
671
672 assert_eq!(fixed, content);
674 }
675
676 #[test]
677 fn test_fix_list_items_config_true() {
678 let rule = MD031BlanksAroundFences::new(true);
680
681 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let fixed = rule.fix(&ctx).unwrap();
684
685 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
687 assert_eq!(fixed, expected);
688 }
689
690 #[test]
691 fn test_no_warning_after_frontmatter() {
692 let rule = MD031BlanksAroundFences::default();
695
696 let content = "---\ntitle: Test\n---\n```\ncode\n```";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698 let warnings = rule.check(&ctx).unwrap();
699
700 assert!(
702 warnings.is_empty(),
703 "Expected no warnings for code block after frontmatter, got: {warnings:?}"
704 );
705 }
706
707 #[test]
708 fn test_fix_does_not_add_blank_after_frontmatter() {
709 let rule = MD031BlanksAroundFences::default();
711
712 let content = "---\ntitle: Test\n---\n```\ncode\n```";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let fixed = rule.fix(&ctx).unwrap();
715
716 assert_eq!(fixed, content);
718 }
719
720 #[test]
721 fn test_frontmatter_with_blank_line_before_code() {
722 let rule = MD031BlanksAroundFences::default();
724
725 let content = "---\ntitle: Test\n---\n\n```\ncode\n```";
726 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
727 let warnings = rule.check(&ctx).unwrap();
728
729 assert!(warnings.is_empty());
730 }
731
732 #[test]
733 fn test_no_warning_for_admonition_after_frontmatter() {
734 let rule = MD031BlanksAroundFences::default();
736
737 let content = "---\ntitle: Test\n---\n!!! note\n This is a note";
738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
739 let warnings = rule.check(&ctx).unwrap();
740
741 assert!(
742 warnings.is_empty(),
743 "Expected no warnings for admonition after frontmatter, got: {warnings:?}"
744 );
745 }
746
747 #[test]
748 fn test_toml_frontmatter_before_code() {
749 let rule = MD031BlanksAroundFences::default();
751
752 let content = "+++\ntitle = \"Test\"\n+++\n```\ncode\n```";
753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
754 let warnings = rule.check(&ctx).unwrap();
755
756 assert!(
757 warnings.is_empty(),
758 "Expected no warnings for code block after TOML frontmatter, got: {warnings:?}"
759 );
760 }
761}