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 blank_count = 0;
90 continue;
91 }
92
93 if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
95 if !in_code_block {
96 in_code_block = true;
98 code_fence_marker = if trimmed.starts_with("```") { "```" } else { "~~~" };
99 } else if trimmed.starts_with(code_fence_marker) {
100 in_code_block = false;
102 code_fence_marker = "";
103 }
104 blank_count = 0;
106 continue;
107 }
108
109 if in_code_block || in_front_matter {
111 blank_count = 0;
113 continue;
114 }
115
116 let is_indented_code = line.len() >= 4 && line.starts_with(" ") && !line.trim().is_empty();
118 if is_indented_code {
119 blank_count = 0;
120 continue;
121 }
122
123 if line.trim().is_empty() {
124 if blank_count == 0 {
125 blank_start = line_num;
126 }
127 blank_count += 1;
128 if blank_count > self.config.maximum {
130 lines_to_check.insert(line_num);
131 }
132 } else {
133 if blank_count > self.config.maximum {
134 let location = if blank_start == 0 {
136 "at start of file"
137 } else {
138 "between content"
139 };
140
141 for i in self.config.maximum..blank_count {
143 let excess_line_num = blank_start + i;
144 if lines_to_check.contains(&excess_line_num) {
145 let excess_line = excess_line_num + 1; let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
147
148 let (start_line, start_col, end_line, end_col) =
150 calculate_line_range(excess_line, excess_line_content);
151
152 warnings.push(LintWarning {
153 rule_name: Some(self.name()),
154 severity: Severity::Warning,
155 message: format!(
156 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
157 location, self.config.maximum, blank_count
158 ),
159 line: start_line,
160 column: start_col,
161 end_line,
162 end_column: end_col,
163 fix: Some(Fix {
164 range: {
165 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
167 let line_end = _line_index
168 .get_line_start_byte(excess_line + 1)
169 .unwrap_or(line_start + 1);
170 line_start..line_end
171 },
172 replacement: String::new(), }),
174 });
175 }
176 }
177 }
178 blank_count = 0;
179 lines_to_check.clear();
180 }
181 }
182
183 if blank_count > self.config.maximum {
185 let location = "at end of file";
186 for i in self.config.maximum..blank_count {
187 let excess_line_num = blank_start + i;
188 if lines_to_check.contains(&excess_line_num) {
189 let excess_line = excess_line_num + 1;
190 let excess_line_content = lines.get(excess_line_num).unwrap_or(&"");
191
192 let (start_line, start_col, end_line, end_col) =
194 calculate_line_range(excess_line, excess_line_content);
195
196 warnings.push(LintWarning {
197 rule_name: Some(self.name()),
198 severity: Severity::Warning,
199 message: format!(
200 "Multiple consecutive blank lines {} (Expected: {}; Actual: {})",
201 location, self.config.maximum, blank_count
202 ),
203 line: start_line,
204 column: start_col,
205 end_line,
206 end_column: end_col,
207 fix: Some(Fix {
208 range: {
209 let line_start = _line_index.get_line_start_byte(excess_line).unwrap_or(0);
211 let line_end = _line_index
212 .get_line_start_byte(excess_line + 1)
213 .unwrap_or(line_start + 1);
214 line_start..line_end
215 },
216 replacement: String::new(),
217 }),
218 });
219 }
220 }
221 }
222
223 Ok(warnings)
224 }
225
226 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
227 let content = ctx.content;
228 let _line_index = LineIndex::new(content.to_string());
229
230 let mut result = Vec::new();
231
232 let mut blank_count = 0;
233
234 let lines: Vec<&str> = content.lines().collect();
235
236 let mut in_code_block = false;
237
238 let mut in_front_matter = false;
239
240 let mut code_block_blanks = Vec::new();
241
242 for &line in lines.iter() {
243 if line.trim_start().starts_with("```") || line.trim_start().starts_with("~~~") {
245 if !in_code_block {
247 let allowed_blanks = blank_count.min(self.config.maximum);
248 if allowed_blanks > 0 {
249 result.extend(vec![""; allowed_blanks]);
250 }
251 blank_count = 0;
252 } else {
253 result.append(&mut code_block_blanks);
255 }
256 in_code_block = !in_code_block;
257 result.push(line);
258 continue;
259 }
260
261 if line.trim() == "---" {
262 in_front_matter = !in_front_matter;
263 if blank_count > 0 {
264 result.extend(vec![""; blank_count]);
265 blank_count = 0;
266 }
267 result.push(line);
268 continue;
269 }
270
271 if in_code_block {
272 if line.trim().is_empty() {
273 code_block_blanks.push(line);
274 } else {
275 result.append(&mut code_block_blanks);
276 result.push(line);
277 }
278 } else if in_front_matter {
279 if blank_count > 0 {
280 result.extend(vec![""; blank_count]);
281 blank_count = 0;
282 }
283 result.push(line);
284 } else if line.trim().is_empty() {
285 blank_count += 1;
286 } else {
287 let allowed_blanks = blank_count.min(self.config.maximum);
289 if allowed_blanks > 0 {
290 result.extend(vec![""; allowed_blanks]);
291 }
292 blank_count = 0;
293 result.push(line);
294 }
295 }
296
297 if !in_code_block {
299 let allowed_blanks = blank_count.min(self.config.maximum);
300 if allowed_blanks > 0 {
301 result.extend(vec![""; allowed_blanks]);
302 }
303 }
304
305 let mut output = result.join("\n");
308 if content.ends_with('\n') {
309 output.push('\n');
310 }
311
312 Ok(output)
313 }
314
315 fn as_any(&self) -> &dyn std::any::Any {
316 self
317 }
318
319 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
320 ctx.content.is_empty() || !ctx.content.contains("\n\n")
322 }
323
324 fn default_config_section(&self) -> Option<(String, toml::Value)> {
325 let default_config = MD012Config::default();
326 let json_value = serde_json::to_value(&default_config).ok()?;
327 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
328
329 if let toml::Value::Table(table) = toml_value {
330 if !table.is_empty() {
331 Some((MD012Config::RULE_NAME.to_string(), toml::Value::Table(table)))
332 } else {
333 None
334 }
335 } else {
336 None
337 }
338 }
339
340 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
341 where
342 Self: Sized,
343 {
344 let rule_config = crate::rule_config_serde::load_rule_config::<MD012Config>(config);
345 Box::new(Self::from_config_struct(rule_config))
346 }
347}
348
349impl crate::utils::document_structure::DocumentStructureExtensions for MD012NoMultipleBlanks {
350 fn has_relevant_elements(
351 &self,
352 ctx: &crate::lint_context::LintContext,
353 _structure: &crate::utils::document_structure::DocumentStructure,
354 ) -> bool {
355 !ctx.content.is_empty()
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::lint_context::LintContext;
364
365 #[test]
366 fn test_single_blank_line_allowed() {
367 let rule = MD012NoMultipleBlanks::default();
368 let content = "Line 1\n\nLine 2\n\nLine 3";
369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
370 let result = rule.check(&ctx).unwrap();
371 assert!(result.is_empty());
372 }
373
374 #[test]
375 fn test_multiple_blank_lines_flagged() {
376 let rule = MD012NoMultipleBlanks::default();
377 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
379 let result = rule.check(&ctx).unwrap();
380 assert_eq!(result.len(), 3); assert_eq!(result[0].line, 3);
382 assert_eq!(result[1].line, 6);
383 assert_eq!(result[2].line, 7);
384 }
385
386 #[test]
387 fn test_custom_maximum() {
388 let rule = MD012NoMultipleBlanks::new(2);
389 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
391 let result = rule.check(&ctx).unwrap();
392 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 7);
394 }
395
396 #[test]
397 fn test_fix_multiple_blank_lines() {
398 let rule = MD012NoMultipleBlanks::default();
399 let content = "Line 1\n\n\nLine 2\n\n\n\nLine 3";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
401 let fixed = rule.fix(&ctx).unwrap();
402 assert_eq!(fixed, "Line 1\n\nLine 2\n\nLine 3");
403 }
404
405 #[test]
406 fn test_blank_lines_in_code_block() {
407 let rule = MD012NoMultipleBlanks::default();
408 let content = "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter";
409 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
410 let result = rule.check(&ctx).unwrap();
411 assert!(result.is_empty()); }
413
414 #[test]
415 fn test_fix_preserves_code_block_blanks() {
416 let rule = MD012NoMultipleBlanks::default();
417 let content = "Before\n\n\n```\ncode\n\n\n\nmore code\n```\n\n\nAfter";
418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
419 let fixed = rule.fix(&ctx).unwrap();
420 assert_eq!(fixed, "Before\n\n```\ncode\n\n\n\nmore code\n```\n\nAfter");
421 }
422
423 #[test]
424 fn test_blank_lines_in_front_matter() {
425 let rule = MD012NoMultipleBlanks::default();
426 let content = "---\ntitle: Test\n\n\nauthor: Me\n---\n\nContent";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429 assert!(result.is_empty()); }
431
432 #[test]
433 fn test_blank_lines_at_start() {
434 let rule = MD012NoMultipleBlanks::default();
435 let content = "\n\n\nContent";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 2);
439 assert!(result[0].message.contains("at start of file"));
440 }
441
442 #[test]
443 fn test_blank_lines_at_end() {
444 let rule = MD012NoMultipleBlanks::default();
445 let content = "Content\n\n\n";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(result.len(), 1);
449 assert!(result[0].message.contains("at end of file"));
450 }
451
452 #[test]
453 fn test_whitespace_only_lines() {
454 let rule = MD012NoMultipleBlanks::default();
455 let content = "Line 1\n \n\t\nLine 2";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
457 let result = rule.check(&ctx).unwrap();
458 assert_eq!(result.len(), 1); }
460
461 #[test]
462 fn test_indented_code_blocks() {
463 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);
466 let result = rule.check(&ctx).unwrap();
467 assert!(result.is_empty()); }
469
470 #[test]
471 fn test_fix_with_final_newline() {
472 let rule = MD012NoMultipleBlanks::default();
473 let content = "Line 1\n\n\nLine 2\n";
474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
475 let fixed = rule.fix(&ctx).unwrap();
476 assert_eq!(fixed, "Line 1\n\nLine 2\n");
477 assert!(fixed.ends_with('\n'));
478 }
479
480 #[test]
481 fn test_empty_content() {
482 let rule = MD012NoMultipleBlanks::default();
483 let content = "";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
485 let result = rule.check(&ctx).unwrap();
486 assert!(result.is_empty());
487 }
488
489 #[test]
490 fn test_nested_code_blocks() {
491 let rule = MD012NoMultipleBlanks::default();
492 let content = "Before\n\n~~~\nouter\n\n```\ninner\n\n\n```\n\n~~~\n\nAfter";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494 let result = rule.check(&ctx).unwrap();
495 assert!(result.is_empty());
496 }
497
498 #[test]
499 fn test_unclosed_code_block() {
500 let rule = MD012NoMultipleBlanks::default();
501 let content = "Before\n\n```\ncode\n\n\n\nno closing fence";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
503 let result = rule.check(&ctx).unwrap();
504 assert!(result.is_empty()); }
506
507 #[test]
508 fn test_mixed_fence_styles() {
509 let rule = MD012NoMultipleBlanks::default();
510 let content = "Before\n\n```\ncode\n\n\n~~~\n\nAfter";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty()); }
515
516 #[test]
517 fn test_config_from_toml() {
518 let mut config = crate::config::Config::default();
519 let mut rule_config = crate::config::RuleConfig::default();
520 rule_config
521 .values
522 .insert("maximum".to_string(), toml::Value::Integer(3));
523 config.rules.insert("MD012".to_string(), rule_config);
524
525 let rule = MD012NoMultipleBlanks::from_config(&config);
526 let content = "Line 1\n\n\n\nLine 2"; let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty()); }
531
532 #[test]
533 fn test_blank_lines_between_sections() {
534 let rule = MD012NoMultipleBlanks::default();
535 let content = "# Section 1\n\nContent\n\n\n# Section 2\n\nContent";
536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
537 let result = rule.check(&ctx).unwrap();
538 assert_eq!(result.len(), 1);
539 assert_eq!(result[0].line, 5);
540 }
541
542 #[test]
543 fn test_fix_preserves_indented_code() {
544 let rule = MD012NoMultipleBlanks::default();
545 let content = "Text\n\n\n code\n \n more code\n\n\nText";
546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
547 let fixed = rule.fix(&ctx).unwrap();
548 assert_eq!(fixed, "Text\n\n code\n\n more code\n\nText");
550 }
551
552 #[test]
553 fn test_edge_case_only_blanks() {
554 let rule = MD012NoMultipleBlanks::default();
555 let content = "\n\n\n";
556 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
557 let result = rule.check(&ctx).unwrap();
558 assert_eq!(result.len(), 2); }
560}