1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::document_structure::{DocumentStructure, DocumentStructureExtensions};
6use crate::utils::range_utils::{LineIndex, calculate_line_range};
7
8#[derive(Clone)]
9pub struct MD028NoBlanksBlockquote;
10
11impl MD028NoBlanksBlockquote {
12 fn get_replacement(indent: &str, level: usize) -> String {
14 let mut result = indent.to_string();
15
16 for _ in 0..level {
18 result.push('>');
19 }
20 result.push(' ');
22
23 result
24 }
25}
26
27impl Default for MD028NoBlanksBlockquote {
28 fn default() -> Self {
29 Self
30 }
31}
32
33impl Rule for MD028NoBlanksBlockquote {
34 fn name(&self) -> &'static str {
35 "MD028"
36 }
37
38 fn description(&self) -> &'static str {
39 "Blank line inside blockquote"
40 }
41
42 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
43 Some(self)
44 }
45
46 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
47 if !ctx.content.contains('>') {
49 return Ok(Vec::new());
50 }
51
52 let line_index = LineIndex::new(ctx.content.to_string());
53 let mut warnings = Vec::new();
54
55 for (line_idx, line_info) in ctx.lines.iter().enumerate() {
57 let line_num = line_idx + 1;
58
59 if line_info.in_code_block {
61 continue;
62 }
63
64 if let Some(blockquote) = &line_info.blockquote
66 && blockquote.needs_md028_fix
67 {
68 let (start_line, start_col, end_line, end_col) = calculate_line_range(line_num, &line_info.content);
70
71 warnings.push(LintWarning {
72 rule_name: Some(self.name()),
73 message: "Empty blockquote line should contain '>' marker".to_string(),
74 line: start_line,
75 column: start_col,
76 end_line,
77 end_column: end_col,
78 severity: Severity::Warning,
79 fix: Some(Fix {
80 range: line_index.line_col_to_byte_range_with_length(line_num, 1, line_info.content.len()),
81 replacement: Self::get_replacement(&blockquote.indent, blockquote.nesting_level),
82 }),
83 });
84 }
85 }
86
87 Ok(warnings)
88 }
89
90 fn check_with_structure(
92 &self,
93 ctx: &crate::lint_context::LintContext,
94 _structure: &DocumentStructure,
95 ) -> LintResult {
96 self.check(ctx)
98 }
99
100 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
101 let mut result = Vec::with_capacity(ctx.lines.len());
102
103 for line_info in &ctx.lines {
104 if let Some(blockquote) = &line_info.blockquote {
105 if blockquote.needs_md028_fix {
106 let replacement = Self::get_replacement(&blockquote.indent, blockquote.nesting_level);
107 result.push(replacement);
108 } else {
109 result.push(line_info.content.clone());
110 }
111 } else {
112 result.push(line_info.content.clone());
113 }
114 }
115
116 Ok(result.join("\n") + if ctx.content.ends_with('\n') { "\n" } else { "" })
117 }
118
119 fn category(&self) -> RuleCategory {
121 RuleCategory::Blockquote
122 }
123
124 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
126 !ctx.content.contains('>')
127 }
128
129 fn as_any(&self) -> &dyn std::any::Any {
130 self
131 }
132
133 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
134 where
135 Self: Sized,
136 {
137 Box::new(MD028NoBlanksBlockquote)
138 }
139}
140
141impl DocumentStructureExtensions for MD028NoBlanksBlockquote {
142 fn has_relevant_elements(
143 &self,
144 _ctx: &crate::lint_context::LintContext,
145 doc_structure: &DocumentStructure,
146 ) -> bool {
147 !doc_structure.blockquotes.is_empty()
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::lint_context::LintContext;
155
156 #[test]
157 fn test_no_blockquotes() {
158 let rule = MD028NoBlanksBlockquote;
159 let content = "This is regular text\n\nWith blank lines\n\nBut no blockquotes";
160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
161 let result = rule.check(&ctx).unwrap();
162 assert!(result.is_empty(), "Should not flag content without blockquotes");
163 }
164
165 #[test]
166 fn test_valid_blockquote_no_blanks() {
167 let rule = MD028NoBlanksBlockquote;
168 let content = "> This is a blockquote\n> With multiple lines\n> But no blank lines";
169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
170 let result = rule.check(&ctx).unwrap();
171 assert!(result.is_empty(), "Should not flag blockquotes without blank lines");
172 }
173
174 #[test]
175 fn test_blank_line_in_blockquote() {
176 let rule = MD028NoBlanksBlockquote;
177 let content = "> First line\n>\n> Third line";
178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
179 let result = rule.check(&ctx).unwrap();
180 assert_eq!(result.len(), 1);
181 assert_eq!(result[0].line, 2);
182 assert!(result[0].message.contains("Empty blockquote line"));
183 }
184
185 #[test]
186 fn test_multiple_blank_lines() {
187 let rule = MD028NoBlanksBlockquote;
188 let content = "> First\n>\n>\n> Fourth";
189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
190 let result = rule.check(&ctx).unwrap();
191 assert_eq!(result.len(), 2, "Should flag each blank line separately");
192 assert_eq!(result[0].line, 2);
193 assert_eq!(result[1].line, 3);
194 }
195
196 #[test]
197 fn test_nested_blockquote_blank() {
198 let rule = MD028NoBlanksBlockquote;
199 let content = ">> Nested quote\n>>\n>> More nested";
200 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
201 let result = rule.check(&ctx).unwrap();
202 assert_eq!(result.len(), 1);
203 assert_eq!(result[0].line, 2);
204 }
205
206 #[test]
207 fn test_fix_single_blank() {
208 let rule = MD028NoBlanksBlockquote;
209 let content = "> First\n>\n> Third";
210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
211 let fixed = rule.fix(&ctx).unwrap();
212 assert_eq!(fixed, "> First\n> \n> Third");
213 }
214
215 #[test]
216 fn test_fix_nested_blank() {
217 let rule = MD028NoBlanksBlockquote;
218 let content = ">> Nested\n>>\n>> More";
219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
220 let fixed = rule.fix(&ctx).unwrap();
221 assert_eq!(fixed, ">> Nested\n>> \n>> More");
222 }
223
224 #[test]
225 fn test_fix_with_indentation() {
226 let rule = MD028NoBlanksBlockquote;
227 let content = " > Indented quote\n >\n > More";
228 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
229 let fixed = rule.fix(&ctx).unwrap();
230 assert_eq!(fixed, " > Indented quote\n > \n > More");
231 }
232
233 #[test]
234 fn test_mixed_levels() {
235 let rule = MD028NoBlanksBlockquote;
236 let content = "> Level 1\n>\n>> Level 2\n>>\n> Level 1 again";
237 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
238 let result = rule.check(&ctx).unwrap();
239 assert_eq!(result.len(), 2);
240 assert_eq!(result[0].line, 2);
241 assert_eq!(result[1].line, 4);
242 }
243
244 #[test]
245 fn test_blockquote_with_code_block() {
246 let rule = MD028NoBlanksBlockquote;
247 let content = "> Quote with code:\n> ```\n> code\n> ```\n>\n> More quote";
248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
249 let result = rule.check(&ctx).unwrap();
250 assert_eq!(result.len(), 1);
251 assert_eq!(result[0].line, 5);
252 }
253
254 #[test]
255 fn test_category() {
256 let rule = MD028NoBlanksBlockquote;
257 assert_eq!(rule.category(), RuleCategory::Blockquote);
258 }
259
260 #[test]
261 fn test_should_skip() {
262 let rule = MD028NoBlanksBlockquote;
263 let ctx1 = LintContext::new("No blockquotes here", crate::config::MarkdownFlavor::Standard);
264 assert!(rule.should_skip(&ctx1));
265
266 let ctx2 = LintContext::new("> Has blockquote", crate::config::MarkdownFlavor::Standard);
267 assert!(!rule.should_skip(&ctx2));
268 }
269
270 #[test]
271 fn test_get_replacement() {
272 assert_eq!(MD028NoBlanksBlockquote::get_replacement("", 1), "> ");
273 assert_eq!(MD028NoBlanksBlockquote::get_replacement(" ", 1), " > ");
274 assert_eq!(MD028NoBlanksBlockquote::get_replacement("", 2), ">> ");
275 assert_eq!(MD028NoBlanksBlockquote::get_replacement(" ", 3), " >>> ");
276 }
277
278 #[test]
279 fn test_empty_content() {
280 let rule = MD028NoBlanksBlockquote;
281 let content = "";
282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
283 let result = rule.check(&ctx).unwrap();
284 assert!(result.is_empty());
285 }
286
287 #[test]
288 fn test_blank_after_blockquote() {
289 let rule = MD028NoBlanksBlockquote;
290 let content = "> Quote\n\nNot a quote";
291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
292 let result = rule.check(&ctx).unwrap();
293 assert!(result.is_empty(), "Blank line after blockquote is valid");
294 }
295
296 #[test]
297 fn test_preserve_trailing_newline() {
298 let rule = MD028NoBlanksBlockquote;
299 let content = "> Quote\n>\n> More\n";
300 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
301 let fixed = rule.fix(&ctx).unwrap();
302 assert!(fixed.ends_with('\n'));
303
304 let content_no_newline = "> Quote\n>\n> More";
305 let ctx2 = LintContext::new(content_no_newline, crate::config::MarkdownFlavor::Standard);
306 let fixed2 = rule.fix(&ctx2).unwrap();
307 assert!(!fixed2.ends_with('\n'));
308 }
309
310 #[test]
311 fn test_document_structure_extension() {
312 let rule = MD028NoBlanksBlockquote;
313 let ctx = LintContext::new("> test", crate::config::MarkdownFlavor::Standard);
314 let doc_structure = DocumentStructure::new("> test");
315 assert!(rule.has_relevant_elements(&ctx, &doc_structure));
316
317 let ctx2 = LintContext::new("no blockquote", crate::config::MarkdownFlavor::Standard);
318 let doc_structure2 = DocumentStructure::new("no blockquote");
319 assert!(!rule.has_relevant_elements(&ctx2, &doc_structure2));
320 }
321
322 #[test]
323 fn test_deeply_nested_blank() {
324 let rule = MD028NoBlanksBlockquote;
325 let content = ">>> Deep nest\n>>>\n>>> More deep";
326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
327 let result = rule.check(&ctx).unwrap();
328 assert_eq!(result.len(), 1);
329
330 let fixed = rule.fix(&ctx).unwrap();
331 assert_eq!(fixed, ">>> Deep nest\n>>> \n>>> More deep");
332 }
333
334 #[test]
335 fn test_complex_blockquote_structure() {
336 let rule = MD028NoBlanksBlockquote;
337 let content = "> Level 1\n> > Nested properly\n>\n> Back to level 1";
338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
339 let result = rule.check(&ctx).unwrap();
340 assert_eq!(result.len(), 1);
341 assert_eq!(result[0].line, 3);
342 }
343}