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 if !has_potential_blanks {
61 return Ok(Vec::new());
62 }
63
64 let _line_index = LineIndex::new(content.to_string());
65
66 let mut warnings = Vec::new();
67
68 let mut blank_count = 0;
70 let mut blank_start = 0;
71 let mut in_code_block = false;
72 let mut in_front_matter = false;
73 let mut code_fence_marker = "";
74
75 let mut lines_to_check: HashSet<usize> = HashSet::new();
77
78 for (line_num, &line) in lines.iter().enumerate() {
79 let trimmed = line.trim_start();
80
81 if trimmed == "---" {
83 if line_num == 0 {
84 in_front_matter = true;
85 } else if in_front_matter {
86 in_front_matter = false;
87 }
88 if blank_count > self.config.maximum {
90 let location = if blank_start == 0 {
91 "at start of file"
92 } else {
93 "between content"
94 };
95 for i in self.config.maximum..blank_count {
96 let excess_line_num = blank_start + i;
97 if lines_to_check.contains(&excess_line_num) {
98 let excess_line = excess_line_num + 1;
99 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
100 let (start_line, start_col, end_line, end_col) =
101 calculate_line_range(excess_line, excess_line_content);
102 warnings.push(LintWarning {
103 rule_name: Some(self.name()),
104 severity: Severity::Warning,
105 message: format!(
106 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
107 location, self.config.maximum, blank_count
108 ),
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!(
152 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
153 location, self.config.maximum, blank_count
154 ),
155 line: start_line,
156 column: start_col,
157 end_line,
158 end_column: end_col,
159 fix: Some(Fix {
160 range: {
161 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
162 let line_end = _line_index
163 .get_line_start_byte(excess_line + 1)
164 .unwrap_or(line_start + 1);
165 line_start..line_end
166 },
167 replacement: String::new(),
168 }),
169 });
170 }
171 }
172 }
173
174 if !in_code_block {
175 in_code_block = true;
177 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
178 } else if trimmed.starts_with(code_fence_marker) {
179 in_code_block = false;
181 code_fence_marker = "";
182 }
183 blank_count = 0;
184 lines_to_check.clear();
185 continue;
186 }
187
188 if in_code_block || in_front_matter {
190 blank_count = 0;
192 continue;
193 }
194
195 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
197 if is_indented_code {
198 if blank_count > self.config.maximum {
200 let location = if blank_start == 0 {
201 "at start of file"
202 } else {
203 "between content"
204 };
205 for i in self.config.maximum..blank_count {
206 let excess_line_num = blank_start + i;
207 if lines_to_check.contains(&excess_line_num) {
208 let excess_line = excess_line_num + 1;
209 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
210 let (start_line, start_col, end_line, end_col) =
211 calculate_line_range(excess_line, excess_line_content);
212 warnings.push(LintWarning {
213 rule_name: Some(self.name()),
214 severity: Severity::Warning,
215 message: format!(
216 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
217 location, self.config.maximum, blank_count
218 ),
219 line: start_line,
220 column: start_col,
221 end_line,
222 end_column: end_col,
223 fix: Some(Fix {
224 range: {
225 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
226 let line_end = _line_index
227 .get_line_start_byte(excess_line + 1)
228 .unwrap_or(line_start + 1);
229 line_start..line_end
230 },
231 replacement: String::new(),
232 }),
233 });
234 }
235 }
236 }
237 blank_count = 0;
238 lines_to_check.clear();
239 continue;
240 }
241
242 if line.trim().is_empty() {
243 if blank_count == 0 {
244 blank_start = line_num;
245 }
246 blank_count += 1;
247 if blank_count > self.config.maximum {
249 lines_to_check.insert(line_num);
250 }
251 } else {
252 if blank_count > self.config.maximum {
253 let location = if blank_start == 0 {
255 "at start of file"
256 } else {
257 "between content"
258 };
259
260 for i in self.config.maximum..blank_count {
262 let excess_line_num = blank_start + i;
263 if lines_to_check.contains(&excess_line_num) {
264 let excess_line = excess_line_num + 1; let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
266
267 let (start_line, start_col, end_line, end_col) =
269 calculate_line_range(excess_line, excess_line_content);
270
271 warnings.push(LintWarning {
272 rule_name: Some(self.name()),
273 severity: Severity::Warning,
274 message: format!(
275 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
276 location, self.config.maximum, blank_count
277 ),
278 line: start_line,
279 column: start_col,
280 end_line,
281 end_column: end_col,
282 fix: Some(Fix {
283 range: {
284 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
286 let line_end = _line_index
287 .get_line_start_byte(excess_line + 1)
288 .unwrap_or(line_start + 1);
289 line_start..line_end
290 },
291 replacement: String::new(), }),
293 });
294 }
295 }
296 }
297 blank_count = 0;
298 lines_to_check.clear();
299 }
300 }
301
302 if blank_count > self.config.maximum {
304 let location = "at end of file";
305 for i in self.config.maximum..blank_count {
306 let excess_line_num = blank_start + i;
307 if lines_to_check.contains(&excess_line_num) {
308 let excess_line = excess_line_num + 1;
309 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
310
311 let (start_line, start_col, end_line, end_col) =
313 calculate_line_range(excess_line, excess_line_content);
314
315 warnings.push(LintWarning {
316 rule_name: Some(self.name()),
317 severity: Severity::Warning,
318 message: format!(
319 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
320 location, self.config.maximum, blank_count
321 ),
322 line: start_line,
323 column: start_col,
324 end_line,
325 end_column: end_col,
326 fix: Some(Fix {
327 range: {
328 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
330 let line_end = _line_index
331 .get_line_start_byte(excess_line + 1)
332 .unwrap_or(line_start + 1);
333 line_start..line_end
334 },
335 replacement: String::new(),
336 }),
337 });
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.content.contains("\n\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
468impl crate::utils::document_structure::DocumentStructureExtensions for MD012NoMultipleBlanks {
469 fn has_relevant_elements(
470 &self,
471 ctx: &crate::lint_context::LintContext,
472 _structure: &crate::utils::document_structure::DocumentStructure,
473 ) -> bool {
474 !ctx.content.is_empty()
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::lint_context::LintContext;
483
484 #[test]
485 fn test_single_blank_line_allowed() {
486 let rule = MD012NoMultipleBlanks::default();
487 let content = "Line 1\n\nLine 2\n\nLine 3";
488 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
489 let result = rule.check(&ctx).unwrap();
490 assert!(result.is_empty());
491 }
492
493 #[test]
494 fn test_multiple_blank_lines_flagged() {
495 let rule = MD012NoMultipleBlanks::default();
496 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
497 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
498 let result = rule.check(&ctx).unwrap();
499 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
501 assert_eq!(result[1].line, 6);
502 assert_eq!(result[2].line, 7);
503 }
504
505 #[test]
506 fn test_custom_maximum() {
507 let rule = MD012NoMultipleBlanks::new(2);
508 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
509 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
510 let result = rule.check(&ctx).unwrap();
511 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
513 }
514
515 #[test]
516 fn test_fix_multiple_blank_lines() {
517 let rule = MD012NoMultipleBlanks::default();
518 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
520 let fixed = rule.fix(&ctx).unwrap();
521 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
522 }
523
524 #[test]
525 fn test_blank_lines_in_code_block() {
526 let rule = MD012NoMultipleBlanks::default();
527 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
529 let result = rule.check(&ctx).unwrap();
530 assert!(result.is_empty()); }
532
533 #[test]
534 fn test_fix_preserves_code_block_blanks() {
535 let rule = MD012NoMultipleBlanks::default();
536 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
538 let fixed = rule.fix(&ctx).unwrap();
539 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
540 }
541
542 #[test]
543 fn test_blank_lines_in_front_matter() {
544 let rule = MD012NoMultipleBlanks::default();
545 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let result = rule.check(&ctx).unwrap();
548 assert!(result.is_empty()); }
550
551 #[test]
552 fn test_blank_lines_at_start() {
553 let rule = MD012NoMultipleBlanks::default();
554 let content = "\n\n\nContent";
555 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557 assert_eq!(result.len(), 2);
558 assert!(result[0].message.contains("at start of file"));
559 }
560
561 #[test]
562 fn test_blank_lines_at_end() {
563 let rule = MD012NoMultipleBlanks::default();
564 let content = "Content\n\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(), 2); }
682}