1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
8use crate::utils::range_utils::{LineIndex, 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 line_index = LineIndex::new(ctx.content.to_string());
190 let mut warnings = Vec::new();
191
192 let lines: Vec<&str> = ctx.content.lines().collect();
194
195 let mut blank_line_indices = Vec::new();
197 let mut has_blockquotes = false;
198
199 for (line_idx, line) in lines.iter().enumerate() {
200 if line_idx < ctx.lines.len() && ctx.lines[line_idx].in_code_block {
202 continue;
203 }
204
205 if line.trim().is_empty() {
206 blank_line_indices.push(line_idx);
207 } else if Self::is_blockquote_line(line) {
208 has_blockquotes = true;
209 }
210 }
211
212 if !has_blockquotes {
214 return Ok(Vec::new());
215 }
216
217 for &line_idx in &blank_line_indices {
219 let line_num = line_idx + 1;
220
221 if let Some((level, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
223 let line = lines[line_idx];
224 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, line);
225
226 warnings.push(LintWarning {
227 rule_name: Some(self.name()),
228 message: format!("Blank line inside blockquote (level {level})"),
229 line: start_line,
230 column: start_col,
231 end_line,
232 end_column: end_col,
233 severity: Severity::Warning,
234 fix: Some(Fix {
235 range: line_index.line_col_to_byte_range_with_length(line_num, 1, line.len()),
236 replacement: fix_content,
237 }),
238 });
239 }
240 }
241
242 Ok(warnings)
243 }
244
245 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
246 let mut result = Vec::with_capacity(ctx.lines.len());
247 let lines: Vec<&str> = ctx.content.lines().collect();
248
249 for (line_idx, line) in lines.iter().enumerate() {
250 if let Some((_, fix_content)) = Self::is_problematic_blank_line(&lines, line_idx) {
252 result.push(fix_content);
253 } else {
254 result.push(line.to_string());
255 }
256 }
257
258 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
259 }
260
261 fn category(&self) -> RuleCategory {
263 RuleCategory::Blockquote
264 }
265
266 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
268 !ctx.content.contains('>')
269 }
270
271 fn as_any(&self) -> &dyn std::any::Any {
272 self
273 }
274
275 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
276 where
277 Self: Sized,
278 {
279 Box::new(MD028NoBlanksBlockquote)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::lint_context::LintContext;
287
288 #[test]
289 fn test_no_blockquotes() {
290 let rule = MD028NoBlanksBlockquote;
291 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
293 let result = rule.check(&ctx).unwrap();
294 assert!(result.is_empty(), "Should not flag content without blockquotes");
295 }
296
297 #[test]
298 fn test_valid_blockquote_no_blanks() {
299 let rule = MD028NoBlanksBlockquote;
300 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
302 let result = rule.check(&ctx).unwrap();
303 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
304 }
305
306 #[test]
307 fn test_blockquote_with_empty_line_marker() {
308 let rule = MD028NoBlanksBlockquote;
309 let content = "> First line\n>\n> Third line";
311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
312 let result = rule.check(&ctx).unwrap();
313 assert!(result.is_empty(), "Should not flag lines with just > marker");
314 }
315
316 #[test]
317 fn test_blockquote_with_empty_line_marker_and_space() {
318 let rule = MD028NoBlanksBlockquote;
319 let content = "> First line\n> \n> Third line";
321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
322 let result = rule.check(&ctx).unwrap();
323 assert!(result.is_empty(), "Should not flag lines with > and space");
324 }
325
326 #[test]
327 fn test_blank_line_in_blockquote() {
328 let rule = MD028NoBlanksBlockquote;
329 let content = "> First line\n\n> Third line";
331 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
332 let result = rule.check(&ctx).unwrap();
333 assert_eq!(result.len(), 1, "Should flag truly blank line inside blockquote");
334 assert_eq!(result[0].line, 2);
335 assert!(result[0].message.contains("Blank line inside blockquote"));
336 }
337
338 #[test]
339 fn test_multiple_blank_lines() {
340 let rule = MD028NoBlanksBlockquote;
341 let content = "> First\n\n\n> Fourth";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
343 let result = rule.check(&ctx).unwrap();
344 assert_eq!(result.len(), 2, "Should flag each blank line within the blockquote");
346 assert_eq!(result[0].line, 2);
347 assert_eq!(result[1].line, 3);
348 }
349
350 #[test]
351 fn test_nested_blockquote_blank() {
352 let rule = MD028NoBlanksBlockquote;
353 let content = ">> Nested quote\n\n>> More nested";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
355 let result = rule.check(&ctx).unwrap();
356 assert_eq!(result.len(), 1);
357 assert_eq!(result[0].line, 2);
358 }
359
360 #[test]
361 fn test_nested_blockquote_with_marker() {
362 let rule = MD028NoBlanksBlockquote;
363 let content = ">> Nested quote\n>>\n>> More nested";
365 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
366 let result = rule.check(&ctx).unwrap();
367 assert!(result.is_empty(), "Should not flag lines with >> marker");
368 }
369
370 #[test]
371 fn test_fix_single_blank() {
372 let rule = MD028NoBlanksBlockquote;
373 let content = "> First\n\n> Third";
374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
375 let fixed = rule.fix(&ctx).unwrap();
376 assert_eq!(fixed, "> First\n>\n> Third");
377 }
378
379 #[test]
380 fn test_fix_nested_blank() {
381 let rule = MD028NoBlanksBlockquote;
382 let content = ">> Nested\n\n>> More";
383 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
384 let fixed = rule.fix(&ctx).unwrap();
385 assert_eq!(fixed, ">> Nested\n>>\n>> More");
386 }
387
388 #[test]
389 fn test_fix_with_indentation() {
390 let rule = MD028NoBlanksBlockquote;
391 let content = " > Indented quote\n\n > More";
392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
393 let fixed = rule.fix(&ctx).unwrap();
394 assert_eq!(fixed, " > Indented quote\n >\n > More");
395 }
396
397 #[test]
398 fn test_mixed_levels() {
399 let rule = MD028NoBlanksBlockquote;
400 let content = "> Level 1\n\n>> Level 2\n\n> Level 1 again";
402 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
403 let result = rule.check(&ctx).unwrap();
404 assert_eq!(result.len(), 1);
407 assert_eq!(result[0].line, 2);
408 }
409
410 #[test]
411 fn test_blockquote_with_code_block() {
412 let rule = MD028NoBlanksBlockquote;
413 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
414 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
415 let result = rule.check(&ctx).unwrap();
416 assert!(result.is_empty(), "Should not flag line with > marker");
418 }
419
420 #[test]
421 fn test_category() {
422 let rule = MD028NoBlanksBlockquote;
423 assert_eq!(rule.category(), RuleCategory::Blockquote);
424 }
425
426 #[test]
427 fn test_should_skip() {
428 let rule = MD028NoBlanksBlockquote;
429 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
430 assert!(rule.should_skip(&ctx1));
431
432 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
433 assert!(!rule.should_skip(&ctx2));
434 }
435
436 #[test]
437 fn test_empty_content() {
438 let rule = MD028NoBlanksBlockquote;
439 let content = "";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
441 let result = rule.check(&ctx).unwrap();
442 assert!(result.is_empty());
443 }
444
445 #[test]
446 fn test_blank_after_blockquote() {
447 let rule = MD028NoBlanksBlockquote;
448 let content = "> Quote\n\nNot a quote";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
450 let result = rule.check(&ctx).unwrap();
451 assert!(result.is_empty(), "Blank line after blockquote ends is valid");
452 }
453
454 #[test]
455 fn test_blank_before_blockquote() {
456 let rule = MD028NoBlanksBlockquote;
457 let content = "Not a quote\n\n> Quote";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let result = rule.check(&ctx).unwrap();
460 assert!(result.is_empty(), "Blank line before blockquote starts is valid");
461 }
462
463 #[test]
464 fn test_preserve_trailing_newline() {
465 let rule = MD028NoBlanksBlockquote;
466 let content = "> Quote\n\n> More\n";
467 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
468 let fixed = rule.fix(&ctx).unwrap();
469 assert!(fixed.ends_with('\n'));
470
471 let content_no_newline = "> Quote\n\n> More";
472 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
473 let fixed2 = rule.fix(&ctx2).unwrap();
474 assert!(!fixed2.ends_with('\n'));
475 }
476
477 #[test]
478 fn test_document_structure_extension() {
479 let rule = MD028NoBlanksBlockquote;
480 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
483 assert!(result.is_empty(), "Should not flag valid blockquote");
484
485 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
487 assert!(rule.should_skip(&ctx2), "Should skip content without blockquotes");
488 }
489
490 #[test]
491 fn test_deeply_nested_blank() {
492 let rule = MD028NoBlanksBlockquote;
493 let content = ">>> Deep nest\n\n>>> More deep";
494 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
495 let result = rule.check(&ctx).unwrap();
496 assert_eq!(result.len(), 1);
497
498 let fixed = rule.fix(&ctx).unwrap();
499 assert_eq!(fixed, ">>> Deep nest\n>>>\n>>> More deep");
500 }
501
502 #[test]
503 fn test_deeply_nested_with_marker() {
504 let rule = MD028NoBlanksBlockquote;
505 let content = ">>> Deep nest\n>>>\n>>> More deep";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
508 let result = rule.check(&ctx).unwrap();
509 assert!(result.is_empty(), "Should not flag lines with >>> marker");
510 }
511
512 #[test]
513 fn test_complex_blockquote_structure() {
514 let rule = MD028NoBlanksBlockquote;
515 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
518 let result = rule.check(&ctx).unwrap();
519 assert!(result.is_empty(), "Should not flag line with > marker");
520 }
521
522 #[test]
523 fn test_complex_with_blank() {
524 let rule = MD028NoBlanksBlockquote;
525 let content = "> Level 1\n> > Nested\n\n> Back to level 1";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
529 let result = rule.check(&ctx).unwrap();
530 assert_eq!(
531 result.len(),
532 0,
533 "Blank between different nesting levels is not inside blockquote"
534 );
535 }
536}