1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6use crate::utils::kramdown_utils::is_kramdown_block_attribute;
7use crate::utils::mkdocs_admonitions;
8use crate::utils::range_utils::calculate_line_range;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "kebab-case")]
14pub struct MD031Config {
15 #[serde(default = "default_list_items")]
17 pub list_items: bool,
18}
19
20impl Default for MD031Config {
21 fn default() -> Self {
22 Self {
23 list_items: default_list_items(),
24 }
25 }
26}
27
28fn default_list_items() -> bool {
29 true
30}
31
32impl RuleConfig for MD031Config {
33 const RULE_NAME: &'static str = "MD031";
34}
35
36#[derive(Clone, Default)]
38pub struct MD031BlanksAroundFences {
39 config: MD031Config,
40}
41
42impl MD031BlanksAroundFences {
43 pub fn new(list_items: bool) -> Self {
44 Self {
45 config: MD031Config { list_items },
46 }
47 }
48
49 pub fn from_config_struct(config: MD031Config) -> Self {
50 Self { config }
51 }
52
53 fn is_empty_line(line: &str) -> bool {
54 line.trim().is_empty()
55 }
56
57 fn is_in_list(&self, line_index: usize, lines: &[&str]) -> bool {
59 for i in (0..=line_index).rev() {
61 let line = lines[i];
62 let trimmed = line.trim_start();
63
64 if trimmed.is_empty() {
66 return false;
67 }
68
69 if trimmed.chars().next().is_some_and(|c| c.is_ascii_digit()) {
71 let mut chars = trimmed.chars().skip_while(|c| c.is_ascii_digit());
72 if let Some(next) = chars.next()
73 && (next == '.' || next == ')')
74 && chars.next() == Some(' ')
75 {
76 return true;
77 }
78 }
79
80 if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
82 return true;
83 }
84
85 let is_indented = line.starts_with(" ") || line.starts_with("\t") || line.starts_with(" ");
87 if is_indented {
88 continue; }
90
91 return false;
94 }
95
96 false
97 }
98
99 fn get_indentation(line: &str) -> usize {
101 line.chars().take_while(|c| *c == ' ').count()
102 }
103
104 fn get_fence_marker(line: &str) -> Option<String> {
106 let indent = Self::get_indentation(line);
107 if indent > 3 {
109 return None;
110 }
111
112 let trimmed = line.trim_start();
113 if trimmed.starts_with("```") {
114 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
115 if backtick_count >= 3 {
116 return Some("`".repeat(backtick_count));
117 }
118 } else if trimmed.starts_with("~~~") {
119 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
120 if tilde_count >= 3 {
121 return Some("~".repeat(tilde_count));
122 }
123 }
124 None
125 }
126
127 fn should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
129 if self.config.list_items {
130 true
132 } else {
133 !self.is_in_list(line_index, lines)
135 }
136 }
137}
138
139impl Rule for MD031BlanksAroundFences {
140 fn name(&self) -> &'static str {
141 "MD031"
142 }
143
144 fn description(&self) -> &'static str {
145 "Fenced code blocks should be surrounded by blank lines"
146 }
147
148 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
149 let content = ctx.content;
150 let line_index = &ctx.line_index;
151
152 let mut warnings = Vec::new();
153 let lines: Vec<&str> = content.lines().collect();
154
155 let mut in_code_block = false;
156 let mut current_fence_marker: Option<String> = None;
157 let mut in_admonition = false;
158 let mut admonition_indent = 0;
159 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
160 let mut i = 0;
161
162 while i < lines.len() {
163 let line = lines[i];
164 let trimmed = line.trim_start();
165
166 if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
168 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
170 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
171
172 warnings.push(LintWarning {
173 rule_name: Some(self.name().to_string()),
174 line: start_line,
175 column: start_col,
176 end_line,
177 end_column: end_col,
178 message: "No blank line before admonition block".to_string(),
179 severity: Severity::Warning,
180 fix: Some(Fix {
181 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
182 replacement: "\n".to_string(),
183 }),
184 });
185 }
186
187 in_admonition = true;
188 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
189 i += 1;
190 continue;
191 }
192
193 if in_admonition {
195 if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
196 in_admonition = false;
198
199 if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
201 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
202
203 warnings.push(LintWarning {
204 rule_name: Some(self.name().to_string()),
205 line: start_line,
206 column: start_col,
207 end_line,
208 end_column: end_col,
209 message: "No blank line after admonition block".to_string(),
210 severity: Severity::Warning,
211 fix: Some(Fix {
212 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
213 replacement: "\n".to_string(),
214 }),
215 });
216 }
217
218 admonition_indent = 0;
219 } else {
221 i += 1;
223 continue;
224 }
225 }
226
227 let fence_marker = Self::get_fence_marker(line);
229
230 if let Some(fence_marker) = fence_marker {
231 if in_code_block {
232 if let Some(ref current_marker) = current_fence_marker {
234 let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
239 || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
240
241 if same_type
242 && fence_marker.len() >= current_marker.len()
243 && trimmed[fence_marker.len()..].trim().is_empty()
244 {
245 in_code_block = false;
247 current_fence_marker = None;
248
249 if i + 1 < lines.len()
252 && !Self::is_empty_line(lines[i + 1])
253 && !is_kramdown_block_attribute(lines[i + 1])
254 && self.should_require_blank_line(i, &lines)
255 {
256 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
257
258 warnings.push(LintWarning {
259 rule_name: Some(self.name().to_string()),
260 line: start_line,
261 column: start_col,
262 end_line,
263 end_column: end_col,
264 message: "No blank line after fenced code block".to_string(),
265 severity: Severity::Warning,
266 fix: Some(Fix {
267 range: line_index.line_col_to_byte_range_with_length(
268 i + 1,
269 lines[i].len() + 1,
270 0,
271 ),
272 replacement: "\n".to_string(),
273 }),
274 });
275 }
276 }
277 }
279 } else {
280 in_code_block = true;
282 current_fence_marker = Some(fence_marker);
283
284 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
286 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
287
288 warnings.push(LintWarning {
289 rule_name: Some(self.name().to_string()),
290 line: start_line,
291 column: start_col,
292 end_line,
293 end_column: end_col,
294 message: "No blank line before fenced code block".to_string(),
295 severity: Severity::Warning,
296 fix: Some(Fix {
297 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
298 replacement: "\n".to_string(),
299 }),
300 });
301 }
302 }
303 }
304 i += 1;
306 }
307
308 Ok(warnings)
309 }
310
311 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
312 let content = ctx.content;
313 let _line_index = &ctx.line_index;
314
315 let had_trailing_newline = content.ends_with('\n');
317
318 let lines: Vec<&str> = content.lines().collect();
319
320 let mut result = Vec::new();
321 let mut in_code_block = false;
322 let mut current_fence_marker: Option<String> = None;
323
324 let mut i = 0;
325
326 while i < lines.len() {
327 let line = lines[i];
328 let trimmed = line.trim_start();
329
330 let fence_marker = Self::get_fence_marker(line);
332
333 if let Some(fence_marker) = fence_marker {
334 if in_code_block {
335 if let Some(ref current_marker) = current_fence_marker {
337 if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
338 result.push(line.to_string());
340 in_code_block = false;
341 current_fence_marker = None;
342
343 if i + 1 < lines.len()
346 && !Self::is_empty_line(lines[i + 1])
347 && !is_kramdown_block_attribute(lines[i + 1])
348 && self.should_require_blank_line(i, &lines)
349 {
350 result.push(String::new());
351 }
352 } else {
353 result.push(line.to_string());
355 }
356 } else {
357 result.push(line.to_string());
359 }
360 } else {
361 in_code_block = true;
363 current_fence_marker = Some(fence_marker);
364
365 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
367 result.push(String::new());
368 }
369
370 result.push(line.to_string());
372 }
373 } else if in_code_block {
374 result.push(line.to_string());
376 } else {
377 result.push(line.to_string());
379 }
380 i += 1;
381 }
382
383 let fixed = result.join("\n");
384
385 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
387 format!("{fixed}\n")
388 } else {
389 fixed
390 };
391
392 Ok(final_result)
393 }
394
395 fn category(&self) -> RuleCategory {
397 RuleCategory::CodeBlock
398 }
399
400 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
402 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
404 }
405
406 fn as_any(&self) -> &dyn std::any::Any {
407 self
408 }
409
410 fn default_config_section(&self) -> Option<(String, toml::Value)> {
411 let default_config = MD031Config::default();
412 let json_value = serde_json::to_value(&default_config).ok()?;
413 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
414 if let toml::Value::Table(table) = toml_value {
415 if !table.is_empty() {
416 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
417 } else {
418 None
419 }
420 } else {
421 None
422 }
423 }
424
425 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
426 where
427 Self: Sized,
428 {
429 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
430 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::lint_context::LintContext;
438
439 #[test]
440 fn test_basic_functionality() {
441 let rule = MD031BlanksAroundFences::default();
442
443 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
446 let warnings = rule.check(&ctx).unwrap();
447 assert!(
448 warnings.is_empty(),
449 "Expected no warnings for properly formatted code blocks"
450 );
451
452 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455 let warnings = rule.check(&ctx).unwrap();
456 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
457 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
458 assert!(
459 warnings[0].message.contains("before"),
460 "Warning should be about blank line before"
461 );
462
463 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
466 let warnings = rule.check(&ctx).unwrap();
467 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
468 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
469 assert!(
470 warnings[0].message.contains("after"),
471 "Warning should be about blank line after"
472 );
473
474 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477 let warnings = rule.check(&ctx).unwrap();
478 assert_eq!(
479 warnings.len(),
480 2,
481 "Expected 2 warnings for missing blank lines before and after"
482 );
483 }
484
485 #[test]
486 fn test_nested_code_blocks() {
487 let rule = MD031BlanksAroundFences::default();
488
489 let content = r#"````markdown
491```
492content
493```
494````"#;
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
496 let warnings = rule.check(&ctx).unwrap();
497 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
498
499 let fixed = rule.fix(&ctx).unwrap();
501 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
502 }
503
504 #[test]
505 fn test_nested_code_blocks_complex() {
506 let rule = MD031BlanksAroundFences::default();
507
508 let content = r#"# Documentation
510
511## Examples
512
513````markdown
514```python
515def hello():
516 print("Hello, world!")
517```
518
519```javascript
520console.log("Hello, world!");
521```
522````
523
524More text here."#;
525
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
527 let warnings = rule.check(&ctx).unwrap();
528 assert_eq!(
529 warnings.len(),
530 0,
531 "Should not flag any issues in properly formatted nested code blocks"
532 );
533
534 let content_5 = r#"`````markdown
536````python
537```bash
538echo "nested"
539```
540````
541`````"#;
542
543 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
544 let warnings_5 = rule.check(&ctx_5).unwrap();
545 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
546 }
547
548 #[test]
549 fn test_fix_preserves_trailing_newline() {
550 let rule = MD031BlanksAroundFences::default();
551
552 let content = "Some text\n```\ncode\n```\nMore text\n";
554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
555 let fixed = rule.fix(&ctx).unwrap();
556
557 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
559 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
560 }
561
562 #[test]
563 fn test_fix_preserves_no_trailing_newline() {
564 let rule = MD031BlanksAroundFences::default();
565
566 let content = "Some text\n```\ncode\n```\nMore text";
568 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
569 let fixed = rule.fix(&ctx).unwrap();
570
571 assert!(
573 !fixed.ends_with('\n'),
574 "Fix should not add trailing newline if original didn't have one"
575 );
576 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
577 }
578
579 #[test]
580 fn test_list_items_config_true() {
581 let rule = MD031BlanksAroundFences::new(true);
583
584 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586 let warnings = rule.check(&ctx).unwrap();
587
588 assert_eq!(warnings.len(), 2);
590 assert!(warnings[0].message.contains("before"));
591 assert!(warnings[1].message.contains("after"));
592 }
593
594 #[test]
595 fn test_list_items_config_false() {
596 let rule = MD031BlanksAroundFences::new(false);
598
599 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601 let warnings = rule.check(&ctx).unwrap();
602
603 assert_eq!(warnings.len(), 0);
605 }
606
607 #[test]
608 fn test_list_items_config_false_outside_list() {
609 let rule = MD031BlanksAroundFences::new(false);
611
612 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614 let warnings = rule.check(&ctx).unwrap();
615
616 assert_eq!(warnings.len(), 2);
618 assert!(warnings[0].message.contains("before"));
619 assert!(warnings[1].message.contains("after"));
620 }
621
622 #[test]
623 fn test_default_config_section() {
624 let rule = MD031BlanksAroundFences::default();
625 let config_section = rule.default_config_section();
626
627 assert!(config_section.is_some());
628 let (name, value) = config_section.unwrap();
629 assert_eq!(name, "MD031");
630
631 if let toml::Value::Table(table) = value {
633 assert!(table.contains_key("list-items"));
634 assert_eq!(table["list-items"], toml::Value::Boolean(true));
635 } else {
636 panic!("Expected TOML table");
637 }
638 }
639
640 #[test]
641 fn test_fix_list_items_config_false() {
642 let rule = MD031BlanksAroundFences::new(false);
644
645 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647 let fixed = rule.fix(&ctx).unwrap();
648
649 assert_eq!(fixed, content);
651 }
652
653 #[test]
654 fn test_fix_list_items_config_true() {
655 let rule = MD031BlanksAroundFences::new(true);
657
658 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
660 let fixed = rule.fix(&ctx).unwrap();
661
662 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
664 assert_eq!(fixed, expected);
665 }
666}