1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::LineIndex;
3use crate::utils::range_utils::calculate_line_range;
4use std::collections::HashSet;
5use toml;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rule_config_serde::RuleConfig;
9
10mod md012_config;
11use md012_config::MD012Config;
12
13#[derive(Debug, Clone, Default)]
18pub struct MD012NoMultipleBlanks {
19 config: MD012Config,
20}
21
22impl MD012NoMultipleBlanks {
23 pub fn new(maximum: usize) -> Self {
24 use crate::types::PositiveUsize;
25 Self {
26 config: MD012Config {
27 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
28 },
29 }
30 }
31
32 pub const fn from_config_struct(config: MD012Config) -> Self {
33 Self { config }
34 }
35
36 fn generate_excess_warnings(
38 &self,
39 blank_start: usize,
40 blank_count: usize,
41 lines: &[&str],
42 lines_to_check: &HashSet<usize>,
43 line_index: &LineIndex,
44 ) -> Vec<LintWarning> {
45 let mut warnings = Vec::new();
46
47 let location = if blank_start == 0 {
48 "at start of file"
49 } else {
50 "between content"
51 };
52
53 for i in self.config.maximum.get()..blank_count {
54 let excess_line_num = blank_start + i;
55 if lines_to_check.contains(&excess_line_num) {
56 let excess_line = excess_line_num + 1;
57 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
58 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
59 warnings.push(LintWarning {
60 rule_name: Some(self.name().to_string()),
61 severity: Severity::Warning,
62 message: format!("Multiple consecutive blank lines {location}"),
63 line: start_line,
64 column: start_col,
65 end_line,
66 end_column: end_col,
67 fix: Some(Fix {
68 range: {
69 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
70 let line_end = line_index
71 .get_line_start_byte(excess_line + 1)
72 .unwrap_or(line_start + 1);
73 line_start..line_end
74 },
75 replacement: String::new(),
76 }),
77 });
78 }
79 }
80
81 warnings
82 }
83}
84
85impl Rule for MD012NoMultipleBlanks {
86 fn name(&self) -> &'static str {
87 "MD012"
88 }
89
90 fn description(&self) -> &'static str {
91 "Multiple consecutive blank lines"
92 }
93
94 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95 let content = ctx.content;
96
97 if content.is_empty() {
99 return Ok(Vec::new());
100 }
101
102 let lines: Vec<&str> = content.lines().collect();
105 let has_potential_blanks = lines
106 .windows(2)
107 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
108
109 let ends_with_multiple_newlines = content.ends_with("\n\n");
112
113 if !has_potential_blanks && !ends_with_multiple_newlines {
114 return Ok(Vec::new());
115 }
116
117 let line_index = &ctx.line_index;
118
119 let mut warnings = Vec::new();
120
121 let mut blank_count = 0;
123 let mut blank_start = 0;
124 let mut last_line_num: Option<usize> = None;
125
126 let mut lines_to_check: HashSet<usize> = HashSet::new();
128
129 for filtered_line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
133 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
135
136 if let Some(last) = last_line_num
139 && line_num > last + 1
140 {
141 if blank_count > self.config.maximum.get() {
144 warnings.extend(self.generate_excess_warnings(
145 blank_start,
146 blank_count,
147 &lines,
148 &lines_to_check,
149 line_index,
150 ));
151 }
152 blank_count = 0;
153 lines_to_check.clear();
154 }
155 last_line_num = Some(line_num);
156
157 if line.trim().is_empty() {
158 if blank_count == 0 {
159 blank_start = line_num;
160 }
161 blank_count += 1;
162 if blank_count > self.config.maximum.get() {
164 lines_to_check.insert(line_num);
165 }
166 } else {
167 if blank_count > self.config.maximum.get() {
168 warnings.extend(self.generate_excess_warnings(
169 blank_start,
170 blank_count,
171 &lines,
172 &lines_to_check,
173 line_index,
174 ));
175 }
176 blank_count = 0;
177 lines_to_check.clear();
178 }
179 }
180
181 let last_line_is_blank = lines.last().is_some_and(|l| l.trim().is_empty());
188
189 if blank_count > 0 && last_line_is_blank {
193 let location = "at end of file";
194
195 let report_line = lines.len();
197
198 let fix_start = line_index
201 .get_line_start_byte(report_line - blank_count + 1)
202 .unwrap_or(0);
203 let fix_end = content.len();
204
205 warnings.push(LintWarning {
207 rule_name: Some(self.name().to_string()),
208 severity: Severity::Warning,
209 message: format!("Multiple consecutive blank lines {location}"),
210 line: report_line,
211 column: 1,
212 end_line: report_line,
213 end_column: 1,
214 fix: Some(Fix {
215 range: fix_start..fix_end,
216 replacement: if content.ends_with('\n') {
218 "\n".to_string()
219 } else {
220 String::new()
221 },
222 }),
223 });
224 }
225
226 Ok(warnings)
227 }
228
229 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
230 let content = ctx.content;
231
232 let mut result = Vec::new();
233 let mut blank_count = 0;
234
235 let mut in_code_block = false;
236 let mut code_block_blanks = Vec::new();
237 let mut in_front_matter = false;
238
239 for filtered_line in ctx.filtered_lines() {
241 let line = filtered_line.content;
242
243 if filtered_line.line_info.in_front_matter {
245 if !in_front_matter {
246 let allowed_blanks = blank_count.min(self.config.maximum.get());
248 if allowed_blanks > 0 {
249 result.extend(vec![""; allowed_blanks]);
250 }
251 blank_count = 0;
252 in_front_matter = true;
253 }
254 result.push(line);
255 continue;
256 } else if in_front_matter {
257 in_front_matter = false;
259 }
260
261 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
263 if !in_code_block {
265 let allowed_blanks = blank_count.min(self.config.maximum.get());
266 if allowed_blanks > 0 {
267 result.extend(vec![""; allowed_blanks]);
268 }
269 blank_count = 0;
270 } else {
271 result.append(&mut code_block_blanks);
273 }
274 in_code_block = !in_code_block;
275 result.push(line);
276 continue;
277 }
278
279 if in_code_block {
280 if line.trim().is_empty() {
281 code_block_blanks.push(line);
282 } else {
283 result.append(&mut code_block_blanks);
284 result.push(line);
285 }
286 } else if line.trim().is_empty() {
287 blank_count += 1;
288 } else {
289 let allowed_blanks = blank_count.min(self.config.maximum.get());
291 if allowed_blanks > 0 {
292 result.extend(vec![""; allowed_blanks]);
293 }
294 blank_count = 0;
295 result.push(line);
296 }
297 }
298
299 let mut output = result.join("\n");
303 if content.ends_with('\n') {
304 output.push('\n');
305 }
306
307 Ok(output)
308 }
309
310 fn as_any(&self) -> &dyn std::any::Any {
311 self
312 }
313
314 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
315 ctx.content.is_empty() || !ctx.has_char('\n')
317 }
318
319 fn default_config_section(&self) -> Option<(String, toml::Value)> {
320 let default_config = MD012Config::default();
321 let json_value = serde_json::to_value(&default_config).ok()?;
322 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
323
324 if let toml::Value::Table(table) = toml_value {
325 if !table.is_empty() {
326 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
327 } else {
328 None
329 }
330 } else {
331 None
332 }
333 }
334
335 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
336 where
337 Self: Sized,
338 {
339 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
340 Box::new(Self::from_config_struct(rule_config))
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::lint_context::LintContext;
348
349 #[test]
350 fn test_single_blank_line_allowed() {
351 let rule = MD012NoMultipleBlanks::default();
352 let content = "Line 1\n\nLine 2\n\nLine 3";
353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354 let result = rule.check(&ctx).unwrap();
355 assert!(result.is_empty());
356 }
357
358 #[test]
359 fn test_multiple_blank_lines_flagged() {
360 let rule = MD012NoMultipleBlanks::default();
361 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
366 assert_eq!(result[1].line, 6);
367 assert_eq!(result[2].line, 7);
368 }
369
370 #[test]
371 fn test_custom_maximum() {
372 let rule = MD012NoMultipleBlanks::new(2);
373 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
375 let result = rule.check(&ctx).unwrap();
376 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
378 }
379
380 #[test]
381 fn test_fix_multiple_blank_lines() {
382 let rule = MD012NoMultipleBlanks::default();
383 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let fixed = rule.fix(&ctx).unwrap();
386 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
387 }
388
389 #[test]
390 fn test_blank_lines_in_code_block() {
391 let rule = MD012NoMultipleBlanks::default();
392 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
393 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394 let result = rule.check(&ctx).unwrap();
395 assert!(result.is_empty()); }
397
398 #[test]
399 fn test_fix_preserves_code_block_blanks() {
400 let rule = MD012NoMultipleBlanks::default();
401 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
403 let fixed = rule.fix(&ctx).unwrap();
404 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
405 }
406
407 #[test]
408 fn test_blank_lines_in_front_matter() {
409 let rule = MD012NoMultipleBlanks::default();
410 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
411 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
412 let result = rule.check(&ctx).unwrap();
413 assert!(result.is_empty()); }
415
416 #[test]
417 fn test_blank_lines_at_start() {
418 let rule = MD012NoMultipleBlanks::default();
419 let content = "\n\n\nContent";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422 assert_eq!(result.len(), 2);
423 assert!(result[0].message.contains("at start of file"));
424 }
425
426 #[test]
427 fn test_blank_lines_at_end() {
428 let rule = MD012NoMultipleBlanks::default();
429 let content = "Content\n\n\n";
430 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
431 let result = rule.check(&ctx).unwrap();
432 assert_eq!(result.len(), 1);
433 assert!(result[0].message.contains("at end of file"));
434 }
435
436 #[test]
437 fn test_single_blank_at_eof_flagged() {
438 let rule = MD012NoMultipleBlanks::default();
440 let content = "Content\n\n";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
442 let result = rule.check(&ctx).unwrap();
443 assert_eq!(result.len(), 1);
444 assert!(result[0].message.contains("at end of file"));
445 }
446
447 #[test]
448 fn test_whitespace_only_lines() {
449 let rule = MD012NoMultipleBlanks::default();
450 let content = "Line 1\n \n\t\nLine 2";
451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
452 let result = rule.check(&ctx).unwrap();
453 assert_eq!(result.len(), 1); }
455
456 #[test]
457 fn test_indented_code_blocks() {
458 let rule = MD012NoMultipleBlanks::default();
460 let content = "Text\n\n code\n \n \n more code\n\nText";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
464 }
465
466 #[test]
467 fn test_blanks_in_indented_code_block() {
468 let content = " code line 1\n\n\n code line 2\n";
470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471 let rule = MD012NoMultipleBlanks::default();
472 let warnings = rule.check(&ctx).unwrap();
473 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
474 }
475
476 #[test]
477 fn test_blanks_in_indented_code_block_with_heading() {
478 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481 let rule = MD012NoMultipleBlanks::default();
482 let warnings = rule.check(&ctx).unwrap();
483 assert!(
484 warnings.is_empty(),
485 "Should not flag blanks in indented code after heading"
486 );
487 }
488
489 #[test]
490 fn test_blanks_after_indented_code_block_flagged() {
491 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let rule = MD012NoMultipleBlanks::default();
495 let warnings = rule.check(&ctx).unwrap();
496 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
498 }
499
500 #[test]
501 fn test_fix_with_final_newline() {
502 let rule = MD012NoMultipleBlanks::default();
503 let content = "Line 1\n\n\nLine 2\n";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
505 let fixed = rule.fix(&ctx).unwrap();
506 assert_eq!(fixed, "Line 1\n\nLine 2\n");
507 assert!(fixed.ends_with('\n'));
508 }
509
510 #[test]
511 fn test_empty_content() {
512 let rule = MD012NoMultipleBlanks::default();
513 let content = "";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515 let result = rule.check(&ctx).unwrap();
516 assert!(result.is_empty());
517 }
518
519 #[test]
520 fn test_nested_code_blocks() {
521 let rule = MD012NoMultipleBlanks::default();
522 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert!(result.is_empty());
526 }
527
528 #[test]
529 fn test_unclosed_code_block() {
530 let rule = MD012NoMultipleBlanks::default();
531 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
532 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533 let result = rule.check(&ctx).unwrap();
534 assert!(result.is_empty()); }
536
537 #[test]
538 fn test_mixed_fence_styles() {
539 let rule = MD012NoMultipleBlanks::default();
540 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert!(result.is_empty()); }
545
546 #[test]
547 fn test_config_from_toml() {
548 let mut config = crate::config::Config::default();
549 let mut rule_config = crate::config::RuleConfig::default();
550 rule_config
551 .values
552 .insert("maximum".to_string(), toml::Value::Integer(3));
553 config.rules.insert("MD012".to_string(), rule_config);
554
555 let rule = MD012NoMultipleBlanks::from_config(&config);
556 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert!(result.is_empty()); }
561
562 #[test]
563 fn test_blank_lines_between_sections() {
564 let rule = MD012NoMultipleBlanks::default();
565 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
567 let result = rule.check(&ctx).unwrap();
568 assert_eq!(result.len(), 1);
569 assert_eq!(result[0].line, 5);
570 }
571
572 #[test]
573 fn test_fix_preserves_indented_code() {
574 let rule = MD012NoMultipleBlanks::default();
575 let content = "Text\n\n\n code\n \n more code\n\n\nText";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let fixed = rule.fix(&ctx).unwrap();
578 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
580 }
581
582 #[test]
583 fn test_edge_case_only_blanks() {
584 let rule = MD012NoMultipleBlanks::default();
585 let content = "\n\n\n";
586 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
587 let result = rule.check(&ctx).unwrap();
588 assert_eq!(result.len(), 1);
590 assert!(result[0].message.contains("at end of file"));
591 }
592
593 #[test]
596 fn test_blanks_after_fenced_code_block_mid_document() {
597 let rule = MD012NoMultipleBlanks::default();
599 let content = "## Input\n\n```javascript\ncode\n```\n\n\n## Error\n";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602 assert_eq!(result.len(), 1, "Should detect blanks after code block");
604 assert_eq!(result[0].line, 7, "Warning should be on line 7 (second blank)");
605 assert!(result[0].message.contains("between content"));
606 }
607
608 #[test]
609 fn test_blanks_after_code_block_at_eof() {
610 let rule = MD012NoMultipleBlanks::default();
612 let content = "# Heading\n\n```\ncode\n```\n\n\n";
613 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
614 let result = rule.check(&ctx).unwrap();
615 assert_eq!(result.len(), 1, "Should detect trailing blanks after code block");
617 assert!(result[0].message.contains("at end of file"));
618 }
619
620 #[test]
621 fn test_single_blank_after_code_block_allowed() {
622 let rule = MD012NoMultipleBlanks::default();
624 let content = "## Input\n\n```\ncode\n```\n\n## Output\n";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
626 let result = rule.check(&ctx).unwrap();
627 assert!(result.is_empty(), "Single blank after code block should be allowed");
628 }
629
630 #[test]
631 fn test_multiple_code_blocks_with_blanks() {
632 let rule = MD012NoMultipleBlanks::default();
634 let content = "```\ncode1\n```\n\n\n```\ncode2\n```\n\n\nEnd\n";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637 assert_eq!(result.len(), 2, "Should detect blanks after both code blocks");
639 }
640
641 #[test]
642 fn test_whitespace_only_lines_after_code_block_at_eof() {
643 let rule = MD012NoMultipleBlanks::default();
646 let content = "```\ncode\n```\n \n \n";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert_eq!(result.len(), 1, "Should detect whitespace-only trailing blanks");
650 assert!(result[0].message.contains("at end of file"));
651 }
652}