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
125 let mut lines_to_check: HashSet<usize> = HashSet::new();
127
128 for filtered_line in ctx.filtered_lines().skip_front_matter().skip_code_blocks() {
132 let line_num = filtered_line.line_num - 1; let line = filtered_line.content;
134
135 if line.trim().is_empty() {
136 if blank_count == 0 {
137 blank_start = line_num;
138 }
139 blank_count += 1;
140 if blank_count > self.config.maximum.get() {
142 lines_to_check.insert(line_num);
143 }
144 } else {
145 if blank_count > self.config.maximum.get() {
146 warnings.extend(self.generate_excess_warnings(
147 blank_start,
148 blank_count,
149 &lines,
150 &lines_to_check,
151 line_index,
152 ));
153 }
154 blank_count = 0;
155 lines_to_check.clear();
156 }
157 }
158
159 let mut consecutive_newlines_at_end: usize = 0;
165 for ch in content.chars().rev() {
166 if ch == '\n' {
167 consecutive_newlines_at_end += 1;
168 } else if ch == '\r' {
169 continue;
171 } else {
172 break;
173 }
174 }
175
176 let blank_lines_at_eof = consecutive_newlines_at_end.saturating_sub(1);
179
180 if blank_lines_at_eof > 0 {
183 let location = "at end of file";
184
185 let report_line = lines.len();
187
188 let target_newlines = 1;
191 let excess_newlines = consecutive_newlines_at_end - target_newlines;
192
193 warnings.push(LintWarning {
195 rule_name: Some(self.name().to_string()),
196 severity: Severity::Warning,
197 message: format!("Multiple consecutive blank lines {location}"),
198 line: report_line,
199 column: 1,
200 end_line: report_line,
201 end_column: 1,
202 fix: Some(Fix {
203 range: {
204 let keep_chars = content.len() - excess_newlines;
206 log::debug!(
207 "MD012 EOF: consecutive_newlines_at_end={}, blank_lines_at_eof={}, target_newlines={}, excess_newlines={}, content_len={}, keep_chars={}, range={}..{}",
208 consecutive_newlines_at_end,
209 blank_lines_at_eof,
210 target_newlines,
211 excess_newlines,
212 content.len(),
213 keep_chars,
214 keep_chars,
215 content.len()
216 );
217 keep_chars..content.len()
218 },
219 replacement: String::new(),
220 }),
221 });
222 }
223
224 Ok(warnings)
225 }
226
227 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
228 let content = ctx.content;
229
230 let mut result = Vec::new();
231 let mut blank_count = 0;
232
233 let mut in_code_block = false;
234 let mut code_block_blanks = Vec::new();
235 let mut in_front_matter = false;
236
237 for filtered_line in ctx.filtered_lines() {
239 let line = filtered_line.content;
240
241 if filtered_line.line_info.in_front_matter {
243 if !in_front_matter {
244 let allowed_blanks = blank_count.min(self.config.maximum.get());
246 if allowed_blanks > 0 {
247 result.extend(vec![""; allowed_blanks]);
248 }
249 blank_count = 0;
250 in_front_matter = true;
251 }
252 result.push(line);
253 continue;
254 } else if in_front_matter {
255 in_front_matter = false;
257 }
258
259 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
261 if !in_code_block {
263 let allowed_blanks = blank_count.min(self.config.maximum.get());
264 if allowed_blanks > 0 {
265 result.extend(vec![""; allowed_blanks]);
266 }
267 blank_count = 0;
268 } else {
269 result.append(&mut code_block_blanks);
271 }
272 in_code_block = !in_code_block;
273 result.push(line);
274 continue;
275 }
276
277 if in_code_block {
278 if line.trim().is_empty() {
279 code_block_blanks.push(line);
280 } else {
281 result.append(&mut code_block_blanks);
282 result.push(line);
283 }
284 } else if line.trim().is_empty() {
285 blank_count += 1;
286 } else {
287 let allowed_blanks = blank_count.min(self.config.maximum.get());
289 if allowed_blanks > 0 {
290 result.extend(vec![""; allowed_blanks]);
291 }
292 blank_count = 0;
293 result.push(line);
294 }
295 }
296
297 let allowed_trailing_blanks = blank_count.min(self.config.maximum.get());
301 if allowed_trailing_blanks > 0 {
302 result.extend(vec![""; allowed_trailing_blanks]);
303 }
304
305 let mut output = result.join("\n");
307 if content.ends_with('\n') {
308 output.push('\n');
309 }
310
311 Ok(output)
312 }
313
314 fn as_any(&self) -> &dyn std::any::Any {
315 self
316 }
317
318 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
319 ctx.content.is_empty() || !ctx.has_char('\n')
321 }
322
323 fn default_config_section(&self) -> Option<(String, toml::Value)> {
324 let default_config = MD012Config::default();
325 let json_value = serde_json::to_value(&default_config).ok()?;
326 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
327
328 if let toml::Value::Table(table) = toml_value {
329 if !table.is_empty() {
330 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
331 } else {
332 None
333 }
334 } else {
335 None
336 }
337 }
338
339 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
340 where
341 Self: Sized,
342 {
343 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
344 Box::new(Self::from_config_struct(rule_config))
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::lint_context::LintContext;
352
353 #[test]
354 fn test_single_blank_line_allowed() {
355 let rule = MD012NoMultipleBlanks::default();
356 let content = "Line 1\n\nLine 2\n\nLine 3";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359 assert!(result.is_empty());
360 }
361
362 #[test]
363 fn test_multiple_blank_lines_flagged() {
364 let rule = MD012NoMultipleBlanks::default();
365 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
370 assert_eq!(result[1].line, 6);
371 assert_eq!(result[2].line, 7);
372 }
373
374 #[test]
375 fn test_custom_maximum() {
376 let rule = MD012NoMultipleBlanks::new(2);
377 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
382 }
383
384 #[test]
385 fn test_fix_multiple_blank_lines() {
386 let rule = MD012NoMultipleBlanks::default();
387 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let fixed = rule.fix(&ctx).unwrap();
390 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
391 }
392
393 #[test]
394 fn test_blank_lines_in_code_block() {
395 let rule = MD012NoMultipleBlanks::default();
396 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert!(result.is_empty()); }
401
402 #[test]
403 fn test_fix_preserves_code_block_blanks() {
404 let rule = MD012NoMultipleBlanks::default();
405 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
406 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
407 let fixed = rule.fix(&ctx).unwrap();
408 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
409 }
410
411 #[test]
412 fn test_blank_lines_in_front_matter() {
413 let rule = MD012NoMultipleBlanks::default();
414 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert!(result.is_empty()); }
419
420 #[test]
421 fn test_blank_lines_at_start() {
422 let rule = MD012NoMultipleBlanks::default();
423 let content = "\n\n\nContent";
424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
425 let result = rule.check(&ctx).unwrap();
426 assert_eq!(result.len(), 2);
427 assert!(result[0].message.contains("at start of file"));
428 }
429
430 #[test]
431 fn test_blank_lines_at_end() {
432 let rule = MD012NoMultipleBlanks::default();
433 let content = "Content\n\n\n";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert_eq!(result.len(), 1);
437 assert!(result[0].message.contains("at end of file"));
438 }
439
440 #[test]
441 fn test_single_blank_at_eof_flagged() {
442 let rule = MD012NoMultipleBlanks::default();
444 let content = "Content\n\n";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 1);
448 assert!(result[0].message.contains("at end of file"));
449 }
450
451 #[test]
452 fn test_whitespace_only_lines() {
453 let rule = MD012NoMultipleBlanks::default();
454 let content = "Line 1\n \n\t\nLine 2";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert_eq!(result.len(), 1); }
459
460 #[test]
461 fn test_indented_code_blocks() {
462 let rule = MD012NoMultipleBlanks::default();
464 let content = "Text\n\n code\n \n \n more code\n\nText";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467 assert!(result.is_empty(), "Should not flag blanks inside indented code blocks");
468 }
469
470 #[test]
471 fn test_blanks_in_indented_code_block() {
472 let content = " code line 1\n\n\n code line 2\n";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
475 let rule = MD012NoMultipleBlanks::default();
476 let warnings = rule.check(&ctx).unwrap();
477 assert!(warnings.is_empty(), "Should not flag blanks in indented code");
478 }
479
480 #[test]
481 fn test_blanks_in_indented_code_block_with_heading() {
482 let content = "# Heading\n\n code line 1\n\n\n code line 2\n\nMore text\n";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let rule = MD012NoMultipleBlanks::default();
486 let warnings = rule.check(&ctx).unwrap();
487 assert!(
488 warnings.is_empty(),
489 "Should not flag blanks in indented code after heading"
490 );
491 }
492
493 #[test]
494 fn test_blanks_after_indented_code_block_flagged() {
495 let content = "# Heading\n\n code line\n\n\n\nMore text\n";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
498 let rule = MD012NoMultipleBlanks::default();
499 let warnings = rule.check(&ctx).unwrap();
500 assert_eq!(warnings.len(), 2, "Should flag blanks after indented code block ends");
502 }
503
504 #[test]
505 fn test_fix_with_final_newline() {
506 let rule = MD012NoMultipleBlanks::default();
507 let content = "Line 1\n\n\nLine 2\n";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let fixed = rule.fix(&ctx).unwrap();
510 assert_eq!(fixed, "Line 1\n\nLine 2\n");
511 assert!(fixed.ends_with('\n'));
512 }
513
514 #[test]
515 fn test_empty_content() {
516 let rule = MD012NoMultipleBlanks::default();
517 let content = "";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519 let result = rule.check(&ctx).unwrap();
520 assert!(result.is_empty());
521 }
522
523 #[test]
524 fn test_nested_code_blocks() {
525 let rule = MD012NoMultipleBlanks::default();
526 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
527 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty());
530 }
531
532 #[test]
533 fn test_unclosed_code_block() {
534 let rule = MD012NoMultipleBlanks::default();
535 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
537 let result = rule.check(&ctx).unwrap();
538 assert!(result.is_empty()); }
540
541 #[test]
542 fn test_mixed_fence_styles() {
543 let rule = MD012NoMultipleBlanks::default();
544 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
545 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
546 let result = rule.check(&ctx).unwrap();
547 assert!(result.is_empty()); }
549
550 #[test]
551 fn test_config_from_toml() {
552 let mut config = crate::config::Config::default();
553 let mut rule_config = crate::config::RuleConfig::default();
554 rule_config
555 .values
556 .insert("maximum".to_string(), toml::Value::Integer(3));
557 config.rules.insert("MD012".to_string(), rule_config);
558
559 let rule = MD012NoMultipleBlanks::from_config(&config);
560 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty()); }
565
566 #[test]
567 fn test_blank_lines_between_sections() {
568 let rule = MD012NoMultipleBlanks::default();
569 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
570 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
571 let result = rule.check(&ctx).unwrap();
572 assert_eq!(result.len(), 1);
573 assert_eq!(result[0].line, 5);
574 }
575
576 #[test]
577 fn test_fix_preserves_indented_code() {
578 let rule = MD012NoMultipleBlanks::default();
579 let content = "Text\n\n\n code\n \n more code\n\n\nText";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let fixed = rule.fix(&ctx).unwrap();
582 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
584 }
585
586 #[test]
587 fn test_edge_case_only_blanks() {
588 let rule = MD012NoMultipleBlanks::default();
589 let content = "\n\n\n";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert_eq!(result.len(), 1);
594 assert!(result[0].message.contains("at end of file"));
595 }
596}