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::{LineIndex, 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 should_require_blank_line(&self, line_index: usize, lines: &[&str]) -> bool {
101 if self.config.list_items {
102 true
104 } else {
105 !self.is_in_list(line_index, lines)
107 }
108 }
109}
110
111impl Rule for MD031BlanksAroundFences {
112 fn name(&self) -> &'static str {
113 "MD031"
114 }
115
116 fn description(&self) -> &'static str {
117 "Fenced code blocks should be surrounded by blank lines"
118 }
119
120 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
121 let content = ctx.content;
122 let line_index = LineIndex::new(content.to_string());
123
124 let mut warnings = Vec::new();
125 let lines: Vec<&str> = content.lines().collect();
126
127 let mut in_code_block = false;
128 let mut current_fence_marker: Option<String> = None;
129 let mut in_admonition = false;
130 let mut admonition_indent = 0;
131 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
132 let mut i = 0;
133
134 while i < lines.len() {
135 let line = lines[i];
136 let trimmed = line.trim_start();
137
138 if is_mkdocs && mkdocs_admonitions::is_admonition_start(line) {
140 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
142 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
143
144 warnings.push(LintWarning {
145 rule_name: Some(self.name().to_string()),
146 line: start_line,
147 column: start_col,
148 end_line,
149 end_column: end_col,
150 message: "No blank line before admonition block".to_string(),
151 severity: Severity::Warning,
152 fix: Some(Fix {
153 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
154 replacement: "\n".to_string(),
155 }),
156 });
157 }
158
159 in_admonition = true;
160 admonition_indent = mkdocs_admonitions::get_admonition_indent(line).unwrap_or(0);
161 i += 1;
162 continue;
163 }
164
165 if in_admonition {
167 if !line.trim().is_empty() && !mkdocs_admonitions::is_admonition_content(line, admonition_indent) {
168 in_admonition = false;
170
171 if !Self::is_empty_line(line) && self.should_require_blank_line(i - 1, &lines) {
173 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
174
175 warnings.push(LintWarning {
176 rule_name: Some(self.name().to_string()),
177 line: start_line,
178 column: start_col,
179 end_line,
180 end_column: end_col,
181 message: "No blank line after admonition block".to_string(),
182 severity: Severity::Warning,
183 fix: Some(Fix {
184 range: line_index.line_col_to_byte_range_with_length(i, 0, 0),
185 replacement: "\n".to_string(),
186 }),
187 });
188 }
189
190 admonition_indent = 0;
191 } else {
193 i += 1;
195 continue;
196 }
197 }
198
199 let fence_marker = if trimmed.starts_with("```") {
201 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
202 if backtick_count >= 3 {
203 Some("`".repeat(backtick_count))
204 } else {
205 None
206 }
207 } else if trimmed.starts_with("~~~") {
208 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
209 if tilde_count >= 3 {
210 Some("~".repeat(tilde_count))
211 } else {
212 None
213 }
214 } else {
215 None
216 };
217
218 if let Some(fence_marker) = fence_marker {
219 if in_code_block {
220 if let Some(ref current_marker) = current_fence_marker {
222 let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
227 || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
228
229 if same_type
230 && fence_marker.len() >= current_marker.len()
231 && trimmed[fence_marker.len()..].trim().is_empty()
232 {
233 in_code_block = false;
235 current_fence_marker = None;
236
237 if i + 1 < lines.len()
240 && !Self::is_empty_line(lines[i + 1])
241 && !is_kramdown_block_attribute(lines[i + 1])
242 && self.should_require_blank_line(i, &lines)
243 {
244 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
245
246 warnings.push(LintWarning {
247 rule_name: Some(self.name().to_string()),
248 line: start_line,
249 column: start_col,
250 end_line,
251 end_column: end_col,
252 message: "No blank line after fenced code block".to_string(),
253 severity: Severity::Warning,
254 fix: Some(Fix {
255 range: line_index.line_col_to_byte_range_with_length(
256 i + 1,
257 lines[i].len() + 1,
258 0,
259 ),
260 replacement: "\n".to_string(),
261 }),
262 });
263 }
264 }
265 }
267 } else {
268 in_code_block = true;
270 current_fence_marker = Some(fence_marker);
271
272 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
274 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
275
276 warnings.push(LintWarning {
277 rule_name: Some(self.name().to_string()),
278 line: start_line,
279 column: start_col,
280 end_line,
281 end_column: end_col,
282 message: "No blank line before fenced code block".to_string(),
283 severity: Severity::Warning,
284 fix: Some(Fix {
285 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
286 replacement: "\n".to_string(),
287 }),
288 });
289 }
290 }
291 }
292 i += 1;
294 }
295
296 Ok(warnings)
297 }
298
299 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300 let content = ctx.content;
301 let _line_index = LineIndex::new(content.to_string());
302
303 let had_trailing_newline = content.ends_with('\n');
305
306 let lines: Vec<&str> = content.lines().collect();
307
308 let mut result = Vec::new();
309 let mut in_code_block = false;
310 let mut current_fence_marker: Option<String> = None;
311
312 let mut i = 0;
313
314 while i < lines.len() {
315 let line = lines[i];
316 let trimmed = line.trim_start();
317
318 let fence_marker = if trimmed.starts_with("```") {
320 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
321 if backtick_count >= 3 {
322 Some("`".repeat(backtick_count))
323 } else {
324 None
325 }
326 } else if trimmed.starts_with("~~~") {
327 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
328 if tilde_count >= 3 {
329 Some("~".repeat(tilde_count))
330 } else {
331 None
332 }
333 } else {
334 None
335 };
336
337 if let Some(fence_marker) = fence_marker {
338 if in_code_block {
339 if let Some(ref current_marker) = current_fence_marker {
341 if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
342 result.push(line.to_string());
344 in_code_block = false;
345 current_fence_marker = None;
346
347 if i + 1 < lines.len()
350 && !Self::is_empty_line(lines[i + 1])
351 && !is_kramdown_block_attribute(lines[i + 1])
352 && self.should_require_blank_line(i, &lines)
353 {
354 result.push(String::new());
355 }
356 } else {
357 result.push(line.to_string());
359 }
360 } else {
361 result.push(line.to_string());
363 }
364 } else {
365 in_code_block = true;
367 current_fence_marker = Some(fence_marker);
368
369 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
371 result.push(String::new());
372 }
373
374 result.push(line.to_string());
376 }
377 } else if in_code_block {
378 result.push(line.to_string());
380 } else {
381 result.push(line.to_string());
383 }
384 i += 1;
385 }
386
387 let fixed = result.join("\n");
388
389 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
391 format!("{fixed}\n")
392 } else {
393 fixed
394 };
395
396 Ok(final_result)
397 }
398
399 fn category(&self) -> RuleCategory {
401 RuleCategory::CodeBlock
402 }
403
404 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
406 ctx.content.is_empty() || (!ctx.likely_has_code() && !ctx.has_char('~'))
408 }
409
410 fn as_any(&self) -> &dyn std::any::Any {
411 self
412 }
413
414 fn default_config_section(&self) -> Option<(String, toml::Value)> {
415 let default_config = MD031Config::default();
416 let json_value = serde_json::to_value(&default_config).ok()?;
417 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
418 if let toml::Value::Table(table) = toml_value {
419 if !table.is_empty() {
420 Some((MD031Config::RULE_NAME.to_string(), toml::Value::Table(table)))
421 } else {
422 None
423 }
424 } else {
425 None
426 }
427 }
428
429 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
430 where
431 Self: Sized,
432 {
433 let rule_config = crate::rule_config_serde::load_rule_config::<MD031Config>(config);
434 Box::new(MD031BlanksAroundFences::from_config_struct(rule_config))
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::lint_context::LintContext;
442
443 #[test]
444 fn test_basic_functionality() {
445 let rule = MD031BlanksAroundFences::default();
446
447 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
450 let warnings = rule.check(&ctx).unwrap();
451 assert!(
452 warnings.is_empty(),
453 "Expected no warnings for properly formatted code blocks"
454 );
455
456 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let warnings = rule.check(&ctx).unwrap();
460 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
461 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
462 assert!(
463 warnings[0].message.contains("before"),
464 "Warning should be about blank line before"
465 );
466
467 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470 let warnings = rule.check(&ctx).unwrap();
471 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
472 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
473 assert!(
474 warnings[0].message.contains("after"),
475 "Warning should be about blank line after"
476 );
477
478 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let warnings = rule.check(&ctx).unwrap();
482 assert_eq!(
483 warnings.len(),
484 2,
485 "Expected 2 warnings for missing blank lines before and after"
486 );
487 }
488
489 #[test]
490 fn test_nested_code_blocks() {
491 let rule = MD031BlanksAroundFences::default();
492
493 let content = r#"````markdown
495```
496content
497```
498````"#;
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500 let warnings = rule.check(&ctx).unwrap();
501 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
502
503 let fixed = rule.fix(&ctx).unwrap();
505 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
506 }
507
508 #[test]
509 fn test_nested_code_blocks_complex() {
510 let rule = MD031BlanksAroundFences::default();
511
512 let content = r#"# Documentation
514
515## Examples
516
517````markdown
518```python
519def hello():
520 print("Hello, world!")
521```
522
523```javascript
524console.log("Hello, world!");
525```
526````
527
528More text here."#;
529
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
531 let warnings = rule.check(&ctx).unwrap();
532 assert_eq!(
533 warnings.len(),
534 0,
535 "Should not flag any issues in properly formatted nested code blocks"
536 );
537
538 let content_5 = r#"`````markdown
540````python
541```bash
542echo "nested"
543```
544````
545`````"#;
546
547 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
548 let warnings_5 = rule.check(&ctx_5).unwrap();
549 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
550 }
551
552 #[test]
553 fn test_fix_preserves_trailing_newline() {
554 let rule = MD031BlanksAroundFences::default();
555
556 let content = "Some text\n```\ncode\n```\nMore text\n";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
559 let fixed = rule.fix(&ctx).unwrap();
560
561 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
563 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
564 }
565
566 #[test]
567 fn test_fix_preserves_no_trailing_newline() {
568 let rule = MD031BlanksAroundFences::default();
569
570 let content = "Some text\n```\ncode\n```\nMore text";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let fixed = rule.fix(&ctx).unwrap();
574
575 assert!(
577 !fixed.ends_with('\n'),
578 "Fix should not add trailing newline if original didn't have one"
579 );
580 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
581 }
582
583 #[test]
584 fn test_list_items_config_true() {
585 let rule = MD031BlanksAroundFences::new(true);
587
588 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
590 let warnings = rule.check(&ctx).unwrap();
591
592 assert_eq!(warnings.len(), 2);
594 assert!(warnings[0].message.contains("before"));
595 assert!(warnings[1].message.contains("after"));
596 }
597
598 #[test]
599 fn test_list_items_config_false() {
600 let rule = MD031BlanksAroundFences::new(false);
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);
605 let warnings = rule.check(&ctx).unwrap();
606
607 assert_eq!(warnings.len(), 0);
609 }
610
611 #[test]
612 fn test_list_items_config_false_outside_list() {
613 let rule = MD031BlanksAroundFences::new(false);
615
616 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
617 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
618 let warnings = rule.check(&ctx).unwrap();
619
620 assert_eq!(warnings.len(), 2);
622 assert!(warnings[0].message.contains("before"));
623 assert!(warnings[1].message.contains("after"));
624 }
625
626 #[test]
627 fn test_default_config_section() {
628 let rule = MD031BlanksAroundFences::default();
629 let config_section = rule.default_config_section();
630
631 assert!(config_section.is_some());
632 let (name, value) = config_section.unwrap();
633 assert_eq!(name, "MD031");
634
635 if let toml::Value::Table(table) = value {
637 assert!(table.contains_key("list-items"));
638 assert_eq!(table["list-items"], toml::Value::Boolean(true));
639 } else {
640 panic!("Expected TOML table");
641 }
642 }
643
644 #[test]
645 fn test_fix_list_items_config_false() {
646 let rule = MD031BlanksAroundFences::new(false);
648
649 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
651 let fixed = rule.fix(&ctx).unwrap();
652
653 assert_eq!(fixed, content);
655 }
656
657 #[test]
658 fn test_fix_list_items_config_true() {
659 let rule = MD031BlanksAroundFences::new(true);
661
662 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664 let fixed = rule.fix(&ctx).unwrap();
665
666 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
668 assert_eq!(fixed, expected);
669 }
670}