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