1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
9use crate::utils::range_utils::{LineIndex, calculate_line_range};
10
11#[derive(Clone)]
12pub struct MD028NoBlanksBlockquote;
13
14impl MD028NoBlanksBlockquote {
15 fn is_blockquote_line(line: &str) -> bool {
17 line.trim_start().starts_with('>')
18 }
19
20 fn get_blockquote_level(line: &str) -> usize {
22 let trimmed = line.trim_start();
23 let mut level = 0;
24 let chars = trimmed.chars();
25
26 for ch in chars {
27 if ch == '>' {
28 level += 1;
29 } else if ch != ' ' && ch != '\t' {
30 break;
31 }
32 }
33
34 level
35 }
36
37 fn get_leading_whitespace(line: &str) -> &str {
39 let trimmed_len = line.trim_start().len();
40 let total_len = line.len();
41 &line[..total_len - trimmed_len]
42 }
43
44 fn has_content_between(lines: &[&str], start: usize, end: usize) -> bool {
47 for line in lines.iter().take(end).skip(start) {
48 let trimmed = line.trim();
49 if !trimmed.is_empty() && !trimmed.starts_with('>') {
51 return true;
52 }
53 }
54 false
55 }
56
57 fn are_likely_same_blockquote(lines: &[&str], blank_idx: usize) -> bool {
59 let mut prev_quote_idx = None;
71 let mut next_quote_idx = None;
72
73 for i in (0..blank_idx).rev() {
74 if Self::is_blockquote_line(lines[i]) {
75 prev_quote_idx = Some(i);
76 break;
77 }
78 }
79
80 for (i, line) in lines.iter().enumerate().skip(blank_idx + 1) {
81 if Self::is_blockquote_line(line) {
82 next_quote_idx = Some(i);
83 break;
84 }
85 }
86
87 let (prev_idx, next_idx) = match (prev_quote_idx, next_quote_idx) {
88 (Some(p), Some(n)) => (p, n),
89 _ => return false,
90 };
91
92 if Self::has_content_between(lines, prev_idx + 1, next_idx) {
94 return false;
95 }
96
97 let prev_level = Self::get_blockquote_level(lines[prev_idx]);
99 let next_level = Self::get_blockquote_level(lines[next_idx]);
100
101 if next_level < prev_level {
104 return false;
105 }
106
107 let prev_indent = Self::get_leading_whitespace(lines[prev_idx]);
109 let next_indent = Self::get_leading_whitespace(lines[next_idx]);
110
111 prev_indent == next_indent
114 }
115
116 fn is_problematic_blank_line(lines: &[&str], index: usize) -> Option<(usize, String)> {
118 let current_line = lines[index];
119
120 if !current_line.trim().is_empty() || Self::is_blockquote_line(current_line) {
122 return None;
123 }
124
125 if !Self::are_likely_same_blockquote(lines, index) {
128 return None;
129 }
130
131 for i in (0..index).rev() {
134 if Self::is_blockquote_line(lines[i]) {
135 let level = Self::get_blockquote_level(lines[i]);
136 let indent = Self::get_leading_whitespace(lines[i]);
137 let mut fix = indent.to_string();
138 for _ in 0..level {
139 fix.push('>');
140 }
141 return Some((level, fix));
142 }
143 }
144
145 None
146 }
147}
148
149impl Default for MD028NoBlanksBlockquote {
150 fn default() -> Self {
151 Self
152 }
153}
154
155impl Rule for MD028NoBlanksBlockquote {
156 fn name(&self) -> &'static str {
157 "MD028"
158 }
159
160 fn description(&self) -> &'static str {
161 "Blank line inside blockquote"
162 }
163
164 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
165 Some(self)
166 }
167
168 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
169 if !ctx.content.contains('>') {
171 return Ok(Vec::new());
172 }
173
174 let line_index = LineIndex::new(ctx.content.to_string());
175 let mut warnings = Vec::new();
176
177 let lines: Vec<&str> = ctx.content.lines().collect();
179
180 for (line_idx, line) in lines.iter().enumerate() {
182 let line_num = line_idx + 1;
183
184 if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
186 continue;
187 }
188
189 if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
191 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
192
193 warnings.push(LintWarning {
194 rule_name: Some(self.name()),
195 message: format!("Blank line inside blockquote (level {level})"),
196 line: start_line,
197 column: start_col,
198 end_line,
199 end_column: end_col,
200 severity: Severity::Warning,
201 fix: Some(Fix {
202 range: line_index.line_col_to_byte_range_with_length(line_num, 1, line.len()),
203 replacement: fix_content,
204 }),
205 });
206 }
207 }
208
209 Ok(warnings)
210 }
211
212 fn check_with_structure(
214 &self,
215 ctx: &crate::lint_context::LintContext,
216 _structure: &DocumentStructure,
217 ) -> LintResult {
218 self.check(ctx)
220 }
221
222 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
223 let mut result = Vec::with_capacity(ctx.lines.len());
224 let lines: Vec<&str> = ctx.content.lines().collect();
225
226 for (line_idx, line) in lines.iter().enumerate() {
227 if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
229 result.push(fix_content);
230 } else {
231 result.push(line.to_string());
232 }
233 }
234
235 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
236 }
237
238 fn category(&self) -> RuleCategory {
240 RuleCategory::Blockquote
241 }
242
243 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
245 !ctx.content.contains('>')
246 }
247
248 fn as_any(&self) -> &dyn std::any::Any {
249 self
250 }
251
252 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
253 where
254 Self: Sized,
255 {
256 Box::new(MD028NoBlanksBlockquote)
257 }
258}
259
260impl DocumentStructureExtensions for MD028NoBlanksBlockquote {
261 fn has_relevant_elements(
262 &self,
263 _ctx: &crate::lint_context::LintContext,
264 doc_structure: &DocumentStructure,
265 ) -> bool {
266 !doc_structure.blockquotes.is_empty()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::lint_context::LintContext;
274
275 #[test]
276 fn test_no_blockquotes() {
277 let rule = MD028NoBlanksBlockquote;
278 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
279 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
280 let result = rule.check(&ctx).unwrap();
281 assert!(result.is_empty(), "Should not flag content without blockquotes");
282 }
283
284 #[test]
285 fn test_valid_blockquote_no_blanks() {
286 let rule = MD028NoBlanksBlockquote;
287 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
289 let result = rule.check(&ctx).unwrap();
290 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
291 }
292
293 #[test]
294 fn test_blockquote_with_empty_line_marker() {
295 let rule = MD028NoBlanksBlockquote;
296 let content = "> First line\n>\n> Third line";
298 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
299 let result = rule.check(&ctx).unwrap();
300 assert!(result.is_empty(), "Should not flag lines with just > marker");
301 }
302
303 #[test]
304 fn test_blockquote_with_empty_line_marker_and_space() {
305 let rule = MD028NoBlanksBlockquote;
306 let content = "> First line\n> \n> Third line";
308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
309 let result = rule.check(&ctx).unwrap();
310 assert!(result.is_empty(), "Should not flag lines with > and space");
311 }
312
313 #[test]
314 fn test_blank_line_in_blockquote() {
315 let rule = MD028NoBlanksBlockquote;
316 let content = "> First line\n\n> Third line";
318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
319 let result = rule.check(&ctx).unwrap();
320 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
321 assert_eq!(result[0].line, 2);
322 assert!(result[0].message.contains("Blank line inside blockquote"));
323 }
324
325 #[test]
326 fn test_multiple_blank_lines() {
327 let rule = MD028NoBlanksBlockquote;
328 let content = "> First\n\n\n> Fourth";
329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
330 let result = rule.check(&ctx).unwrap();
331 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
333 assert_eq!(result[0].line, 2);
334 assert_eq!(result[1].line, 3);
335 }
336
337 #[test]
338 fn test_nested_blockquote_blank() {
339 let rule = MD028NoBlanksBlockquote;
340 let content = ">> Nested quote\n\n>> More nested";
341 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
342 let result = rule.check(&ctx).unwrap();
343 assert_eq!(result.len(), 1);
344 assert_eq!(result[0].line, 2);
345 }
346
347 #[test]
348 fn test_nested_blockquote_with_marker() {
349 let rule = MD028NoBlanksBlockquote;
350 let content = ">> Nested quote\n>>\n>> More nested";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
353 let result = rule.check(&ctx).unwrap();
354 assert!(result.is_empty(), "Should not flag lines with >> marker");
355 }
356
357 #[test]
358 fn test_fix_single_blank() {
359 let rule = MD028NoBlanksBlockquote;
360 let content = "> First\n\n> Third";
361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
362 let fixed = rule.fix(&ctx).unwrap();
363 assert_eq!(fixed, "> First\n>\n> Third");
364 }
365
366 #[test]
367 fn test_fix_nested_blank() {
368 let rule = MD028NoBlanksBlockquote;
369 let content = ">> Nested\n\n>> More";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
371 let fixed = rule.fix(&ctx).unwrap();
372 assert_eq!(fixed, ">> Nested\n>>\n>> More");
373 }
374
375 #[test]
376 fn test_fix_with_indentation() {
377 let rule = MD028NoBlanksBlockquote;
378 let content = " > Indented quote\n\n > More";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
380 let fixed = rule.fix(&ctx).unwrap();
381 assert_eq!(fixed, " > Indented quote\n >\n > More");
382 }
383
384 #[test]
385 fn test_mixed_levels() {
386 let rule = MD028NoBlanksBlockquote;
387 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
390 let result = rule.check(&ctx).unwrap();
391 assert_eq!(result.len(), 1);
394 assert_eq!(result[0].line, 2);
395 }
396
397 #[test]
398 fn test_blockquote_with_code_block() {
399 let rule = MD028NoBlanksBlockquote;
400 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
402 let result = rule.check(&ctx).unwrap();
403 assert!(result.is_empty(), "Should not flag line with > marker");
405 }
406
407 #[test]
408 fn test_category() {
409 let rule = MD028NoBlanksBlockquote;
410 assert_eq!(rule.category(), RuleCategory::Blockquote);
411 }
412
413 #[test]
414 fn test_should_skip() {
415 let rule = MD028NoBlanksBlockquote;
416 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
417 assert!(rule.should_skip(&ctx1));
418
419 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
420 assert!(!rule.should_skip(&ctx2));
421 }
422
423 #[test]
424 fn test_empty_content() {
425 let rule = MD028NoBlanksBlockquote;
426 let content = "";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429 assert!(result.is_empty());
430 }
431
432 #[test]
433 fn test_blank_after_blockquote() {
434 let rule = MD028NoBlanksBlockquote;
435 let content = "> Quote\n\nNot a quote";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437 let result = rule.check(&ctx).unwrap();
438 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
439 }
440
441 #[test]
442 fn test_blank_before_blockquote() {
443 let rule = MD028NoBlanksBlockquote;
444 let content = "Not a quote\n\n> Quote";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
446 let result = rule.check(&ctx).unwrap();
447 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
448 }
449
450 #[test]
451 fn test_preserve_trailing_newline() {
452 let rule = MD028NoBlanksBlockquote;
453 let content = "> Quote\n\n> More\n";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
455 let fixed = rule.fix(&ctx).unwrap();
456 assert!(fixed.ends_with('\n'));
457
458 let content_no_newline = "> Quote\n\n> More";
459 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
460 let fixed2 = rule.fix(&ctx2).unwrap();
461 assert!(!fixed2.ends_with('\n'));
462 }
463
464 #[test]
465 fn test_document_structure_extension() {
466 let rule = MD028NoBlanksBlockquote;
467 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
468 let doc_structure = DocumentStructure::new("> test");
469 assert!(rule.has_relevant_elements(&ctx, &doc_structure));
470
471 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
472 let doc_structure2 = DocumentStructure::new("no blockquote");
473 assert!(!rule.has_relevant_elements(&ctx2, &doc_structure2));
474 }
475
476 #[test]
477 fn test_deeply_nested_blank() {
478 let rule = MD028NoBlanksBlockquote;
479 let content = ">>> Deep nest\n\n>>> More deep";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 1);
483
484 let fixed = rule.fix(&ctx).unwrap();
485 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
486 }
487
488 #[test]
489 fn test_deeply_nested_with_marker() {
490 let rule = MD028NoBlanksBlockquote;
491 let content = ">>> Deep nest\n>>>\n>>> More deep";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
494 let result = rule.check(&ctx).unwrap();
495 assert!(result.is_empty(), "Should not flag lines with >>> marker");
496 }
497
498 #[test]
499 fn test_complex_blockquote_structure() {
500 let rule = MD028NoBlanksBlockquote;
501 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
503 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
504 let result = rule.check(&ctx).unwrap();
505 assert!(result.is_empty(), "Should not flag line with > marker");
506 }
507
508 #[test]
509 fn test_complex_with_blank() {
510 let rule = MD028NoBlanksBlockquote;
511 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515 let result = rule.check(&ctx).unwrap();
516 assert_eq!(
517 result.len(),
518 0,
519 "Blank between different nesting levels is not inside blockquote"
520 );
521 }
522}