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