1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::range_utils::calculate_line_range;
3use std::collections::HashSet;
4use toml;
5
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
7use crate::rule_config_serde::RuleConfig;
8
9mod md012_config;
10use md012_config::MD012Config;
11
12#[derive(Debug, Clone, Default)]
17pub struct MD012NoMultipleBlanks {
18 config: MD012Config,
19}
20
21impl MD012NoMultipleBlanks {
22 pub fn new(maximum: usize) -> Self {
23 Self {
24 config: MD012Config { maximum },
25 }
26 }
27
28 pub fn from_config_struct(config: MD012Config) -> Self {
29 Self { config }
30 }
31}
32
33impl Rule for MD012NoMultipleBlanks {
34 fn name(&self) -> &'static str {
35 "MD012"
36 }
37
38 fn description(&self) -> &'static str {
39 "Multiple consecutive blank lines"
40 }
41
42 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
43 let content = ctx.content;
44
45 if content.is_empty() {
47 return Ok(Vec::new());
48 }
49
50 let lines: Vec<&str> = content.lines().collect();
53 let has_potential_blanks = lines
54 .windows(2)
55 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
56
57 let ends_with_multiple_newlines = content.ends_with("\n\n");
60
61 if !has_potential_blanks && !ends_with_multiple_newlines {
62 return Ok(Vec::new());
63 }
64
65 let line_index = &ctx.line_index;
66
67 let mut warnings = Vec::new();
68
69 let mut blank_count = 0;
71 let mut blank_start = 0;
72 let mut in_code_block = false;
73 let mut code_fence_marker = "";
74
75 let mut lines_to_check: HashSet<usize> = HashSet::new();
77
78 for filtered_line in ctx.filtered_lines().skip_front_matter() {
80 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
82 let trimmed = line.trim_start();
83
84 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
86 if blank_count > self.config.maximum {
88 let location = if blank_start == 0 {
89 "at start of file"
90 } else {
91 "between content"
92 };
93 for i in self.config.maximum..blank_count {
94 let excess_line_num = blank_start + i;
95 if lines_to_check.contains(&excess_line_num) {
96 let excess_line = excess_line_num + 1;
97 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
98 let (start_line, start_col, end_line, end_col) =
99 calculate_line_range(excess_line, excess_line_content);
100 warnings.push(LintWarning {
101 rule_name: Some(self.name().to_string()),
102 severity: Severity::Warning,
103 message: format!("Multiple consecutive blank lines {location}"),
104 line: start_line,
105 column: start_col,
106 end_line,
107 end_column: end_col,
108 fix: Some(Fix {
109 range: {
110 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
111 let line_end = line_index
112 .get_line_start_byte(excess_line + 1)
113 .unwrap_or(line_start + 1);
114 line_start..line_end
115 },
116 replacement: String::new(),
117 }),
118 });
119 }
120 }
121 }
122
123 if !in_code_block {
124 in_code_block = true;
126 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
127 } else if trimmed.starts_with(code_fence_marker) {
128 in_code_block = false;
130 code_fence_marker = "";
131 }
132 blank_count = 0;
133 lines_to_check.clear();
134 continue;
135 }
136
137 if in_code_block {
139 blank_count = 0;
141 continue;
142 }
143
144 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
146 if is_indented_code {
147 if blank_count > self.config.maximum {
149 let location = if blank_start == 0 {
150 "at start of file"
151 } else {
152 "between content"
153 };
154 for i in self.config.maximum..blank_count {
155 let excess_line_num = blank_start + i;
156 if lines_to_check.contains(&excess_line_num) {
157 let excess_line = excess_line_num + 1;
158 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
159 let (start_line, start_col, end_line, end_col) =
160 calculate_line_range(excess_line, excess_line_content);
161 warnings.push(LintWarning {
162 rule_name: Some(self.name().to_string()),
163 severity: Severity::Warning,
164 message: format!("Multiple consecutive blank lines {location}"),
165 line: start_line,
166 column: start_col,
167 end_line,
168 end_column: end_col,
169 fix: Some(Fix {
170 range: {
171 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
172 let line_end = line_index
173 .get_line_start_byte(excess_line + 1)
174 .unwrap_or(line_start + 1);
175 line_start..line_end
176 },
177 replacement: String::new(),
178 }),
179 });
180 }
181 }
182 }
183 blank_count = 0;
184 lines_to_check.clear();
185 continue;
186 }
187
188 if line.trim().is_empty() {
189 if blank_count == 0 {
190 blank_start = line_num;
191 }
192 blank_count += 1;
193 if blank_count > self.config.maximum {
195 lines_to_check.insert(line_num);
196 }
197 } else {
198 if blank_count > self.config.maximum {
199 let location = if blank_start == 0 {
201 "at start of file"
202 } else {
203 "between content"
204 };
205
206 for i in self.config.maximum..blank_count {
208 let excess_line_num = blank_start + i;
209 if lines_to_check.contains(&excess_line_num) {
210 let excess_line = excess_line_num + 1; let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
212
213 let (start_line, start_col, end_line, end_col) =
215 calculate_line_range(excess_line, excess_line_content);
216
217 warnings.push(LintWarning {
218 rule_name: Some(self.name().to_string()),
219 severity: Severity::Warning,
220 message: format!("Multiple consecutive blank lines {location}"),
221 line: start_line,
222 column: start_col,
223 end_line,
224 end_column: end_col,
225 fix: Some(Fix {
226 range: {
227 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
229 let line_end = line_index
230 .get_line_start_byte(excess_line + 1)
231 .unwrap_or(line_start + 1);
232 line_start..line_end
233 },
234 replacement: String::new(), }),
236 });
237 }
238 }
239 }
240 blank_count = 0;
241 lines_to_check.clear();
242 }
243 }
244
245 let mut consecutive_newlines_at_end: usize = 0;
251 for ch in content.chars().rev() {
252 if ch == '\n' {
253 consecutive_newlines_at_end += 1;
254 } else if ch == '\r' {
255 continue;
257 } else {
258 break;
259 }
260 }
261
262 let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
265
266 if blank_lines_at_eof > 0 {
269 let location = "at end of file";
270
271 let report_line = lines.len();
273
274 let target_newlines = 1;
277 let excess_newlines = consecutive_newlines_at_end - target_newlines;
278
279 warnings.push(LintWarning {
281 rule_name: Some(self.name().to_string()),
282 severity: Severity::Warning,
283 message: format!("Multiple consecutive blank lines {location}"),
284 line: report_line,
285 column: 1,
286 end_line: report_line,
287 end_column: 1,
288 fix: Some(Fix {
289 range: {
290 let keep_chars = content.len() - excess_newlines;
292 log::debug!(
293 "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
294 consecutive_newlines_at_end,
295 blank_lines_at_eof,
296 target_newlines,
297 excess_newlines,
298 content.len(),
299 keep_chars,
300 keep_chars,
301 content.len()
302 );
303 keep_chars..content.len()
304 },
305 replacement: String::new(),
306 }),
307 });
308 }
309
310 Ok(warnings)
311 }
312
313 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
314 let content = ctx.content;
315
316 let mut result = Vec::new();
317 let mut blank_count = 0;
318
319 let mut in_code_block = false;
320 let mut code_block_blanks = Vec::new();
321 let mut in_front_matter = false;
322
323 for filtered_line in ctx.filtered_lines() {
325 let line = filtered_line.content;
326
327 if filtered_line.line_info.in_front_matter {
329 if !in_front_matter {
330 let allowed_blanks = blank_count.min(self.config.maximum);
332 if allowed_blanks > 0 {
333 result.extend(vec![""; allowed_blanks]);
334 }
335 blank_count = 0;
336 in_front_matter = true;
337 }
338 result.push(line);
339 continue;
340 } else if in_front_matter {
341 in_front_matter = false;
343 }
344
345 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
347 if !in_code_block {
349 let allowed_blanks = blank_count.min(self.config.maximum);
350 if allowed_blanks > 0 {
351 result.extend(vec![""; allowed_blanks]);
352 }
353 blank_count = 0;
354 } else {
355 result.append(&mut code_block_blanks);
357 }
358 in_code_block = !in_code_block;
359 result.push(line);
360 continue;
361 }
362
363 if in_code_block {
364 if line.trim().is_empty() {
365 code_block_blanks.push(line);
366 } else {
367 result.append(&mut code_block_blanks);
368 result.push(line);
369 }
370 } else if line.trim().is_empty() {
371 blank_count += 1;
372 } else {
373 let allowed_blanks = blank_count.min(self.config.maximum);
375 if allowed_blanks > 0 {
376 result.extend(vec![""; allowed_blanks]);
377 }
378 blank_count = 0;
379 result.push(line);
380 }
381 }
382
383 let allowed_trailing_blanks = blank_count.min(self.config.maximum);
387 if allowed_trailing_blanks > 0 {
388 result.extend(vec![""; allowed_trailing_blanks]);
389 }
390
391 let mut output = result.join("\n");
393 if content.ends_with('\n') {
394 output.push('\n');
395 }
396
397 Ok(output)
398 }
399
400 fn as_any(&self) -> &dyn std::any::Any {
401 self
402 }
403
404 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
405 ctx.content.is_empty() || !ctx.has_char('\n')
407 }
408
409 fn default_config_section(&self) -> Option<(String, toml::Value)> {
410 let default_config = MD012Config::default();
411 let json_value = serde_json::to_value(&default_config).ok()?;
412 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
413
414 if let toml::Value::Table(table) = toml_value {
415 if !table.is_empty() {
416 Some((MD012Config::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::<MD012Config>(config);
430 Box::new(Self::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_single_blank_line_allowed() {
441 let rule = MD012NoMultipleBlanks::default();
442 let content = "Line 1\n\nLine 2\n\nLine 3";
443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
444 let result = rule.check(&ctx).unwrap();
445 assert!(result.is_empty());
446 }
447
448 #[test]
449 fn test_multiple_blank_lines_flagged() {
450 let rule = MD012NoMultipleBlanks::default();
451 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
452 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
453 let result = rule.check(&ctx).unwrap();
454 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
456 assert_eq!(result[1].line, 6);
457 assert_eq!(result[2].line, 7);
458 }
459
460 #[test]
461 fn test_custom_maximum() {
462 let rule = MD012NoMultipleBlanks::new(2);
463 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
464 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
465 let result = rule.check(&ctx).unwrap();
466 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
468 }
469
470 #[test]
471 fn test_fix_multiple_blank_lines() {
472 let rule = MD012NoMultipleBlanks::default();
473 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475 let fixed = rule.fix(&ctx).unwrap();
476 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
477 }
478
479 #[test]
480 fn test_blank_lines_in_code_block() {
481 let rule = MD012NoMultipleBlanks::default();
482 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.is_empty()); }
487
488 #[test]
489 fn test_fix_preserves_code_block_blanks() {
490 let rule = MD012NoMultipleBlanks::default();
491 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let fixed = rule.fix(&ctx).unwrap();
494 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
495 }
496
497 #[test]
498 fn test_blank_lines_in_front_matter() {
499 let rule = MD012NoMultipleBlanks::default();
500 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502 let result = rule.check(&ctx).unwrap();
503 assert!(result.is_empty()); }
505
506 #[test]
507 fn test_blank_lines_at_start() {
508 let rule = MD012NoMultipleBlanks::default();
509 let content = "\n\n\nContent";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511 let result = rule.check(&ctx).unwrap();
512 assert_eq!(result.len(), 2);
513 assert!(result[0].message.contains("at start of file"));
514 }
515
516 #[test]
517 fn test_blank_lines_at_end() {
518 let rule = MD012NoMultipleBlanks::default();
519 let content = "Content\n\n\n";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1);
523 assert!(result[0].message.contains("at end of file"));
524 }
525
526 #[test]
527 fn test_single_blank_at_eof_flagged() {
528 let rule = MD012NoMultipleBlanks::default();
530 let content = "Content\n\n";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
532 let result = rule.check(&ctx).unwrap();
533 assert_eq!(result.len(), 1);
534 assert!(result[0].message.contains("at end of file"));
535 }
536
537 #[test]
538 fn test_whitespace_only_lines() {
539 let rule = MD012NoMultipleBlanks::default();
540 let content = "Line 1\n \n\t\nLine 2";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
542 let result = rule.check(&ctx).unwrap();
543 assert_eq!(result.len(), 1); }
545
546 #[test]
547 fn test_indented_code_blocks() {
548 let rule = MD012NoMultipleBlanks::default();
549 let content = "Text\n\n code\n \n \n more code\n\nText";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551 let result = rule.check(&ctx).unwrap();
552 assert_eq!(result.len(), 1);
555 assert_eq!(result[0].line, 5); }
557
558 #[test]
559 fn test_fix_with_final_newline() {
560 let rule = MD012NoMultipleBlanks::default();
561 let content = "Line 1\n\n\nLine 2\n";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
563 let fixed = rule.fix(&ctx).unwrap();
564 assert_eq!(fixed, "Line 1\n\nLine 2\n");
565 assert!(fixed.ends_with('\n'));
566 }
567
568 #[test]
569 fn test_empty_content() {
570 let rule = MD012NoMultipleBlanks::default();
571 let content = "";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574 assert!(result.is_empty());
575 }
576
577 #[test]
578 fn test_nested_code_blocks() {
579 let rule = MD012NoMultipleBlanks::default();
580 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty());
584 }
585
586 #[test]
587 fn test_unclosed_code_block() {
588 let rule = MD012NoMultipleBlanks::default();
589 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591 let result = rule.check(&ctx).unwrap();
592 assert!(result.is_empty()); }
594
595 #[test]
596 fn test_mixed_fence_styles() {
597 let rule = MD012NoMultipleBlanks::default();
598 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601 assert!(result.is_empty()); }
603
604 #[test]
605 fn test_config_from_toml() {
606 let mut config = crate::config::Config::default();
607 let mut rule_config = crate::config::RuleConfig::default();
608 rule_config
609 .values
610 .insert("maximum".to_string(), toml::Value::Integer(3));
611 config.rules.insert("MD012".to_string(), rule_config);
612
613 let rule = MD012NoMultipleBlanks::from_config(&config);
614 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
616 let result = rule.check(&ctx).unwrap();
617 assert!(result.is_empty()); }
619
620 #[test]
621 fn test_blank_lines_between_sections() {
622 let rule = MD012NoMultipleBlanks::default();
623 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(result.len(), 1);
627 assert_eq!(result[0].line, 5);
628 }
629
630 #[test]
631 fn test_fix_preserves_indented_code() {
632 let rule = MD012NoMultipleBlanks::default();
633 let content = "Text\n\n\n code\n \n more code\n\n\nText";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let fixed = rule.fix(&ctx).unwrap();
636 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
638 }
639
640 #[test]
641 fn test_edge_case_only_blanks() {
642 let rule = MD012NoMultipleBlanks::default();
643 let content = "\n\n\n";
644 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
645 let result = rule.check(&ctx).unwrap();
646 assert_eq!(result.len(), 1);
648 assert!(result[0].message.contains("at end of file"));
649 }
650}