mdbook_lint_core/rules/standard/
md027.rs1use crate::error::Result;
7use crate::rule::{Rule, RuleCategory, RuleMetadata};
8use crate::{
9 Document,
10 violation::{Severity, Violation},
11};
12
13pub struct MD027;
15
16impl Rule for MD027 {
17 fn id(&self) -> &'static str {
18 "MD027"
19 }
20
21 fn name(&self) -> &'static str {
22 "no-multiple-space-blockquote"
23 }
24
25 fn description(&self) -> &'static str {
26 "Multiple spaces after blockquote symbol"
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; let mut pos = 0;
45 while let Some(gt_pos) = line[pos..].find('>') {
46 let actual_pos = pos + gt_pos;
47
48 let after_blockquote = &line[actual_pos + 1..];
50
51 let leading_whitespace_count = after_blockquote
53 .chars()
54 .take_while(|&c| c.is_whitespace())
55 .count();
56
57 let has_tab = after_blockquote
59 .chars()
60 .take_while(|&c| c.is_whitespace())
61 .any(|c| c == '\t');
62
63 if leading_whitespace_count >= 2 || has_tab {
64 violations.push(self.create_violation(
65 format!("Multiple spaces after blockquote symbol: found {leading_whitespace_count} whitespace characters, expected 1"),
66 line_num,
67 actual_pos + 2, Severity::Warning,
69 ));
70 }
71
72 pos = actual_pos + 1;
74 }
75 }
76
77 Ok(violations)
78 }
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::Document;
85 use crate::rule::Rule;
86 use std::path::PathBuf;
87
88 #[test]
89 fn test_md027_no_violations() {
90 let content = r#"> Single space after blockquote
91> Another line with single space
92>
93> Empty blockquote line is fine
94
95Regular text here.
96
97> Multi-line blockquote
98> with single spaces
99> throughout
100
101Nested blockquotes:
102> Level 1
103> > Level 2 with single space
104> > > Level 3 with single space
105"#;
106 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
107 let rule = MD027;
108 let violations = rule.check(&document).unwrap();
109
110 assert_eq!(violations.len(), 0);
111 }
112
113 #[test]
114 fn test_md027_multiple_spaces_violation() {
115 let content = r#"> Single space is fine
116> Two spaces after blockquote
117> Three spaces after blockquote
118> Four spaces after blockquote
119
120Regular text.
121"#;
122 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
123 let rule = MD027;
124 let violations = rule.check(&document).unwrap();
125
126 assert_eq!(violations.len(), 3);
127 assert!(
128 violations[0]
129 .message
130 .contains("found 2 whitespace characters, expected 1")
131 );
132 assert!(
133 violations[1]
134 .message
135 .contains("found 3 whitespace characters, expected 1")
136 );
137 assert!(
138 violations[2]
139 .message
140 .contains("found 4 whitespace characters, expected 1")
141 );
142 assert_eq!(violations[0].line, 2);
143 assert_eq!(violations[1].line, 3);
144 assert_eq!(violations[2].line, 4);
145 }
146
147 #[test]
148 fn test_md027_nested_blockquotes() {
149 let content = r#"> Level 1 with single space
150> > Level 2 with single space
151> > Level 2 with multiple spaces
152> > > Level 3 with single space
153> > > Level 3 with multiple spaces
154
155More content.
156"#;
157 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
158 let rule = MD027;
159 let violations = rule.check(&document).unwrap();
160
161 assert_eq!(violations.len(), 2);
162 assert_eq!(violations[0].line, 3);
163 assert_eq!(violations[1].line, 5);
164 }
165
166 #[test]
167 fn test_md027_indented_blockquotes() {
168 let content = r#"Regular text.
169
170 > Indented blockquote with single space
171 > Indented blockquote with multiple spaces
172 > Another with even more spaces
173
174Back to regular text.
175"#;
176 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
177 let rule = MD027;
178 let violations = rule.check(&document).unwrap();
179
180 assert_eq!(violations.len(), 2);
181 assert_eq!(violations[0].line, 4);
182 assert_eq!(violations[1].line, 5);
183 }
184
185 #[test]
186 fn test_md027_mixed_valid_invalid() {
187 let content = r#"> Valid blockquote
188> Invalid: two spaces
189> Another valid line
190> Invalid: three spaces
191> Valid again
192
193Regular paragraph.
194"#;
195 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
196 let rule = MD027;
197 let violations = rule.check(&document).unwrap();
198
199 assert_eq!(violations.len(), 2);
200 assert_eq!(violations[0].line, 2);
201 assert_eq!(violations[1].line, 4);
202 }
203
204 #[test]
205 fn test_md027_no_space_after_gt() {
206 let content = r#"> Valid with space
207>No space after gt
208>Still no space
209
210Some text.
211"#;
212 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
213 let rule = MD027;
214 let violations = rule.check(&document).unwrap();
215
216 assert_eq!(violations.len(), 0);
218 }
219
220 #[test]
221 fn test_md027_tabs_and_mixed_whitespace() {
222 let content =
223 ">\tTab after blockquote\n>\t\tTwo tabs after blockquote\n> \tSpace then tab\n";
224 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
225 let rule = MD027;
226 let violations = rule.check(&document).unwrap();
227
228 assert_eq!(violations.len(), 3);
230 }
231
232 #[test]
233 fn test_md027_empty_blockquote() {
234 let content = r#"> Valid content
235>
236>
237>
238
239More content.
240"#;
241 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
242 let rule = MD027;
243 let violations = rule.check(&document).unwrap();
244
245 assert_eq!(violations.len(), 0);
247 }
248
249 #[test]
250 fn test_md027_complex_nesting() {
251 let content = r#"> Level 1
252> > Level 2
253> > Level 2 with extra spaces
254> > > Level 3
255> > > Level 3 with extra spaces
256> Back to level 1
257> Level 1 with extra spaces
258
259Regular text.
260"#;
261 let document = Document::new(content.to_string(), PathBuf::from("test.md")).unwrap();
262 let rule = MD027;
263 let violations = rule.check(&document).unwrap();
264
265 assert_eq!(violations.len(), 3);
266 assert_eq!(violations[0].line, 3);
267 assert_eq!(violations[1].line, 5);
268 assert_eq!(violations[2].line, 7);
269 }
270}