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