1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD012MultipleBlankLinesTable {
14 #[serde(default)]
15 pub maximum: usize,
16}
17
18impl Default for MD012MultipleBlankLinesTable {
19 fn default() -> Self {
20 Self { maximum: 1 }
21 }
22}
23
24pub(crate) struct MD012Linter {
30 context: Rc<Context>,
31 violations: Vec<RuleViolation>,
32}
33
34impl MD012Linter {
35 pub fn new(context: Rc<Context>) -> Self {
36 Self {
37 context,
38 violations: Vec::new(),
39 }
40 }
41
42 fn analyze_all_lines(&mut self) {
45 let settings = &self.context.config.linters.settings.multiple_blank_lines;
46 let lines = self.context.lines.borrow();
47 let maximum = settings.maximum;
48
49 let mut code_block_mask = vec![false; lines.len()];
53 self.populate_code_block_mask(&mut code_block_mask);
54
55 let mut consecutive_blanks = 0;
56
57 for (line_index, line) in lines.iter().enumerate() {
58 let is_blank = line.trim().is_empty();
59 let is_in_code_block = code_block_mask.get(line_index).copied().unwrap_or(false);
61
62 if is_blank && !is_in_code_block {
63 consecutive_blanks += 1;
64
65 if consecutive_blanks > maximum {
68 let violation = self.create_violation(line_index, consecutive_blanks, maximum);
69 self.violations.push(violation);
70 }
71 } else {
72 consecutive_blanks = 0;
73 }
74 }
75
76 }
79
80 fn populate_code_block_mask(&self, mask: &mut [bool]) {
89 let node_cache = self.context.node_cache.borrow();
90 let lines = self.context.lines.borrow();
91
92 if let Some(indented_blocks) = node_cache.get("indented_code_block") {
94 for node_info in indented_blocks {
95 for line_num in node_info.line_start..=node_info.line_end {
96 if let Some(is_in_block) = mask.get_mut(line_num) {
97 *is_in_block = true;
98 }
99 }
100 }
101 }
102
103 if let Some(fenced_blocks) = node_cache.get("fenced_code_block") {
105 for node_info in fenced_blocks {
106 let mut end_line = node_info.line_end;
107
108 if let Some(last_line) = lines.get(end_line) {
111 if last_line.trim().is_empty() {
112 if let Some(prev_line) = lines.get(end_line.saturating_sub(1)) {
114 if prev_line.trim().starts_with("```") {
115 end_line = end_line.saturating_sub(1);
118 }
119 }
120 }
121 }
122
123 for line_num in node_info.line_start..=end_line {
124 if let Some(is_in_block) = mask.get_mut(line_num) {
125 *is_in_block = true;
126 }
127 }
128 }
129 }
130 }
131
132 fn create_violation(
134 &self,
135 line_index: usize,
136 consecutive_blanks: usize,
137 maximum: usize,
138 ) -> RuleViolation {
139 let message = format!(
140 "Multiple consecutive blank lines [Expected: {maximum} or fewer; Actual: {consecutive_blanks}]"
141 );
142
143 RuleViolation::new(
144 &MD012,
145 message,
146 self.context.file_path.clone(),
147 range_from_tree_sitter(&tree_sitter::Range {
148 start_byte: 0,
154 end_byte: 0,
155 start_point: tree_sitter::Point {
156 row: line_index,
157 column: 0,
158 },
159 end_point: tree_sitter::Point {
160 row: line_index,
161 column: 0,
162 },
163 }),
164 )
165 }
166}
167
168impl RuleLinter for MD012Linter {
169 fn feed(&mut self, node: &Node) {
170 if node.kind() == "document" {
173 self.analyze_all_lines();
174 }
175 }
176
177 fn finalize(&mut self) -> Vec<RuleViolation> {
178 std::mem::take(&mut self.violations)
179 }
180}
181
182pub const MD012: Rule = Rule {
183 id: "MD012",
184 alias: "no-multiple-blanks",
185 tags: &["blank_lines", "whitespace"],
186 description: "Multiple consecutive blank lines",
187 rule_type: RuleType::Line,
188 required_nodes: &[],
191 new_linter: |context| Box::new(MD012Linter::new(context)),
192};
193
194#[cfg(test)]
195mod test {
196 use std::path::PathBuf;
197
198 use crate::config::{LintersSettingsTable, RuleSeverity};
199 use crate::linter::MultiRuleLinter;
200 use crate::test_utils::test_helpers::{test_config_with_rules, test_config_with_settings};
201
202 fn test_config() -> crate::config::QuickmarkConfig {
203 test_config_with_rules(vec![
204 ("no-multiple-blanks", RuleSeverity::Error),
205 ("heading-style", RuleSeverity::Off),
206 ("heading-increment", RuleSeverity::Off),
207 ])
208 }
209
210 fn test_config_with_multiple_blanks(
211 multiple_blanks_config: crate::config::MD012MultipleBlankLinesTable,
212 ) -> crate::config::QuickmarkConfig {
213 test_config_with_settings(
214 vec![
215 ("no-multiple-blanks", RuleSeverity::Error),
216 ("heading-style", RuleSeverity::Off),
217 ("heading-increment", RuleSeverity::Off),
218 ],
219 LintersSettingsTable {
220 multiple_blank_lines: multiple_blanks_config,
221 ..Default::default()
222 },
223 )
224 }
225
226 #[test]
227 fn test_no_violations_single_line() {
228 let input = "Single line document";
229
230 let config = test_config();
231 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
232 let violations = linter.analyze();
233 assert_eq!(0, violations.len());
234 }
235
236 #[test]
237 fn test_no_violations_no_blank_lines() {
238 let input = r#"Line one
239Line two
240Line three"#;
241
242 let config = test_config();
243 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
244 let violations = linter.analyze();
245 assert_eq!(0, violations.len());
246 }
247
248 #[test]
249 fn test_no_violations_single_blank_line() {
250 let input = r#"Line one
251
252Line two"#;
253
254 let config = test_config();
255 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
256 let violations = linter.analyze();
257 assert_eq!(0, violations.len());
258 }
259
260 #[test]
261 fn test_violation_two_consecutive_blank_lines() {
262 let input = r#"Line one
263
264
265Line two"#;
266
267 let config = test_config();
268 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
269 let violations = linter.analyze();
270 assert_eq!(1, violations.len());
271
272 let violation = &violations[0];
273 assert_eq!("MD012", violation.rule().id);
274 assert!(violation
275 .message()
276 .contains("Multiple consecutive blank lines"));
277 }
278
279 #[test]
280 fn test_violation_three_consecutive_blank_lines() {
281 let input = r#"Line one
282
283
284
285Line two"#;
286
287 let config = test_config();
288 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
289 let violations = linter.analyze();
290 assert_eq!(2, violations.len());
292
293 for violation in &violations {
294 assert_eq!("MD012", violation.rule().id);
295 }
296 }
297
298 #[test]
299 fn test_violation_multiple_locations() {
300 let input = r#"Line one
301
302
303Line two
304
305
306Line three"#;
307
308 let config = test_config();
309 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
310 let violations = linter.analyze();
311 assert_eq!(2, violations.len());
312
313 for violation in &violations {
314 assert_eq!("MD012", violation.rule().id);
315 }
316 }
317
318 #[test]
319 fn test_custom_maximum_two() {
320 let config =
321 test_config_with_multiple_blanks(crate::config::MD012MultipleBlankLinesTable {
322 maximum: 2,
323 });
324
325 let input_allowed = r#"Line one
327
328
329Line two"#;
330 let mut linter = MultiRuleLinter::new_for_document(
331 PathBuf::from("test.md"),
332 config.clone(),
333 input_allowed,
334 );
335 let violations = linter.analyze();
336 assert_eq!(0, violations.len());
337
338 let input_violation = r#"Line one
340
341
342
343Line two"#;
344 let mut linter =
345 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_violation);
346 let violations = linter.analyze();
347 assert_eq!(1, violations.len());
348 }
349
350 #[test]
351 fn test_custom_maximum_zero() {
352 let config =
353 test_config_with_multiple_blanks(crate::config::MD012MultipleBlankLinesTable {
354 maximum: 0,
355 });
356
357 let input = r#"Line one
359
360Line two"#;
361 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
362 let violations = linter.analyze();
363 assert_eq!(1, violations.len());
364 }
365
366 #[test]
367 fn test_code_blocks_excluded() {
368 let input_indented = r#"Normal text
370
371 Code line 1
372
373
374 Code line 2
375
376Normal text again"#;
377
378 let config = test_config();
379 let mut linter = MultiRuleLinter::new_for_document(
380 PathBuf::from("test.md"),
381 config.clone(),
382 input_indented,
383 );
384 let violations = linter.analyze();
385 assert_eq!(0, violations.len());
387
388 let input_fenced = r#"Normal text
390
391```
392Code line 1
393
394
395Code line 2
396```
397
398Normal text again"#;
399
400 let mut linter =
401 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_fenced);
402 let violations = linter.analyze();
403 assert_eq!(0, violations.len());
405 }
406
407 #[test]
408 fn test_code_blocks_with_surrounding_violations() {
409 let input = r#"Normal text
410
411
412```
413Code with blank lines
414
415
416Inside
417```
418
419
420More normal text"#;
421
422 let config = test_config();
423 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
424 let violations = linter.analyze();
425 assert_eq!(2, violations.len());
427 }
428
429 #[test]
430 fn test_blank_lines_with_spaces() {
431 let input = "Line one\n\n \n\nLine two"; let config = test_config();
435 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
436 let violations = linter.analyze();
437 assert_eq!(2, violations.len());
439 }
440
441 #[test]
442 fn test_trailing_newline_edge_case() {
443 let input_single = "Line one\nLine two\n";
450 let config = test_config();
451 let mut linter = MultiRuleLinter::new_for_document(
452 PathBuf::from("test.md"),
453 config.clone(),
454 input_single,
455 );
456 let violations = linter.analyze();
457 assert_eq!(
458 0,
459 violations.len(),
460 "Single trailing newline should not violate"
461 );
462
463 let input_double = "Line one\nLine two\n\n";
466 let mut linter = MultiRuleLinter::new_for_document(
467 PathBuf::from("test.md"),
468 config.clone(),
469 input_double,
470 );
471 let violations = linter.analyze();
472 assert_eq!(
473 1,
474 violations.len(),
475 "Double trailing newline (two consecutive blanks) should violate"
476 );
477
478 let input_triple = "Line one\nLine two\n\n\n";
481 let mut linter =
482 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_triple);
483 let violations = linter.analyze();
484 assert_eq!(
485 2,
486 violations.len(),
487 "Triple trailing newline (three consecutive blanks) should create 2 violations"
488 );
489
490 for violation in &violations {
491 assert_eq!("MD012", violation.rule().id);
492 assert!(violation
493 .message()
494 .contains("Multiple consecutive blank lines"));
495 }
496 }
497
498 #[test]
499 fn test_beginning_and_end_of_document() {
500 let input_beginning = "\n\nLine one\nLine two";
502
503 let config = test_config();
504 let mut linter = MultiRuleLinter::new_for_document(
505 PathBuf::from("test.md"),
506 config.clone(),
507 input_beginning,
508 );
509 let violations = linter.analyze();
510 assert_eq!(1, violations.len());
512
513 let input_end = "Line one\nLine two\n\n\n";
515
516 let mut linter =
517 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_end);
518 let violations = linter.analyze();
519 assert_eq!(2, violations.len());
521 }
522}