mdbook_lint_core/rules/standard/
md027.rs

1//! MD027: Multiple spaces after blockquote symbol
2//!
3//! This rule checks for multiple spaces after the '>' symbol in blockquotes.
4//! Only one space should be used after the blockquote symbol.
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 multiple spaces after blockquote symbol
14pub 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; // Convert to 1-based line numbers
42
43            // Look for all '>' characters in the line
44            let mut pos = 0;
45            while let Some(gt_pos) = line[pos..].find('>') {
46                let actual_pos = pos + gt_pos;
47
48                // Check what comes after the '>'
49                let after_blockquote = &line[actual_pos + 1..];
50
51                // Check for multiple whitespace characters after '>'
52                let leading_whitespace_count = after_blockquote
53                    .chars()
54                    .take_while(|&c| c.is_whitespace())
55                    .count();
56
57                // Flag if there are 2+ spaces OR any tabs (since tabs count as multiple spaces)
58                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, // Position after the '>'
68                        Severity::Warning,
69                    ));
70                }
71
72                // Move past this '>' to look for more
73                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        // No space after > is a different rule (not this one)
217        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        // Should detect multiple whitespace characters (all 3 cases have tabs or multiple spaces)
229        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        // Empty blockquote lines (just >) should not be flagged - no spaces to check
246        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}