1use crate::filtered_lines::FilteredLinesExt;
2use crate::utils::LineIndex;
3use crate::utils::range_utils::calculate_line_range;
4use std::collections::HashSet;
5use toml;
6
7use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rule_config_serde::RuleConfig;
9
10mod md012_config;
11use md012_config::MD012Config;
12
13#[derive(Debug, Clone, Default)]
18pub struct MD012NoMultipleBlanks {
19 config: MD012Config,
20}
21
22impl MD012NoMultipleBlanks {
23 pub fn new(maximum: usize) -> Self {
24 use crate::types::PositiveUsize;
25 Self {
26 config: MD012Config {
27 maximum: PositiveUsize::new(maximum).unwrap_or(PositiveUsize::from_const(1)),
28 },
29 }
30 }
31
32 pub const fn from_config_struct(config: MD012Config) -> Self {
33 Self { config }
34 }
35
36 fn generate_excess_warnings(
38 &self,
39 blank_start: usize,
40 blank_count: usize,
41 lines: &[&str],
42 lines_to_check: &HashSet<usize>,
43 line_index: &LineIndex,
44 ) -> Vec<LintWarning> {
45 let mut warnings = Vec::new();
46
47 let location = if blank_start == 0 {
48 "at start of file"
49 } else {
50 "between content"
51 };
52
53 for i in self.config.maximum.get()..blank_count {
54 let excess_line_num = blank_start + i;
55 if lines_to_check.contains(&excess_line_num) {
56 let excess_line = excess_line_num + 1;
57 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
58 let (start_line, start_col, end_line, end_col) = calculate_line_range(excess_line, excess_line_content);
59 warnings.push(LintWarning {
60 rule_name: Some(self.name().to_string()),
61 severity: Severity::Warning,
62 message: format!("Multiple consecutive blank lines {location}"),
63 line: start_line,
64 column: start_col,
65 end_line,
66 end_column: end_col,
67 fix: Some(Fix {
68 range: {
69 let line_start = line_index.get_line_start_byte(excess_line).unwrap_or(0);
70 let line_end = line_index
71 .get_line_start_byte(excess_line + 1)
72 .unwrap_or(line_start + 1);
73 line_start..line_end
74 },
75 replacement: String::new(),
76 }),
77 });
78 }
79 }
80
81 warnings
82 }
83}
84
85impl Rule for MD012NoMultipleBlanks {
86 fn name(&self) -> &'static str {
87 "MD012"
88 }
89
90 fn description(&self) -> &'static str {
91 "Multiple consecutive blank lines"
92 }
93
94 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
95 let content = ctx.content;
96
97 if content.is_empty() {
99 return Ok(Vec::new());
100 }
101
102 let lines: Vec<&str> = content.lines().collect();
105 let has_potential_blanks = lines
106 .windows(2)
107 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty());
108
109 let ends_with_multiple_newlines = content.ends_with("\n\n");
112
113 if !has_potential_blanks && !ends_with_multiple_newlines {
114 return Ok(Vec::new());
115 }
116
117 let line_index = &ctx.line_index;
118
119 let mut warnings = Vec::new();
120
121 let mut blank_count = 0;
123 let mut blank_start = 0;
124 let mut in_code_block = false;
125 let mut code_fence_marker = "";
126
127 let mut lines_to_check: HashSet<usize> = HashSet::new();
129
130 for filtered_line in ctx.filtered_lines().skip_front_matter() {
132 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
134 let trimmed = line.trim_start();
135
136 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
138 if blank_count > self.config.maximum.get() {
140 warnings.extend(self.generate_excess_warnings(
141 blank_start,
142 blank_count,
143 &lines,
144 &lines_to_check,
145 line_index,
146 ));
147 }
148
149 if !in_code_block {
150 in_code_block = true;
152 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
153 } else if trimmed.starts_with(code_fence_marker) {
154 in_code_block = false;
156 code_fence_marker = "";
157 }
158 blank_count = 0;
159 lines_to_check.clear();
160 continue;
161 }
162
163 if in_code_block {
165 blank_count = 0;
167 continue;
168 }
169
170 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
172 if is_indented_code {
173 if blank_count > self.config.maximum.get() {
175 warnings.extend(self.generate_excess_warnings(
176 blank_start,
177 blank_count,
178 &lines,
179 &lines_to_check,
180 line_index,
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.get() {
195 lines_to_check.insert(line_num);
196 }
197 } else {
198 if blank_count > self.config.maximum.get() {
199 warnings.extend(self.generate_excess_warnings(
200 blank_start,
201 blank_count,
202 &lines,
203 &lines_to_check,
204 line_index,
205 ));
206 }
207 blank_count = 0;
208 lines_to_check.clear();
209 }
210 }
211
212 let mut consecutive_newlines_at_end: usize = 0;
218 for ch in content.chars().rev() {
219 if ch == '\n' {
220 consecutive_newlines_at_end += 1;
221 } else if ch == '\r' {
222 continue;
224 } else {
225 break;
226 }
227 }
228
229 let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
232
233 if blank_lines_at_eof > 0 {
236 let location = "at end of file";
237
238 let report_line = lines.len();
240
241 let target_newlines = 1;
244 let excess_newlines = consecutive_newlines_at_end - target_newlines;
245
246 warnings.push(LintWarning {
248 rule_name: Some(self.name().to_string()),
249 severity: Severity::Warning,
250 message: format!("Multiple consecutive blank lines {location}"),
251 line: report_line,
252 column: 1,
253 end_line: report_line,
254 end_column: 1,
255 fix: Some(Fix {
256 range: {
257 let keep_chars = content.len() - excess_newlines;
259 log::debug!(
260 "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
261 consecutive_newlines_at_end,
262 blank_lines_at_eof,
263 target_newlines,
264 excess_newlines,
265 content.len(),
266 keep_chars,
267 keep_chars,
268 content.len()
269 );
270 keep_chars..content.len()
271 },
272 replacement: String::new(),
273 }),
274 });
275 }
276
277 Ok(warnings)
278 }
279
280 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
281 let content = ctx.content;
282
283 let mut result = Vec::new();
284 let mut blank_count = 0;
285
286 let mut in_code_block = false;
287 let mut code_block_blanks = Vec::new();
288 let mut in_front_matter = false;
289
290 for filtered_line in ctx.filtered_lines() {
292 let line = filtered_line.content;
293
294 if filtered_line.line_info.in_front_matter {
296 if !in_front_matter {
297 let allowed_blanks = blank_count.min(self.config.maximum.get());
299 if allowed_blanks > 0 {
300 result.extend(vec![""; allowed_blanks]);
301 }
302 blank_count = 0;
303 in_front_matter = true;
304 }
305 result.push(line);
306 continue;
307 } else if in_front_matter {
308 in_front_matter = false;
310 }
311
312 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
314 if !in_code_block {
316 let allowed_blanks = blank_count.min(self.config.maximum.get());
317 if allowed_blanks > 0 {
318 result.extend(vec![""; allowed_blanks]);
319 }
320 blank_count = 0;
321 } else {
322 result.append(&mut code_block_blanks);
324 }
325 in_code_block = !in_code_block;
326 result.push(line);
327 continue;
328 }
329
330 if in_code_block {
331 if line.trim().is_empty() {
332 code_block_blanks.push(line);
333 } else {
334 result.append(&mut code_block_blanks);
335 result.push(line);
336 }
337 } else if line.trim().is_empty() {
338 blank_count += 1;
339 } else {
340 let allowed_blanks = blank_count.min(self.config.maximum.get());
342 if allowed_blanks > 0 {
343 result.extend(vec![""; allowed_blanks]);
344 }
345 blank_count = 0;
346 result.push(line);
347 }
348 }
349
350 let allowed_trailing_blanks = blank_count.min(self.config.maximum.get());
354 if allowed_trailing_blanks > 0 {
355 result.extend(vec![""; allowed_trailing_blanks]);
356 }
357
358 let mut output = result.join("\n");
360 if content.ends_with('\n') {
361 output.push('\n');
362 }
363
364 Ok(output)
365 }
366
367 fn as_any(&self) -> &dyn std::any::Any {
368 self
369 }
370
371 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
372 ctx.content.is_empty() || !ctx.has_char('\n')
374 }
375
376 fn default_config_section(&self) -> Option<(String, toml::Value)> {
377 let default_config = MD012Config::default();
378 let json_value = serde_json::to_value(&default_config).ok()?;
379 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
380
381 if let toml::Value::Table(table) = toml_value {
382 if !table.is_empty() {
383 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
384 } else {
385 None
386 }
387 } else {
388 None
389 }
390 }
391
392 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
393 where
394 Self: Sized,
395 {
396 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
397 Box::new(Self::from_config_struct(rule_config))
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::lint_context::LintContext;
405
406 #[test]
407 fn test_single_blank_line_allowed() {
408 let rule = MD012NoMultipleBlanks::default();
409 let content = "Line 1\n\nLine 2\n\nLine 3";
410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
411 let result = rule.check(&ctx).unwrap();
412 assert!(result.is_empty());
413 }
414
415 #[test]
416 fn test_multiple_blank_lines_flagged() {
417 let rule = MD012NoMultipleBlanks::default();
418 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
420 let result = rule.check(&ctx).unwrap();
421 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
423 assert_eq!(result[1].line, 6);
424 assert_eq!(result[2].line, 7);
425 }
426
427 #[test]
428 fn test_custom_maximum() {
429 let rule = MD012NoMultipleBlanks::new(2);
430 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
432 let result = rule.check(&ctx).unwrap();
433 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
435 }
436
437 #[test]
438 fn test_fix_multiple_blank_lines() {
439 let rule = MD012NoMultipleBlanks::default();
440 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
442 let fixed = rule.fix(&ctx).unwrap();
443 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
444 }
445
446 #[test]
447 fn test_blank_lines_in_code_block() {
448 let rule = MD012NoMultipleBlanks::default();
449 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
451 let result = rule.check(&ctx).unwrap();
452 assert!(result.is_empty()); }
454
455 #[test]
456 fn test_fix_preserves_code_block_blanks() {
457 let rule = MD012NoMultipleBlanks::default();
458 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
460 let fixed = rule.fix(&ctx).unwrap();
461 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
462 }
463
464 #[test]
465 fn test_blank_lines_in_front_matter() {
466 let rule = MD012NoMultipleBlanks::default();
467 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
469 let result = rule.check(&ctx).unwrap();
470 assert!(result.is_empty()); }
472
473 #[test]
474 fn test_blank_lines_at_start() {
475 let rule = MD012NoMultipleBlanks::default();
476 let content = "\n\n\nContent";
477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
478 let result = rule.check(&ctx).unwrap();
479 assert_eq!(result.len(), 2);
480 assert!(result[0].message.contains("at start of file"));
481 }
482
483 #[test]
484 fn test_blank_lines_at_end() {
485 let rule = MD012NoMultipleBlanks::default();
486 let content = "Content\n\n\n";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
488 let result = rule.check(&ctx).unwrap();
489 assert_eq!(result.len(), 1);
490 assert!(result[0].message.contains("at end of file"));
491 }
492
493 #[test]
494 fn test_single_blank_at_eof_flagged() {
495 let rule = MD012NoMultipleBlanks::default();
497 let content = "Content\n\n";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
499 let result = rule.check(&ctx).unwrap();
500 assert_eq!(result.len(), 1);
501 assert!(result[0].message.contains("at end of file"));
502 }
503
504 #[test]
505 fn test_whitespace_only_lines() {
506 let rule = MD012NoMultipleBlanks::default();
507 let content = "Line 1\n \n\t\nLine 2";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
509 let result = rule.check(&ctx).unwrap();
510 assert_eq!(result.len(), 1); }
512
513 #[test]
514 fn test_indented_code_blocks() {
515 let rule = MD012NoMultipleBlanks::default();
516 let content = "Text\n\n code\n \n \n more code\n\nText";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
518 let result = rule.check(&ctx).unwrap();
519 assert_eq!(result.len(), 1);
522 assert_eq!(result[0].line, 5); }
524
525 #[test]
526 fn test_fix_with_final_newline() {
527 let rule = MD012NoMultipleBlanks::default();
528 let content = "Line 1\n\n\nLine 2\n";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530 let fixed = rule.fix(&ctx).unwrap();
531 assert_eq!(fixed, "Line 1\n\nLine 2\n");
532 assert!(fixed.ends_with('\n'));
533 }
534
535 #[test]
536 fn test_empty_content() {
537 let rule = MD012NoMultipleBlanks::default();
538 let content = "";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
540 let result = rule.check(&ctx).unwrap();
541 assert!(result.is_empty());
542 }
543
544 #[test]
545 fn test_nested_code_blocks() {
546 let rule = MD012NoMultipleBlanks::default();
547 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
549 let result = rule.check(&ctx).unwrap();
550 assert!(result.is_empty());
551 }
552
553 #[test]
554 fn test_unclosed_code_block() {
555 let rule = MD012NoMultipleBlanks::default();
556 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
557 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
558 let result = rule.check(&ctx).unwrap();
559 assert!(result.is_empty()); }
561
562 #[test]
563 fn test_mixed_fence_styles() {
564 let rule = MD012NoMultipleBlanks::default();
565 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568 assert!(result.is_empty()); }
570
571 #[test]
572 fn test_config_from_toml() {
573 let mut config = crate::config::Config::default();
574 let mut rule_config = crate::config::RuleConfig::default();
575 rule_config
576 .values
577 .insert("maximum".to_string(), toml::Value::Integer(3));
578 config.rules.insert("MD012".to_string(), rule_config);
579
580 let rule = MD012NoMultipleBlanks::from_config(&config);
581 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
583 let result = rule.check(&ctx).unwrap();
584 assert!(result.is_empty()); }
586
587 #[test]
588 fn test_blank_lines_between_sections() {
589 let rule = MD012NoMultipleBlanks::default();
590 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592 let result = rule.check(&ctx).unwrap();
593 assert_eq!(result.len(), 1);
594 assert_eq!(result[0].line, 5);
595 }
596
597 #[test]
598 fn test_fix_preserves_indented_code() {
599 let rule = MD012NoMultipleBlanks::default();
600 let content = "Text\n\n\n code\n \n more code\n\n\nText";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
602 let fixed = rule.fix(&ctx).unwrap();
603 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
605 }
606
607 #[test]
608 fn test_edge_case_only_blanks() {
609 let rule = MD012NoMultipleBlanks::default();
610 let content = "\n\n\n";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
612 let result = rule.check(&ctx).unwrap();
613 assert_eq!(result.len(), 1);
615 assert!(result[0].message.contains("at end of file"));
616 }
617}