1use crate::utils::range_utils::{LineIndex, calculate_line_range};
2use std::collections::HashSet;
3use toml;
4
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
6use crate::rule_config_serde::RuleConfig;
7
8mod md012_config;
9use md012_config::MD012Config;
10
11#[derive(Debug, Clone, Default)]
16pub struct MD012NoMultipleBlanks {
17 config: MD012Config,
18}
19
20impl MD012NoMultipleBlanks {
21 pub fn new(maximum: usize) -> Self {
22 Self {
23 config: MD012Config { maximum },
24 }
25 }
26
27 pub fn from_config_struct(config: MD012Config) -> Self {
28 Self { config }
29 }
30}
31
32impl Rule for MD012NoMultipleBlanks {
33 fn name(&self) -> &'static str {
34 "MD012"
35 }
36
37 fn description(&self) -> &'static str {
38 "Multiple consecutive blank lines"
39 }
40
41 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
42 let content = ctx.content;
43
44 if content.is_empty() {
46 return Ok(Vec::new());
47 }
48
49 let lines: Vec<&str> = content.lines().collect();
52 let has_potential_blanks = lines
53 .windows(2)
54 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
55
56 let ends_with_multiple_newlines = content.ends_with("\n\n");
59
60 if !has_potential_blanks && !ends_with_multiple_newlines {
61 return Ok(Vec::new());
62 }
63
64 let _line_index = LineIndex::new(content.to_string());
65
66 let mut warnings = Vec::new();
67
68 let mut blank_count = 0;
70 let mut blank_start = 0;
71 let mut in_code_block = false;
72 let mut in_front_matter = false;
73 let mut code_fence_marker = "";
74
75 let mut lines_to_check: HashSet<usize> = HashSet::new();
77
78 for (line_num, &line) in lines.iter().enumerate() {
79 let trimmed = line.trim_start();
80
81 if trimmed == "---" {
83 if line_num == 0 {
84 in_front_matter = true;
85 } else if in_front_matter {
86 in_front_matter = false;
87 }
88 if blank_count > self.config.maximum {
90 let location = if blank_start == 0 {
91 "at start of file"
92 } else {
93 "between content"
94 };
95 for i in self.config.maximum..blank_count {
96 let excess_line_num = blank_start + i;
97 if lines_to_check.contains(&excess_line_num) {
98 let excess_line = excess_line_num + 1;
99 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
100 let (start_line, start_col, end_line, end_col) =
101 calculate_line_range(excess_line, excess_line_content);
102 warnings.push(LintWarning {
103 rule_name: Some(self.name().to_string()),
104 severity: Severity::Warning,
105 message: format!("Multiple consecutive blank lines {location}"),
106 line: start_line,
107 column: start_col,
108 end_line,
109 end_column: end_col,
110 fix: Some(Fix {
111 range: {
112 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
113 let line_end = _line_index
114 .get_line_start_byte(excess_line + 1)
115 .unwrap_or(line_start + 1);
116 line_start..line_end
117 },
118 replacement: String::new(),
119 }),
120 });
121 }
122 }
123 }
124 blank_count = 0;
125 lines_to_check.clear();
126 continue;
127 }
128
129 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
131 if blank_count > self.config.maximum {
133 let location = if blank_start == 0 {
134 "at start of file"
135 } else {
136 "between content"
137 };
138 for i in self.config.maximum..blank_count {
139 let excess_line_num = blank_start + i;
140 if lines_to_check.contains(&excess_line_num) {
141 let excess_line = excess_line_num + 1;
142 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
143 let (start_line, start_col, end_line, end_col) =
144 calculate_line_range(excess_line, excess_line_content);
145 warnings.push(LintWarning {
146 rule_name: Some(self.name().to_string()),
147 severity: Severity::Warning,
148 message: format!("Multiple consecutive blank lines {location}"),
149 line: start_line,
150 column: start_col,
151 end_line,
152 end_column: end_col,
153 fix: Some(Fix {
154 range: {
155 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
156 let line_end = _line_index
157 .get_line_start_byte(excess_line + 1)
158 .unwrap_or(line_start + 1);
159 line_start..line_end
160 },
161 replacement: String::new(),
162 }),
163 });
164 }
165 }
166 }
167
168 if !in_code_block {
169 in_code_block = true;
171 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
172 } else if trimmed.starts_with(code_fence_marker) {
173 in_code_block = false;
175 code_fence_marker = "";
176 }
177 blank_count = 0;
178 lines_to_check.clear();
179 continue;
180 }
181
182 if in_code_block || in_front_matter {
184 blank_count = 0;
186 continue;
187 }
188
189 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
191 if is_indented_code {
192 if blank_count > self.config.maximum {
194 let location = if blank_start == 0 {
195 "at start of file"
196 } else {
197 "between content"
198 };
199 for i in self.config.maximum..blank_count {
200 let excess_line_num = blank_start + i;
201 if lines_to_check.contains(&excess_line_num) {
202 let excess_line = excess_line_num + 1;
203 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
204 let (start_line, start_col, end_line, end_col) =
205 calculate_line_range(excess_line, excess_line_content);
206 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: start_line,
211 column: start_col,
212 end_line,
213 end_column: end_col,
214 fix: Some(Fix {
215 range: {
216 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
217 let line_end = _line_index
218 .get_line_start_byte(excess_line + 1)
219 .unwrap_or(line_start + 1);
220 line_start..line_end
221 },
222 replacement: String::new(),
223 }),
224 });
225 }
226 }
227 }
228 blank_count = 0;
229 lines_to_check.clear();
230 continue;
231 }
232
233 if line.trim().is_empty() {
234 if blank_count == 0 {
235 blank_start = line_num;
236 }
237 blank_count += 1;
238 if blank_count > self.config.maximum {
240 lines_to_check.insert(line_num);
241 }
242 } else {
243 if blank_count > self.config.maximum {
244 let location = if blank_start == 0 {
246 "at start of file"
247 } else {
248 "between content"
249 };
250
251 for i in self.config.maximum..blank_count {
253 let excess_line_num = blank_start + i;
254 if lines_to_check.contains(&excess_line_num) {
255 let excess_line = excess_line_num + 1; let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
257
258 let (start_line, start_col, end_line, end_col) =
260 calculate_line_range(excess_line, excess_line_content);
261
262 warnings.push(LintWarning {
263 rule_name: Some(self.name().to_string()),
264 severity: Severity::Warning,
265 message: format!("Multiple consecutive blank lines {location}"),
266 line: start_line,
267 column: start_col,
268 end_line,
269 end_column: end_col,
270 fix: Some(Fix {
271 range: {
272 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
274 let line_end = _line_index
275 .get_line_start_byte(excess_line + 1)
276 .unwrap_or(line_start + 1);
277 line_start..line_end
278 },
279 replacement: String::new(), }),
281 });
282 }
283 }
284 }
285 blank_count = 0;
286 lines_to_check.clear();
287 }
288 }
289
290 let mut consecutive_newlines_at_end: usize = 0;
296 for ch in content.chars().rev() {
297 if ch == '\n' {
298 consecutive_newlines_at_end += 1;
299 } else if ch == '\r' {
300 continue;
302 } else {
303 break;
304 }
305 }
306
307 let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
312
313 if blank_lines_at_eof > 0 {
316 let location = "at end of file";
317
318 let report_line = lines.len();
320
321 warnings.push(LintWarning {
323 rule_name: Some(self.name().to_string()),
324 severity: Severity::Warning,
325 message: format!("Multiple consecutive blank lines {location}"),
326 line: report_line,
327 column: 1,
328 end_line: report_line,
329 end_column: 1,
330 fix: Some(Fix {
331 range: {
332 let excess_newlines = blank_lines_at_eof - self.config.maximum;
335 let keep_chars = content.len() - excess_newlines;
336 keep_chars..content.len()
337 },
338 replacement: String::new(),
339 }),
340 });
341 }
342
343 Ok(warnings)
344 }
345
346 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
347 let content = ctx.content;
348 let _line_index = LineIndex::new(content.to_string());
349
350 let mut result = Vec::new();
351
352 let mut blank_count = 0;
353
354 let lines: Vec<&str> = content.lines().collect();
355
356 let mut in_code_block = false;
357
358 let mut in_front_matter = false;
359
360 let mut code_block_blanks = Vec::new();
361
362 for &line in lines.iter() {
363 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
365 if !in_code_block {
367 let allowed_blanks = blank_count.min(self.config.maximum);
368 if allowed_blanks > 0 {
369 result.extend(vec![""; allowed_blanks]);
370 }
371 blank_count = 0;
372 } else {
373 result.append(&mut code_block_blanks);
375 }
376 in_code_block = !in_code_block;
377 result.push(line);
378 continue;
379 }
380
381 if line.trim() == "---" {
382 in_front_matter = !in_front_matter;
383 if blank_count > 0 {
384 result.extend(vec![""; blank_count]);
385 blank_count = 0;
386 }
387 result.push(line);
388 continue;
389 }
390
391 if in_code_block {
392 if line.trim().is_empty() {
393 code_block_blanks.push(line);
394 } else {
395 result.append(&mut code_block_blanks);
396 result.push(line);
397 }
398 } else if in_front_matter {
399 if blank_count > 0 {
400 result.extend(vec![""; blank_count]);
401 blank_count = 0;
402 }
403 result.push(line);
404 } else if line.trim().is_empty() {
405 blank_count += 1;
406 } else {
407 let allowed_blanks = blank_count.min(self.config.maximum);
409 if allowed_blanks > 0 {
410 result.extend(vec![""; allowed_blanks]);
411 }
412 blank_count = 0;
413 result.push(line);
414 }
415 }
416
417 if !in_code_block {
419 let allowed_blanks = blank_count.min(self.config.maximum);
420 if allowed_blanks > 0 {
421 result.extend(vec![""; allowed_blanks]);
422 }
423 }
424
425 let mut output = result.join("\n");
428 if content.ends_with('\n') {
429 output.push('\n');
430 }
431
432 Ok(output)
433 }
434
435 fn as_any(&self) -> &dyn std::any::Any {
436 self
437 }
438
439 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
440 ctx.content.is_empty() || !ctx.has_char('\n')
442 }
443
444 fn default_config_section(&self) -> Option<(String, toml::Value)> {
445 let default_config = MD012Config::default();
446 let json_value = serde_json::to_value(&default_config).ok()?;
447 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
448
449 if let toml::Value::Table(table) = toml_value {
450 if !table.is_empty() {
451 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
452 } else {
453 None
454 }
455 } else {
456 None
457 }
458 }
459
460 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
461 where
462 Self: Sized,
463 {
464 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
465 Box::new(Self::from_config_struct(rule_config))
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::lint_context::LintContext;
473
474 #[test]
475 fn test_single_blank_line_allowed() {
476 let rule = MD012NoMultipleBlanks::default();
477 let content = "Line 1\n\nLine 2\n\nLine 3";
478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
479 let result = rule.check(&ctx).unwrap();
480 assert!(result.is_empty());
481 }
482
483 #[test]
484 fn test_multiple_blank_lines_flagged() {
485 let rule = MD012NoMultipleBlanks::default();
486 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
488 let result = rule.check(&ctx).unwrap();
489 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
491 assert_eq!(result[1].line, 6);
492 assert_eq!(result[2].line, 7);
493 }
494
495 #[test]
496 fn test_custom_maximum() {
497 let rule = MD012NoMultipleBlanks::new(2);
498 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
500 let result = rule.check(&ctx).unwrap();
501 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
503 }
504
505 #[test]
506 fn test_fix_multiple_blank_lines() {
507 let rule = MD012NoMultipleBlanks::default();
508 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510 let fixed = rule.fix(&ctx).unwrap();
511 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
512 }
513
514 #[test]
515 fn test_blank_lines_in_code_block() {
516 let rule = MD012NoMultipleBlanks::default();
517 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
519 let result = rule.check(&ctx).unwrap();
520 assert!(result.is_empty()); }
522
523 #[test]
524 fn test_fix_preserves_code_block_blanks() {
525 let rule = MD012NoMultipleBlanks::default();
526 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528 let fixed = rule.fix(&ctx).unwrap();
529 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
530 }
531
532 #[test]
533 fn test_blank_lines_in_front_matter() {
534 let rule = MD012NoMultipleBlanks::default();
535 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537 let result = rule.check(&ctx).unwrap();
538 assert!(result.is_empty()); }
540
541 #[test]
542 fn test_blank_lines_at_start() {
543 let rule = MD012NoMultipleBlanks::default();
544 let content = "\n\n\nContent";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
546 let result = rule.check(&ctx).unwrap();
547 assert_eq!(result.len(), 2);
548 assert!(result[0].message.contains("at start of file"));
549 }
550
551 #[test]
552 fn test_blank_lines_at_end() {
553 let rule = MD012NoMultipleBlanks::default();
554 let content = "Content\n\n\n";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557 assert_eq!(result.len(), 1);
558 assert!(result[0].message.contains("at end of file"));
559 }
560
561 #[test]
562 fn test_single_blank_at_eof_flagged() {
563 let rule = MD012NoMultipleBlanks::default();
565 let content = "Content\n\n";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568 assert_eq!(result.len(), 1);
569 assert!(result[0].message.contains("at end of file"));
570 }
571
572 #[test]
573 fn test_whitespace_only_lines() {
574 let rule = MD012NoMultipleBlanks::default();
575 let content = "Line 1\n \n\t\nLine 2";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
577 let result = rule.check(&ctx).unwrap();
578 assert_eq!(result.len(), 1); }
580
581 #[test]
582 fn test_indented_code_blocks() {
583 let rule = MD012NoMultipleBlanks::default();
584 let content = "Text\n\n code\n \n \n more code\n\nText";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586 let result = rule.check(&ctx).unwrap();
587 assert_eq!(result.len(), 1);
590 assert_eq!(result[0].line, 5); }
592
593 #[test]
594 fn test_fix_with_final_newline() {
595 let rule = MD012NoMultipleBlanks::default();
596 let content = "Line 1\n\n\nLine 2\n";
597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
598 let fixed = rule.fix(&ctx).unwrap();
599 assert_eq!(fixed, "Line 1\n\nLine 2\n");
600 assert!(fixed.ends_with('\n'));
601 }
602
603 #[test]
604 fn test_empty_content() {
605 let rule = MD012NoMultipleBlanks::default();
606 let content = "";
607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608 let result = rule.check(&ctx).unwrap();
609 assert!(result.is_empty());
610 }
611
612 #[test]
613 fn test_nested_code_blocks() {
614 let rule = MD012NoMultipleBlanks::default();
615 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
617 let result = rule.check(&ctx).unwrap();
618 assert!(result.is_empty());
619 }
620
621 #[test]
622 fn test_unclosed_code_block() {
623 let rule = MD012NoMultipleBlanks::default();
624 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
625 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
626 let result = rule.check(&ctx).unwrap();
627 assert!(result.is_empty()); }
629
630 #[test]
631 fn test_mixed_fence_styles() {
632 let rule = MD012NoMultipleBlanks::default();
633 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let result = rule.check(&ctx).unwrap();
636 assert!(result.is_empty()); }
638
639 #[test]
640 fn test_config_from_toml() {
641 let mut config = crate::config::Config::default();
642 let mut rule_config = crate::config::RuleConfig::default();
643 rule_config
644 .values
645 .insert("maximum".to_string(), toml::Value::Integer(3));
646 config.rules.insert("MD012".to_string(), rule_config);
647
648 let rule = MD012NoMultipleBlanks::from_config(&config);
649 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
651 let result = rule.check(&ctx).unwrap();
652 assert!(result.is_empty()); }
654
655 #[test]
656 fn test_blank_lines_between_sections() {
657 let rule = MD012NoMultipleBlanks::default();
658 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
660 let result = rule.check(&ctx).unwrap();
661 assert_eq!(result.len(), 1);
662 assert_eq!(result[0].line, 5);
663 }
664
665 #[test]
666 fn test_fix_preserves_indented_code() {
667 let rule = MD012NoMultipleBlanks::default();
668 let content = "Text\n\n\n code\n \n more code\n\n\nText";
669 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
670 let fixed = rule.fix(&ctx).unwrap();
671 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
673 }
674
675 #[test]
676 fn test_edge_case_only_blanks() {
677 let rule = MD012NoMultipleBlanks::default();
678 let content = "\n\n\n";
679 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
680 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 1);
683 assert!(result[0].message.contains("at end of file"));
684 }
685}