mdbook_lint_core/rules/standard/
md028.rs

1//! MD028: Blank line inside blockquote
2//!
3//! This rule checks for blank lines inside blockquotes that break the blockquote flow.
4//! Blank lines should not appear inside blockquotes without proper continuation.
5
6use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9    Document,
10    violation::{Severity, Violation},
11};
12
13/// Rule to check for blank lines inside blockquotes
14pub struct MD028;
15
16impl Rule for MD028 {
17    fn id(&self) -> &'static str {
18        "MD028"
19    }
20
21    fn name(&self) -> &'static str {
22        "no-blanks-blockquote"
23    }
24
25    fn description(&self) -> &'static str {
26        "Blank line inside blockquote"
27    }
28
29    fn metadata(&self) -> RuleMetadata {
30        RuleMetadata::stable(RuleCategory::Formatting).introduced_in("mdbook-lint v0.1.0")
31    }
32
33    fn check_with_ast<'a>(
34        &self,
35        document: &Document,
36        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
37    ) -> Result<Vec<Violation>> {
38        let mut violations = Vec::new();
39
40        for (line_number, line) in document.lines.iter().enumerate() {
41            let line_num = line_number + 1; // Convert to 1-based line numbers
42
43            // Check if this is a blank line
44            if line.trim().is_empty() {
45                // Look backwards to find the last non-blank line
46                let mut prev_is_blockquote = false;
47                for i in (0..line_num - 1).rev() {
48                    if let Some(prev_line) = document.lines.get(i)
49                        && !prev_line.trim().is_empty()
50                    {
51                        prev_is_blockquote = prev_line.trim_start().starts_with('>');
52                        break;
53                    }
54                }
55
56                // Look forwards to find the next non-blank line
57                let mut next_is_blockquote = false;
58                for i in line_num..document.lines.len() {
59                    if let Some(next_line) = document.lines.get(i)
60                        && !next_line.trim().is_empty()
61                    {
62                        next_is_blockquote = next_line.trim_start().starts_with('>');
63                        break;
64                    }
65                }
66
67                // If we have blockquote lines before and after this blank line,
68                // then this blank line breaks the blockquote
69                if prev_is_blockquote && next_is_blockquote {
70                    violations.push(self.create_violation(
71                        "Blank line inside blockquote".to_string(),
72                        line_num,
73                        1,
74                        Severity::Warning,
75                    ));
76                }
77            }
78        }
79
80        Ok(violations)
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::Document;
88    use crate::rule::Rule;
89    use std::path::PathBuf;
90
91    #[test]
92    fn test_md028_no_violations() {
93        let content = r#"> This is a valid blockquote
94> with multiple lines
95> all properly formatted
96
97Regular paragraph here.
98
99> Another blockquote
100> also properly formatted
101>
102> with empty blockquote line
103
104More regular text.
105
106> Single line blockquote
107
108Final paragraph.
109"#;
110        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
111        let rule = MD028;
112        let violations = rule.check(&document).unwrap();
113
114        assert_eq!(violations.len(), 0);
115    }
116
117    #[test]
118    fn test_md028_blank_line_violation() {
119        let content = r#"> This is a blockquote
120> with proper formatting
121
122> but then it continues
123> after a blank line
124
125Regular text here.
126"#;
127        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
128        let rule = MD028;
129        let violations = rule.check(&document).unwrap();
130
131        assert_eq!(violations.len(), 1);
132        assert_eq!(violations[0].line, 3); // The blank line
133        assert!(
134            violations[0]
135                .message
136                .contains("Blank line inside blockquote")
137        );
138    }
139
140    #[test]
141    fn test_md028_multiple_blank_lines() {
142        let content = r#"> Start of blockquote
143> with some content
144
145> continues after blank line
146
147
148> continues after multiple blank lines
149> and ends here
150
151Regular text.
152"#;
153        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
154        let rule = MD028;
155        let violations = rule.check(&document).unwrap();
156
157        assert_eq!(violations.len(), 3);
158        assert_eq!(violations[0].line, 3); // First blank line
159        assert_eq!(violations[1].line, 5); // Second blank line
160        assert_eq!(violations[2].line, 6); // Third blank line
161    }
162
163    #[test]
164    fn test_md028_proper_blockquote_separation() {
165        let content = r#"> First blockquote
166> ends here
167
168Regular paragraph in between.
169
170> Second blockquote
171> starts here
172
173More regular text.
174"#;
175        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
176        let rule = MD028;
177        let violations = rule.check(&document).unwrap();
178
179        // No violations because the blockquotes are properly separated
180        assert_eq!(violations.len(), 0);
181    }
182
183    #[test]
184    fn test_md028_nested_blockquotes() {
185        let content = r#"> Outer blockquote
186> > Inner blockquote
187> > continues here
188
189> > but this breaks the flow
190> back to outer level
191
192Text here.
193"#;
194        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
195        let rule = MD028;
196        let violations = rule.check(&document).unwrap();
197
198        assert_eq!(violations.len(), 1);
199        assert_eq!(violations[0].line, 4); // The blank line between nested levels
200    }
201
202    #[test]
203    fn test_md028_blockquote_at_end() {
204        let content = r#"> Blockquote at the end
205> of the document
206
207> continues here"#;
208        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
209        let rule = MD028;
210        let violations = rule.check(&document).unwrap();
211
212        assert_eq!(violations.len(), 1);
213        assert_eq!(violations[0].line, 3);
214    }
215
216    #[test]
217    fn test_md028_empty_blockquote_lines() {
218        let content = r#"> Blockquote with empty lines
219>
220> is perfectly valid
221>
222> because empty lines have >
223
224Regular text.
225"#;
226        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
227        let rule = MD028;
228        let violations = rule.check(&document).unwrap();
229
230        // Empty lines with '>' markers are valid
231        assert_eq!(violations.len(), 0);
232    }
233
234    #[test]
235    fn test_md028_indented_blockquotes() {
236        let content = r#"Regular text.
237
238    > Indented blockquote
239    > continues here
240
241    > but breaks here
242    > and continues
243
244More text.
245"#;
246        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
247        let rule = MD028;
248        let violations = rule.check(&document).unwrap();
249
250        assert_eq!(violations.len(), 1);
251        assert_eq!(violations[0].line, 5);
252    }
253
254    #[test]
255    fn test_md028_complex_document() {
256        let content = r#"# Heading
257
258> Valid blockquote
259> with multiple lines
260
261Regular paragraph.
262
263> Another blockquote
264
265> that continues improperly
266
267> and has more content
268
269## Another heading
270
271> Final blockquote
272> that ends properly
273
274The end.
275"#;
276        let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
277        let rule = MD028;
278        let violations = rule.check(&document).unwrap();
279
280        assert_eq!(violations.len(), 2);
281        assert_eq!(violations[0].line, 9); // First improper break
282        assert_eq!(violations[1].line, 11); // Second improper break
283    }
284}