1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
7use crate::utils::kramdown_utils::is_kramdown_block_attribute;
8use crate::utils::mkdocs_admonitions;
9use crate::utils::range_utils::{LineIndex, 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 = line.starts_with(" ") || line.starts_with("\t") || line.starts_with(" ");
88 if is_indented {
89 continue; }
91
92 return false;
95 }
96
97 false
98 }
99
100 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
102 if self.config.list_items {
103 true
105 } else {
106 !self.is_in_list(line_index, lines)
108 }
109 }
110}
111
112impl Rule for MD031BlanksAroundFences {
113 fn name(&self) -> &'static str {
114 "MD031"
115 }
116
117 fn description(&self) -> &'static str {
118 "Fenced code blocks should be surrounded by blank lines"
119 }
120
121 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
122 let content = ctx.content;
123 let line_index = LineIndex::new(content.to_string());
124
125 let mut warnings = Vec::new();
126 let lines: Vec<&str> = content.lines().collect();
127
128 let mut in_code_block = false;
129 let mut current_fence_marker: Option<String> = None;
130 let mut in_admonition = false;
131 let mut admonition_indent = 0;
132 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
133 let mut i = 0;
134
135 while i < lines.len() {
136 let line = lines[i];
137 let trimmed = line.trim_start();
138
139 if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
141 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
143 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
144
145 warnings.push(LintWarning {
146 rule_name: Some(self.name()),
147 line: start_line,
148 column: start_col,
149 end_line,
150 end_column: end_col,
151 message: "No blank line before admonition block".to_string(),
152 severity: Severity::Warning,
153 fix: Some(Fix {
154 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
155 replacement: "\n".to_string(),
156 }),
157 });
158 }
159
160 in_admonition = true;
161 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
162 i += 1;
163 continue;
164 }
165
166 if in_admonition {
168 if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
169 in_admonition = false;
171
172 if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
174 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
175
176 warnings.push(LintWarning {
177 rule_name: Some(self.name()),
178 line: start_line,
179 column: start_col,
180 end_line,
181 end_column: end_col,
182 message: "No blank line after admonition block".to_string(),
183 severity: Severity::Warning,
184 fix: Some(Fix {
185 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
186 replacement: "\n".to_string(),
187 }),
188 });
189 }
190
191 admonition_indent = 0;
192 } else {
194 i += 1;
196 continue;
197 }
198 }
199
200 let fence_marker = if trimmed.starts_with("```") {
202 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
203 if backtick_count >= 3 {
204 Some("`".repeat(backtick_count))
205 } else {
206 None
207 }
208 } else if trimmed.starts_with("~~~") {
209 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
210 if tilde_count >= 3 {
211 Some("~".repeat(tilde_count))
212 } else {
213 None
214 }
215 } else {
216 None
217 };
218
219 if let Some(fence_marker) = fence_marker {
220 if in_code_block {
221 if let Some(ref current_marker) = current_fence_marker {
223 let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
228 || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
229
230 if same_type
231 && fence_marker.len() >= current_marker.len()
232 && trimmed[fence_marker.len()..].trim().is_empty()
233 {
234 in_code_block = false;
236 current_fence_marker = None;
237
238 if i + 1 < lines.len()
241 && !Self::is_empty_line(lines[i + 1])
242 && !is_kramdown_block_attribute(lines[i + 1])
243 && self.should_require_blank_line(i, &lines)
244 {
245 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
246
247 warnings.push(LintWarning {
248 rule_name: Some(self.name()),
249 line: start_line,
250 column: start_col,
251 end_line,
252 end_column: end_col,
253 message: "No blank line after fenced code block".to_string(),
254 severity: Severity::Warning,
255 fix: Some(Fix {
256 range: line_index.line_col_to_byte_range_with_length(
257 i + 1,
258 lines[i].len() + 1,
259 0,
260 ),
261 replacement: "\n".to_string(),
262 }),
263 });
264 }
265 }
266 }
268 } else {
269 in_code_block = true;
271 current_fence_marker = Some(fence_marker);
272
273 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
275 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
276
277 warnings.push(LintWarning {
278 rule_name: Some(self.name()),
279 line: start_line,
280 column: start_col,
281 end_line,
282 end_column: end_col,
283 message: "No blank line before fenced code block".to_string(),
284 severity: Severity::Warning,
285 fix: Some(Fix {
286 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
287 replacement: "\n".to_string(),
288 }),
289 });
290 }
291 }
292 }
293 i += 1;
295 }
296
297 Ok(warnings)
298 }
299
300 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
301 let content = ctx.content;
302 let _line_index = LineIndex::new(content.to_string());
303
304 let had_trailing_newline = content.ends_with('\n');
306
307 let lines: Vec<&str> = content.lines().collect();
308
309 let mut result = Vec::new();
310 let mut in_code_block = false;
311 let mut current_fence_marker: Option<String> = None;
312
313 let mut i = 0;
314
315 while i < lines.len() {
316 let line = lines[i];
317 let trimmed = line.trim_start();
318
319 let fence_marker = if trimmed.starts_with("```") {
321 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
322 if backtick_count >= 3 {
323 Some("`".repeat(backtick_count))
324 } else {
325 None
326 }
327 } else if trimmed.starts_with("~~~") {
328 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
329 if tilde_count >= 3 {
330 Some("~".repeat(tilde_count))
331 } else {
332 None
333 }
334 } else {
335 None
336 };
337
338 if let Some(fence_marker) = fence_marker {
339 if in_code_block {
340 if let Some(ref current_marker) = current_fence_marker {
342 if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
343 result.push(line.to_string());
345 in_code_block = false;
346 current_fence_marker = None;
347
348 if i + 1 < lines.len()
351 && !Self::is_empty_line(lines[i + 1])
352 && !is_kramdown_block_attribute(lines[i + 1])
353 && self.should_require_blank_line(i, &lines)
354 {
355 result.push(String::new());
356 }
357 } else {
358 result.push(line.to_string());
360 }
361 } else {
362 result.push(line.to_string());
364 }
365 } else {
366 in_code_block = true;
368 current_fence_marker = Some(fence_marker);
369
370 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
372 result.push(String::new());
373 }
374
375 result.push(line.to_string());
377 }
378 } else if in_code_block {
379 result.push(line.to_string());
381 } else {
382 result.push(line.to_string());
384 }
385 i += 1;
386 }
387
388 let fixed = result.join("\n");
389
390 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
392 format!("{fixed}\n")
393 } else {
394 fixed
395 };
396
397 Ok(final_result)
398 }
399
400 fn category(&self) -> RuleCategory {
402 RuleCategory::CodeBlock
403 }
404
405 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
407 let content = ctx.content;
408 content.is_empty() || (!content.contains("```") && !content.contains("~~~"))
409 }
410
411 fn check_with_structure(
413 &self,
414 ctx: &crate::lint_context::LintContext,
415 structure: &DocumentStructure,
416 ) -> LintResult {
417 let content = ctx.content;
418 if !self.has_relevant_elements(ctx, structure) {
420 return Ok(Vec::new());
421 }
422
423 let line_index = LineIndex::new(content.to_string());
424 let mut warnings = Vec::new();
425 let lines: Vec<&str> = content.lines().collect();
426
427 for &start_line in &structure.fenced_code_block_starts {
429 let line_num = start_line;
430
431 if line_num > 1
433 && !Self::is_empty_line(lines[line_num - 2])
434 && self.should_require_blank_line(line_num - 1, &lines)
435 {
436 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, lines[line_num - 1]);
438
439 warnings.push(LintWarning {
440 rule_name: Some(self.name()),
441 line: start_line,
442 column: start_col,
443 end_line,
444 end_column: end_col,
445 message: "No blank line before fenced code block".to_string(),
446 severity: Severity::Warning,
447 fix: Some(Fix {
448 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
449 replacement: "\n".to_string(),
450 }),
451 });
452 }
453 }
454
455 for &end_line in &structure.fenced_code_block_ends {
456 let line_num = end_line;
457
458 if line_num < lines.len()
460 && !Self::is_empty_line(lines[line_num])
461 && self.should_require_blank_line(line_num - 1, &lines)
462 {
463 let (start_line_fence, start_col_fence, end_line_fence, end_col_fence) =
465 calculate_line_range(line_num, lines[line_num - 1]);
466
467 warnings.push(LintWarning {
468 rule_name: Some(self.name()),
469 line: start_line_fence,
470 column: start_col_fence,
471 end_line: end_line_fence,
472 end_column: end_col_fence,
473 message: "No blank line after fenced code block".to_string(),
474 severity: Severity::Warning,
475 fix: Some(Fix {
476 range: line_index.line_col_to_byte_range_with_length(
477 line_num,
478 lines[line_num - 1].len() + 1,
479 0,
480 ),
481 replacement: "\n".to_string(),
482 }),
483 });
484 }
485 }
486
487 Ok(warnings)
488 }
489
490 fn as_any(&self) -> &dyn std::any::Any {
491 self
492 }
493
494 fn default_config_section(&self) -> Option<(String, toml::Value)> {
495 let default_config = MD031Config::default();
496 let json_value = serde_json::to_value(&default_config).ok()?;
497 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
498 if let toml::Value::Table(table) = toml_value {
499 if !table.is_empty() {
500 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
501 } else {
502 None
503 }
504 } else {
505 None
506 }
507 }
508
509 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
510 where
511 Self: Sized,
512 {
513 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
514 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
515 }
516}
517
518impl DocumentStructureExtensions for MD031BlanksAroundFences {
519 fn has_relevant_elements(
520 &self,
521 _ctx: &crate::lint_context::LintContext,
522 doc_structure: &DocumentStructure,
523 ) -> bool {
524 !doc_structure.fenced_code_block_starts.is_empty() || !doc_structure.fenced_code_block_ends.is_empty()
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::lint_context::LintContext;
532 use crate::utils::document_structure::document_structure_from_str;
533
534 #[test]
535 fn test_with_document_structure() {
536 let rule = MD031BlanksAroundFences::default();
537
538 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
540 let structure = document_structure_from_str(content);
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
542 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
543 assert!(
544 warnings.is_empty(),
545 "Expected no warnings for properly formatted code blocks"
546 );
547
548 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
550 let structure = document_structure_from_str(content);
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
552 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
553 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
554 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
555 assert!(
556 warnings[0].message.contains("before"),
557 "Warning should be about blank line before"
558 );
559
560 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
562 let structure = document_structure_from_str(content);
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
564 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
565 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
566 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
567 assert!(
568 warnings[0].message.contains("after"),
569 "Warning should be about blank line after"
570 );
571
572 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
574 let structure = document_structure_from_str(content);
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
576 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
577 assert_eq!(
578 warnings.len(),
579 2,
580 "Expected 2 warnings for missing blank lines before and after"
581 );
582 }
583
584 #[test]
585 fn test_nested_code_blocks() {
586 let rule = MD031BlanksAroundFences::default();
587
588 let content = r#"````markdown
590```
591content
592```
593````"#;
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
595 let warnings = rule.check(&ctx).unwrap();
596 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
597
598 let fixed = rule.fix(&ctx).unwrap();
600 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
601 }
602
603 #[test]
604 fn test_nested_code_blocks_complex() {
605 let rule = MD031BlanksAroundFences::default();
606
607 let content = r#"# Documentation
609
610## Examples
611
612````markdown
613```python
614def hello():
615 print("Hello, world!")
616```
617
618```javascript
619console.log("Hello, world!");
620```
621````
622
623More text here."#;
624
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
626 let warnings = rule.check(&ctx).unwrap();
627 assert_eq!(
628 warnings.len(),
629 0,
630 "Should not flag any issues in properly formatted nested code blocks"
631 );
632
633 let content_5 = r#"`````markdown
635````python
636```bash
637echo "nested"
638```
639````
640`````"#;
641
642 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
643 let warnings_5 = rule.check(&ctx_5).unwrap();
644 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
645 }
646
647 #[test]
648 fn test_fix_preserves_trailing_newline() {
649 let rule = MD031BlanksAroundFences::default();
650
651 let content = "Some text\n```\ncode\n```\nMore text\n";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
654 let fixed = rule.fix(&ctx).unwrap();
655
656 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
658 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
659 }
660
661 #[test]
662 fn test_fix_preserves_no_trailing_newline() {
663 let rule = MD031BlanksAroundFences::default();
664
665 let content = "Some text\n```\ncode\n```\nMore text";
667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
668 let fixed = rule.fix(&ctx).unwrap();
669
670 assert!(
672 !fixed.ends_with('\n'),
673 "Fix should not add trailing newline if original didn't have one"
674 );
675 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
676 }
677
678 #[test]
679 fn test_list_items_config_true() {
680 let rule = MD031BlanksAroundFences::new(true);
682
683 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
685 let warnings = rule.check(&ctx).unwrap();
686
687 assert_eq!(warnings.len(), 2);
689 assert!(warnings[0].message.contains("before"));
690 assert!(warnings[1].message.contains("after"));
691 }
692
693 #[test]
694 fn test_list_items_config_false() {
695 let rule = MD031BlanksAroundFences::new(false);
697
698 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
700 let warnings = rule.check(&ctx).unwrap();
701
702 assert_eq!(warnings.len(), 0);
704 }
705
706 #[test]
707 fn test_list_items_config_false_outside_list() {
708 let rule = MD031BlanksAroundFences::new(false);
710
711 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
713 let warnings = rule.check(&ctx).unwrap();
714
715 assert_eq!(warnings.len(), 2);
717 assert!(warnings[0].message.contains("before"));
718 assert!(warnings[1].message.contains("after"));
719 }
720
721 #[test]
722 fn test_default_config_section() {
723 let rule = MD031BlanksAroundFences::default();
724 let config_section = rule.default_config_section();
725
726 assert!(config_section.is_some());
727 let (name, value) = config_section.unwrap();
728 assert_eq!(name, "MD031");
729
730 if let toml::Value::Table(table) = value {
732 assert!(table.contains_key("list-items"));
733 assert_eq!(table["list-items"], toml::Value::Boolean(true));
734 } else {
735 panic!("Expected TOML table");
736 }
737 }
738
739 #[test]
740 fn test_fix_list_items_config_false() {
741 let rule = MD031BlanksAroundFences::new(false);
743
744 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
746 let fixed = rule.fix(&ctx).unwrap();
747
748 assert_eq!(fixed, content);
750 }
751
752 #[test]
753 fn test_fix_list_items_config_true() {
754 let rule = MD031BlanksAroundFences::new(true);
756
757 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
758 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
759 let fixed = rule.fix(&ctx).unwrap();
760
761 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
763 assert_eq!(fixed, expected);
764 }
765}