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::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 i = 0;
130
131 while i < lines.len() {
132 let line = lines[i];
133 let trimmed = line.trim_start();
134
135 let fence_marker = if trimmed.starts_with("```") {
137 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
138 if backtick_count >= 3 {
139 Some("`".repeat(backtick_count))
140 } else {
141 None
142 }
143 } else if trimmed.starts_with("~~~") {
144 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
145 if tilde_count >= 3 {
146 Some("~".repeat(tilde_count))
147 } else {
148 None
149 }
150 } else {
151 None
152 };
153
154 if let Some(fence_marker) = fence_marker {
155 if in_code_block {
156 if let Some(ref current_marker) = current_fence_marker {
158 let same_type = (current_marker.starts_with('`') && fence_marker.starts_with('`'))
163 || (current_marker.starts_with('~') && fence_marker.starts_with('~'));
164
165 if same_type
166 && fence_marker.len() >= current_marker.len()
167 && trimmed[fence_marker.len()..].trim().is_empty()
168 {
169 in_code_block = false;
171 current_fence_marker = None;
172
173 if i + 1 < lines.len()
176 && !Self::is_empty_line(lines[i + 1])
177 && !is_kramdown_block_attribute(lines[i + 1])
178 && self.should_require_blank_line(i, &lines)
179 {
180 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
181
182 warnings.push(LintWarning {
183 rule_name: Some(self.name()),
184 line: start_line,
185 column: start_col,
186 end_line,
187 end_column: end_col,
188 message: "No blank line after fenced code block".to_string(),
189 severity: Severity::Warning,
190 fix: Some(Fix {
191 range: line_index.line_col_to_byte_range_with_length(
192 i + 1,
193 lines[i].len() + 1,
194 0,
195 ),
196 replacement: "\n".to_string(),
197 }),
198 });
199 }
200 }
201 }
203 } else {
204 in_code_block = true;
206 current_fence_marker = Some(fence_marker);
207
208 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
210 let (start_line, start_col, end_line, end_col) = calculate_line_range(i + 1, lines[i]);
211
212 warnings.push(LintWarning {
213 rule_name: Some(self.name()),
214 line: start_line,
215 column: start_col,
216 end_line,
217 end_column: end_col,
218 message: "No blank line before fenced code block".to_string(),
219 severity: Severity::Warning,
220 fix: Some(Fix {
221 range: line_index.line_col_to_byte_range_with_length(i + 1, 1, 0),
222 replacement: "\n".to_string(),
223 }),
224 });
225 }
226 }
227 }
228 i += 1;
230 }
231
232 Ok(warnings)
233 }
234
235 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
236 let content = ctx.content;
237 let _line_index = LineIndex::new(content.to_string());
238
239 let had_trailing_newline = content.ends_with('\n');
241
242 let lines: Vec<&str> = content.lines().collect();
243
244 let mut result = Vec::new();
245 let mut in_code_block = false;
246 let mut current_fence_marker: Option<String> = None;
247
248 let mut i = 0;
249
250 while i < lines.len() {
251 let line = lines[i];
252 let trimmed = line.trim_start();
253
254 let fence_marker = if trimmed.starts_with("```") {
256 let backtick_count = trimmed.chars().take_while(|&c| c == '`').count();
257 if backtick_count >= 3 {
258 Some("`".repeat(backtick_count))
259 } else {
260 None
261 }
262 } else if trimmed.starts_with("~~~") {
263 let tilde_count = trimmed.chars().take_while(|&c| c == '~').count();
264 if tilde_count >= 3 {
265 Some("~".repeat(tilde_count))
266 } else {
267 None
268 }
269 } else {
270 None
271 };
272
273 if let Some(fence_marker) = fence_marker {
274 if in_code_block {
275 if let Some(ref current_marker) = current_fence_marker {
277 if trimmed.starts_with(current_marker) && trimmed[current_marker.len()..].trim().is_empty() {
278 result.push(line.to_string());
280 in_code_block = false;
281 current_fence_marker = None;
282
283 if i + 1 < lines.len()
286 && !Self::is_empty_line(lines[i + 1])
287 && !is_kramdown_block_attribute(lines[i + 1])
288 && self.should_require_blank_line(i, &lines)
289 {
290 result.push(String::new());
291 }
292 } else {
293 result.push(line.to_string());
295 }
296 } else {
297 result.push(line.to_string());
299 }
300 } else {
301 in_code_block = true;
303 current_fence_marker = Some(fence_marker);
304
305 if i > 0 && !Self::is_empty_line(lines[i - 1]) && self.should_require_blank_line(i, &lines) {
307 result.push(String::new());
308 }
309
310 result.push(line.to_string());
312 }
313 } else if in_code_block {
314 result.push(line.to_string());
316 } else {
317 result.push(line.to_string());
319 }
320 i += 1;
321 }
322
323 let fixed = result.join("\n");
324
325 let final_result = if had_trailing_newline && !fixed.ends_with('\n') {
327 format!("{fixed}\n")
328 } else {
329 fixed
330 };
331
332 Ok(final_result)
333 }
334
335 fn category(&self) -> RuleCategory {
337 RuleCategory::CodeBlock
338 }
339
340 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
342 let content = ctx.content;
343 content.is_empty() || (!content.contains("```") && !content.contains("~~~"))
344 }
345
346 fn check_with_structure(
348 &self,
349 ctx: &crate::lint_context::LintContext,
350 structure: &DocumentStructure,
351 ) -> LintResult {
352 let content = ctx.content;
353 if !self.has_relevant_elements(ctx, structure) {
355 return Ok(Vec::new());
356 }
357
358 let line_index = LineIndex::new(content.to_string());
359 let mut warnings = Vec::new();
360 let lines: Vec<&str> = content.lines().collect();
361
362 for &start_line in &structure.fenced_code_block_starts {
364 let line_num = start_line;
365
366 if line_num > 1
368 && !Self::is_empty_line(lines[line_num - 2])
369 && self.should_require_blank_line(line_num - 1, &lines)
370 {
371 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, lines[line_num - 1]);
373
374 warnings.push(LintWarning {
375 rule_name: Some(self.name()),
376 line: start_line,
377 column: start_col,
378 end_line,
379 end_column: end_col,
380 message: "No blank line before fenced code block".to_string(),
381 severity: Severity::Warning,
382 fix: Some(Fix {
383 range: line_index.line_col_to_byte_range_with_length(line_num, 1, 0),
384 replacement: "\n".to_string(),
385 }),
386 });
387 }
388 }
389
390 for &end_line in &structure.fenced_code_block_ends {
391 let line_num = end_line;
392
393 if line_num < lines.len()
395 && !Self::is_empty_line(lines[line_num])
396 && self.should_require_blank_line(line_num - 1, &lines)
397 {
398 let (start_line_fence, start_col_fence, end_line_fence, end_col_fence) =
400 calculate_line_range(line_num, lines[line_num - 1]);
401
402 warnings.push(LintWarning {
403 rule_name: Some(self.name()),
404 line: start_line_fence,
405 column: start_col_fence,
406 end_line: end_line_fence,
407 end_column: end_col_fence,
408 message: "No blank line after fenced code block".to_string(),
409 severity: Severity::Warning,
410 fix: Some(Fix {
411 range: line_index.line_col_to_byte_range_with_length(
412 line_num,
413 lines[line_num - 1].len() + 1,
414 0,
415 ),
416 replacement: "\n".to_string(),
417 }),
418 });
419 }
420 }
421
422 Ok(warnings)
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
453impl DocumentStructureExtensions for MD031BlanksAroundFences {
454 fn has_relevant_elements(
455 &self,
456 _ctx: &crate::lint_context::LintContext,
457 doc_structure: &DocumentStructure,
458 ) -> bool {
459 !doc_structure.fenced_code_block_starts.is_empty() || !doc_structure.fenced_code_block_ends.is_empty()
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::lint_context::LintContext;
467 use crate::utils::document_structure::document_structure_from_str;
468
469 #[test]
470 fn test_with_document_structure() {
471 let rule = MD031BlanksAroundFences::default();
472
473 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\n\nSome text here.";
475 let structure = document_structure_from_str(content);
476 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
477 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
478 assert!(
479 warnings.is_empty(),
480 "Expected no warnings for properly formatted code blocks"
481 );
482
483 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\n\nSome text here.";
485 let structure = document_structure_from_str(content);
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
488 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line before");
489 assert_eq!(warnings[0].line, 2, "Warning should be on line 2");
490 assert!(
491 warnings[0].message.contains("before"),
492 "Warning should be about blank line before"
493 );
494
495 let content = "# Test Code Blocks\n\n```rust\nfn main() {}\n```\nSome text here.";
497 let structure = document_structure_from_str(content);
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
499 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
500 assert_eq!(warnings.len(), 1, "Expected 1 warning for missing blank line after");
501 assert_eq!(warnings[0].line, 5, "Warning should be on line 5");
502 assert!(
503 warnings[0].message.contains("after"),
504 "Warning should be about blank line after"
505 );
506
507 let content = "# Test Code Blocks\n```rust\nfn main() {}\n```\nSome text here.";
509 let structure = document_structure_from_str(content);
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511 let warnings = rule.check_with_structure(&ctx, &structure).unwrap();
512 assert_eq!(
513 warnings.len(),
514 2,
515 "Expected 2 warnings for missing blank lines before and after"
516 );
517 }
518
519 #[test]
520 fn test_nested_code_blocks() {
521 let rule = MD031BlanksAroundFences::default();
522
523 let content = r#"````markdown
525```
526content
527```
528````"#;
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530 let warnings = rule.check(&ctx).unwrap();
531 assert_eq!(warnings.len(), 0, "Should not flag nested code blocks");
532
533 let fixed = rule.fix(&ctx).unwrap();
535 assert_eq!(fixed, content, "Fix should not modify nested code blocks");
536 }
537
538 #[test]
539 fn test_nested_code_blocks_complex() {
540 let rule = MD031BlanksAroundFences::default();
541
542 let content = r#"# Documentation
544
545## Examples
546
547````markdown
548```python
549def hello():
550 print("Hello, world!")
551```
552
553```javascript
554console.log("Hello, world!");
555```
556````
557
558More text here."#;
559
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
561 let warnings = rule.check(&ctx).unwrap();
562 assert_eq!(
563 warnings.len(),
564 0,
565 "Should not flag any issues in properly formatted nested code blocks"
566 );
567
568 let content_5 = r#"`````markdown
570````python
571```bash
572echo "nested"
573```
574````
575`````"#;
576
577 let ctx_5 = LintContext::new(content_5, crate::config::MarkdownFlavor::Standard);
578 let warnings_5 = rule.check(&ctx_5).unwrap();
579 assert_eq!(warnings_5.len(), 0, "Should handle deeply nested code blocks");
580 }
581
582 #[test]
583 fn test_fix_preserves_trailing_newline() {
584 let rule = MD031BlanksAroundFences::default();
585
586 let content = "Some text\n```\ncode\n```\nMore text\n";
588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
589 let fixed = rule.fix(&ctx).unwrap();
590
591 assert!(fixed.ends_with('\n'), "Fix should preserve trailing newline");
593 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text\n");
594 }
595
596 #[test]
597 fn test_fix_preserves_no_trailing_newline() {
598 let rule = MD031BlanksAroundFences::default();
599
600 let content = "Some text\n```\ncode\n```\nMore text";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
603 let fixed = rule.fix(&ctx).unwrap();
604
605 assert!(
607 !fixed.ends_with('\n'),
608 "Fix should not add trailing newline if original didn't have one"
609 );
610 assert_eq!(fixed, "Some text\n\n```\ncode\n```\n\nMore text");
611 }
612
613 #[test]
614 fn test_list_items_config_true() {
615 let rule = MD031BlanksAroundFences::new(true);
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);
620 let warnings = rule.check(&ctx).unwrap();
621
622 assert_eq!(warnings.len(), 2);
624 assert!(warnings[0].message.contains("before"));
625 assert!(warnings[1].message.contains("after"));
626 }
627
628 #[test]
629 fn test_list_items_config_false() {
630 let rule = MD031BlanksAroundFences::new(false);
632
633 let content = "1. First item\n ```python\n code_in_list()\n ```\n2. Second item";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let warnings = rule.check(&ctx).unwrap();
636
637 assert_eq!(warnings.len(), 0);
639 }
640
641 #[test]
642 fn test_list_items_config_false_outside_list() {
643 let rule = MD031BlanksAroundFences::new(false);
645
646 let content = "Some text\n```python\ncode_outside_list()\n```\nMore text";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let warnings = rule.check(&ctx).unwrap();
649
650 assert_eq!(warnings.len(), 2);
652 assert!(warnings[0].message.contains("before"));
653 assert!(warnings[1].message.contains("after"));
654 }
655
656 #[test]
657 fn test_default_config_section() {
658 let rule = MD031BlanksAroundFences::default();
659 let config_section = rule.default_config_section();
660
661 assert!(config_section.is_some());
662 let (name, value) = config_section.unwrap();
663 assert_eq!(name, "MD031");
664
665 if let toml::Value::Table(table) = value {
667 assert!(table.contains_key("list-items"));
668 assert_eq!(table["list-items"], toml::Value::Boolean(true));
669 } else {
670 panic!("Expected TOML table");
671 }
672 }
673
674 #[test]
675 fn test_fix_list_items_config_false() {
676 let rule = MD031BlanksAroundFences::new(false);
678
679 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
680 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
681 let fixed = rule.fix(&ctx).unwrap();
682
683 assert_eq!(fixed, content);
685 }
686
687 #[test]
688 fn test_fix_list_items_config_true() {
689 let rule = MD031BlanksAroundFences::new(true);
691
692 let content = "1. First item\n ```python\n code()\n ```\n2. Second item";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
694 let fixed = rule.fix(&ctx).unwrap();
695
696 let expected = "1. First item\n\n ```python\n code()\n ```\n\n2. Second item";
698 assert_eq!(fixed, expected);
699 }
700}