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 use crate::types::PositiveUsize;
24 Self {
25 config: MD012Config {
26 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
27 },
28 }
29 }
30
31 pub const fn from_config_struct(config: MD012Config) -> Self {
32 Self { config }
33 }
34}
35
36impl Rule for MD012NoMultipleBlanks {
37 fn name(&self) -> &'static str {
38 "MD012"
39 }
40
41 fn description(&self) -> &'static str {
42 "Multiple consecutive blank lines"
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");
63
64 if !has_potential_blanks && !ends_with_multiple_newlines {
65 return Ok(Vec::new());
66 }
67
68 let line_index = &ctx.line_index;
69
70 let mut warnings = Vec::new();
71
72 let mut blank_count = 0;
74 let mut blank_start = 0;
75 let mut in_code_block = false;
76 let mut code_fence_marker = "";
77
78 let mut lines_to_check: HashSet<usize> = HashSet::new();
80
81 for filtered_line in ctx.filtered_lines().skip_front_matter() {
83 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
85 let trimmed = line.trim_start();
86
87 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
89 if blank_count > self.config.maximum.get() {
91 let location = if blank_start == 0 {
92 "at start of file"
93 } else {
94 "between content"
95 };
96 for i in self.config.maximum.get()..blank_count {
97 let excess_line_num = blank_start + i;
98 if lines_to_check.contains(&excess_line_num) {
99 let excess_line = excess_line_num + 1;
100 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
101 let (start_line, start_col, end_line, end_col) =
102 calculate_line_range(excess_line, excess_line_content);
103 warnings.push(LintWarning {
104 rule_name: Some(self.name().to_string()),
105 severity: Severity::Warning,
106 message: format!("Multiple consecutive blank lines {location}"),
107 line: start_line,
108 column: start_col,
109 end_line,
110 end_column: end_col,
111 fix: Some(Fix {
112 range: {
113 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
114 let line_end = line_index
115 .get_line_start_byte(excess_line + 1)
116 .unwrap_or(line_start + 1);
117 line_start..line_end
118 },
119 replacement: String::new(),
120 }),
121 });
122 }
123 }
124 }
125
126 if !in_code_block {
127 in_code_block = true;
129 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
130 } else if trimmed.starts_with(code_fence_marker) {
131 in_code_block = false;
133 code_fence_marker = "";
134 }
135 blank_count = 0;
136 lines_to_check.clear();
137 continue;
138 }
139
140 if in_code_block {
142 blank_count = 0;
144 continue;
145 }
146
147 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
149 if is_indented_code {
150 if blank_count > self.config.maximum.get() {
152 let location = if blank_start == 0 {
153 "at start of file"
154 } else {
155 "between content"
156 };
157 for i in self.config.maximum.get()..blank_count {
158 let excess_line_num = blank_start + i;
159 if lines_to_check.contains(&excess_line_num) {
160 let excess_line = excess_line_num + 1;
161 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
162 let (start_line, start_col, end_line, end_col) =
163 calculate_line_range(excess_line, excess_line_content);
164 warnings.push(LintWarning {
165 rule_name: Some(self.name().to_string()),
166 severity: Severity::Warning,
167 message: format!("Multiple consecutive blank lines {location}"),
168 line: start_line,
169 column: start_col,
170 end_line,
171 end_column: end_col,
172 fix: Some(Fix {
173 range: {
174 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
175 let line_end = line_index
176 .get_line_start_byte(excess_line + 1)
177 .unwrap_or(line_start + 1);
178 line_start..line_end
179 },
180 replacement: String::new(),
181 }),
182 });
183 }
184 }
185 }
186 blank_count = 0;
187 lines_to_check.clear();
188 continue;
189 }
190
191 if line.trim().is_empty() {
192 if blank_count == 0 {
193 blank_start = line_num;
194 }
195 blank_count += 1;
196 if blank_count > self.config.maximum.get() {
198 lines_to_check.insert(line_num);
199 }
200 } else {
201 if blank_count > self.config.maximum.get() {
202 let location = if blank_start == 0 {
204 "at start of file"
205 } else {
206 "between content"
207 };
208
209 for i in self.config.maximum.get()..blank_count {
211 let excess_line_num = blank_start + i;
212 if lines_to_check.contains(&excess_line_num) {
213 let excess_line = excess_line_num + 1; let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
215
216 let (start_line, start_col, end_line, end_col) =
218 calculate_line_range(excess_line, excess_line_content);
219
220 warnings.push(LintWarning {
221 rule_name: Some(self.name().to_string()),
222 severity: Severity::Warning,
223 message: format!("Multiple consecutive blank lines {location}"),
224 line: start_line,
225 column: start_col,
226 end_line,
227 end_column: end_col,
228 fix: Some(Fix {
229 range: {
230 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
232 let line_end = line_index
233 .get_line_start_byte(excess_line + 1)
234 .unwrap_or(line_start + 1);
235 line_start..line_end
236 },
237 replacement: String::new(), }),
239 });
240 }
241 }
242 }
243 blank_count = 0;
244 lines_to_check.clear();
245 }
246 }
247
248 let mut consecutive_newlines_at_end: usize = 0;
254 for ch in content.chars().rev() {
255 if ch == '\n' {
256 consecutive_newlines_at_end += 1;
257 } else if ch == '\r' {
258 continue;
260 } else {
261 break;
262 }
263 }
264
265 let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
268
269 if blank_lines_at_eof > 0 {
272 let location = "at end of file";
273
274 let report_line = lines.len();
276
277 let target_newlines = 1;
280 let excess_newlines = consecutive_newlines_at_end - target_newlines;
281
282 warnings.push(LintWarning {
284 rule_name: Some(self.name().to_string()),
285 severity: Severity::Warning,
286 message: format!("Multiple consecutive blank lines {location}"),
287 line: report_line,
288 column: 1,
289 end_line: report_line,
290 end_column: 1,
291 fix: Some(Fix {
292 range: {
293 let keep_chars = content.len() - excess_newlines;
295 log::debug!(
296 "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
297 consecutive_newlines_at_end,
298 blank_lines_at_eof,
299 target_newlines,
300 excess_newlines,
301 content.len(),
302 keep_chars,
303 keep_chars,
304 content.len()
305 );
306 keep_chars..content.len()
307 },
308 replacement: String::new(),
309 }),
310 });
311 }
312
313 Ok(warnings)
314 }
315
316 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
317 let content = ctx.content;
318
319 let mut result = Vec::new();
320 let mut blank_count = 0;
321
322 let mut in_code_block = false;
323 let mut code_block_blanks = Vec::new();
324 let mut in_front_matter = false;
325
326 for filtered_line in ctx.filtered_lines() {
328 let line = filtered_line.content;
329
330 if filtered_line.line_info.in_front_matter {
332 if !in_front_matter {
333 let allowed_blanks = blank_count.min(self.config.maximum.get());
335 if allowed_blanks > 0 {
336 result.extend(vec![""; allowed_blanks]);
337 }
338 blank_count = 0;
339 in_front_matter = true;
340 }
341 result.push(line);
342 continue;
343 } else if in_front_matter {
344 in_front_matter = false;
346 }
347
348 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
350 if !in_code_block {
352 let allowed_blanks = blank_count.min(self.config.maximum.get());
353 if allowed_blanks > 0 {
354 result.extend(vec![""; allowed_blanks]);
355 }
356 blank_count = 0;
357 } else {
358 result.append(&mut code_block_blanks);
360 }
361 in_code_block = !in_code_block;
362 result.push(line);
363 continue;
364 }
365
366 if in_code_block {
367 if line.trim().is_empty() {
368 code_block_blanks.push(line);
369 } else {
370 result.append(&mut code_block_blanks);
371 result.push(line);
372 }
373 } else if line.trim().is_empty() {
374 blank_count += 1;
375 } else {
376 let allowed_blanks = blank_count.min(self.config.maximum.get());
378 if allowed_blanks > 0 {
379 result.extend(vec![""; allowed_blanks]);
380 }
381 blank_count = 0;
382 result.push(line);
383 }
384 }
385
386 let allowed_trailing_blanks = blank_count.min(self.config.maximum.get());
390 if allowed_trailing_blanks > 0 {
391 result.extend(vec![""; allowed_trailing_blanks]);
392 }
393
394 let mut output = result.join("\n");
396 if content.ends_with('\n') {
397 output.push('\n');
398 }
399
400 Ok(output)
401 }
402
403 fn as_any(&self) -> &dyn std::any::Any {
404 self
405 }
406
407 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
408 ctx.content.is_empty() || !ctx.has_char('\n')
410 }
411
412 fn default_config_section(&self) -> Option<(String, toml::Value)> {
413 let default_config = MD012Config::default();
414 let json_value = serde_json::to_value(&default_config).ok()?;
415 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
416
417 if let toml::Value::Table(table) = toml_value {
418 if !table.is_empty() {
419 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
420 } else {
421 None
422 }
423 } else {
424 None
425 }
426 }
427
428 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
429 where
430 Self: Sized,
431 {
432 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
433 Box::new(Self::from_config_struct(rule_config))
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::lint_context::LintContext;
441
442 #[test]
443 fn test_single_blank_line_allowed() {
444 let rule = MD012NoMultipleBlanks::default();
445 let content = "Line 1\n\nLine 2\n\nLine 3";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447 let result = rule.check(&ctx).unwrap();
448 assert!(result.is_empty());
449 }
450
451 #[test]
452 fn test_multiple_blank_lines_flagged() {
453 let rule = MD012NoMultipleBlanks::default();
454 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
456 let result = rule.check(&ctx).unwrap();
457 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
459 assert_eq!(result[1].line, 6);
460 assert_eq!(result[2].line, 7);
461 }
462
463 #[test]
464 fn test_custom_maximum() {
465 let rule = MD012NoMultipleBlanks::new(2);
466 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468 let result = rule.check(&ctx).unwrap();
469 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
471 }
472
473 #[test]
474 fn test_fix_multiple_blank_lines() {
475 let rule = MD012NoMultipleBlanks::default();
476 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let fixed = rule.fix(&ctx).unwrap();
479 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
480 }
481
482 #[test]
483 fn test_blank_lines_in_code_block() {
484 let rule = MD012NoMultipleBlanks::default();
485 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
486 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
487 let result = rule.check(&ctx).unwrap();
488 assert!(result.is_empty()); }
490
491 #[test]
492 fn test_fix_preserves_code_block_blanks() {
493 let rule = MD012NoMultipleBlanks::default();
494 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
496 let fixed = rule.fix(&ctx).unwrap();
497 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
498 }
499
500 #[test]
501 fn test_blank_lines_in_front_matter() {
502 let rule = MD012NoMultipleBlanks::default();
503 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
504 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
505 let result = rule.check(&ctx).unwrap();
506 assert!(result.is_empty()); }
508
509 #[test]
510 fn test_blank_lines_at_start() {
511 let rule = MD012NoMultipleBlanks::default();
512 let content = "\n\n\nContent";
513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
514 let result = rule.check(&ctx).unwrap();
515 assert_eq!(result.len(), 2);
516 assert!(result[0].message.contains("at start of file"));
517 }
518
519 #[test]
520 fn test_blank_lines_at_end() {
521 let rule = MD012NoMultipleBlanks::default();
522 let content = "Content\n\n\n";
523 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1);
526 assert!(result[0].message.contains("at end of file"));
527 }
528
529 #[test]
530 fn test_single_blank_at_eof_flagged() {
531 let rule = MD012NoMultipleBlanks::default();
533 let content = "Content\n\n";
534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
535 let result = rule.check(&ctx).unwrap();
536 assert_eq!(result.len(), 1);
537 assert!(result[0].message.contains("at end of file"));
538 }
539
540 #[test]
541 fn test_whitespace_only_lines() {
542 let rule = MD012NoMultipleBlanks::default();
543 let content = "Line 1\n \n\t\nLine 2";
544 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
545 let result = rule.check(&ctx).unwrap();
546 assert_eq!(result.len(), 1); }
548
549 #[test]
550 fn test_indented_code_blocks() {
551 let rule = MD012NoMultipleBlanks::default();
552 let content = "Text\n\n code\n \n \n more code\n\nText";
553 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
554 let result = rule.check(&ctx).unwrap();
555 assert_eq!(result.len(), 1);
558 assert_eq!(result[0].line, 5); }
560
561 #[test]
562 fn test_fix_with_final_newline() {
563 let rule = MD012NoMultipleBlanks::default();
564 let content = "Line 1\n\n\nLine 2\n";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566 let fixed = rule.fix(&ctx).unwrap();
567 assert_eq!(fixed, "Line 1\n\nLine 2\n");
568 assert!(fixed.ends_with('\n'));
569 }
570
571 #[test]
572 fn test_empty_content() {
573 let rule = MD012NoMultipleBlanks::default();
574 let content = "";
575 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
576 let result = rule.check(&ctx).unwrap();
577 assert!(result.is_empty());
578 }
579
580 #[test]
581 fn test_nested_code_blocks() {
582 let rule = MD012NoMultipleBlanks::default();
583 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
585 let result = rule.check(&ctx).unwrap();
586 assert!(result.is_empty());
587 }
588
589 #[test]
590 fn test_unclosed_code_block() {
591 let rule = MD012NoMultipleBlanks::default();
592 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
594 let result = rule.check(&ctx).unwrap();
595 assert!(result.is_empty()); }
597
598 #[test]
599 fn test_mixed_fence_styles() {
600 let rule = MD012NoMultipleBlanks::default();
601 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
602 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
603 let result = rule.check(&ctx).unwrap();
604 assert!(result.is_empty()); }
606
607 #[test]
608 fn test_config_from_toml() {
609 let mut config = crate::config::Config::default();
610 let mut rule_config = crate::config::RuleConfig::default();
611 rule_config
612 .values
613 .insert("maximum".to_string(), toml::Value::Integer(3));
614 config.rules.insert("MD012".to_string(), rule_config);
615
616 let rule = MD012NoMultipleBlanks::from_config(&config);
617 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty()); }
622
623 #[test]
624 fn test_blank_lines_between_sections() {
625 let rule = MD012NoMultipleBlanks::default();
626 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
628 let result = rule.check(&ctx).unwrap();
629 assert_eq!(result.len(), 1);
630 assert_eq!(result[0].line, 5);
631 }
632
633 #[test]
634 fn test_fix_preserves_indented_code() {
635 let rule = MD012NoMultipleBlanks::default();
636 let content = "Text\n\n\n code\n \n more code\n\n\nText";
637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
638 let fixed = rule.fix(&ctx).unwrap();
639 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
641 }
642
643 #[test]
644 fn test_edge_case_only_blanks() {
645 let rule = MD012NoMultipleBlanks::default();
646 let content = "\n\n\n";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
648 let result = rule.check(&ctx).unwrap();
649 assert_eq!(result.len(), 1);
651 assert!(result[0].message.contains("at end of file"));
652 }
653}