mdbook_lint_core/rules/standard/
md009.rs1use crate::error::Result;
6use crate::rule::{AstRule, RuleCategory, RuleMetadata};
7use crate::{
8 Document,
9 violation::{Severity, Violation},
10};
11
12pub struct MD009 {
14 br_spaces: usize,
16 list_item_empty_lines: bool,
18 strict: bool,
20}
21
22impl MD009 {
23 pub fn new() -> Self {
25 Self {
26 br_spaces: 2, list_item_empty_lines: false,
28 strict: false,
29 }
30 }
31
32 #[allow(dead_code)]
34 pub fn with_config(br_spaces: usize, list_item_empty_lines: bool, strict: bool) -> Self {
35 Self {
36 br_spaces,
37 list_item_empty_lines,
38 strict,
39 }
40 }
41}
42
43impl Default for MD009 {
44 fn default() -> Self {
45 Self::new()
46 }
47}
48
49impl AstRule for MD009 {
50 fn id(&self) -> &'static str {
51 "MD009"
52 }
53
54 fn name(&self) -> &'static str {
55 "no-trailing-spaces"
56 }
57
58 fn description(&self) -> &'static str {
59 "Trailing spaces are not allowed"
60 }
61
62 fn metadata(&self) -> RuleMetadata {
63 RuleMetadata::stable(RuleCategory::Formatting).introduced_in("markdownlint v0.1.0")
64 }
65
66 fn check_ast<'a>(
67 &self,
68 document: &Document,
69 ast: &'a comrak::nodes::AstNode<'a>,
70 ) -> Result<Vec<Violation>> {
71 let mut violations = Vec::new();
72
73 let code_block_lines = self.get_code_block_line_ranges(ast);
75 let list_item_lines = if self.list_item_empty_lines {
76 self.get_list_item_empty_lines(ast)
77 } else {
78 Vec::new()
79 };
80
81 for (line_number, line) in document.lines.iter().enumerate() {
82 let line_num = line_number + 1; if !line.ends_with(' ') && !line.ends_with('\t') {
86 continue;
87 }
88
89 let trailing_spaces = line.chars().rev().take_while(|c| c.is_whitespace()).count();
91
92 let in_code_block = code_block_lines
94 .iter()
95 .any(|(start, end)| line_num >= *start && line_num <= *end);
96
97 if in_code_block && !self.strict {
99 continue;
100 }
101
102 if self.list_item_empty_lines && list_item_lines.contains(&line_num) {
104 continue;
105 }
106
107 if !self.strict && trailing_spaces == self.br_spaces {
109 continue;
110 }
111
112 let column = line.len() - trailing_spaces + 1;
114 violations.push(self.create_violation(
115 format!(
116 "Trailing spaces detected (found {} trailing space{})",
117 trailing_spaces,
118 if trailing_spaces == 1 { "" } else { "s" }
119 ),
120 line_num,
121 column,
122 Severity::Warning,
123 ));
124 }
125
126 Ok(violations)
127 }
128}
129
130impl MD009 {
131 fn get_code_block_line_ranges<'a>(
133 &self,
134 ast: &'a comrak::nodes::AstNode<'a>,
135 ) -> Vec<(usize, usize)> {
136 let mut ranges = Vec::new();
137 self.collect_code_block_ranges(ast, &mut ranges);
138 ranges
139 }
140
141 #[allow(clippy::only_used_in_recursion)]
143 fn collect_code_block_ranges<'a>(
144 &self,
145 node: &'a comrak::nodes::AstNode<'a>,
146 ranges: &mut Vec<(usize, usize)>,
147 ) {
148 use comrak::nodes::NodeValue;
149
150 if let NodeValue::CodeBlock(_) = &node.data.borrow().value {
151 let sourcepos = node.data.borrow().sourcepos;
152 if sourcepos.start.line > 0 && sourcepos.end.line > 0 {
153 ranges.push((sourcepos.start.line, sourcepos.end.line));
154 }
155 }
156
157 for child in node.children() {
158 self.collect_code_block_ranges(child, ranges);
159 }
160 }
161
162 fn get_list_item_empty_lines<'a>(&self, ast: &'a comrak::nodes::AstNode<'a>) -> Vec<usize> {
164 let mut lines = Vec::new();
165 self.collect_list_item_empty_lines(ast, &mut lines);
166 lines
167 }
168
169 #[allow(clippy::only_used_in_recursion)]
172 fn collect_list_item_empty_lines<'a>(
173 &self,
174 node: &'a comrak::nodes::AstNode<'a>,
175 lines: &mut Vec<usize>,
176 ) {
177 use comrak::nodes::NodeValue;
178
179 if let NodeValue::Item(_) = &node.data.borrow().value {
180 }
183
184 for child in node.children() {
185 self.collect_list_item_empty_lines(child, lines);
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::rule::Rule;
194 use std::path::PathBuf;
195
196 fn create_test_document(content: &str) -> Document {
197 Document::new(content.to_string(), PathBuf::from("test.md")).unwrap()
198 }
199
200 #[test]
201 fn test_md009_no_trailing_spaces() {
202 let content = "# Heading\n\nNo trailing spaces here.\nAnother clean line.";
203 let document = create_test_document(content);
204 let rule = MD009::new();
205 let violations = rule.check(&document).unwrap();
206
207 assert_eq!(violations.len(), 0);
208 }
209
210 #[test]
211 fn test_md009_single_trailing_space() {
212 let content = "# Heading\n\nLine with single trailing space. \nClean line.";
213 let document = create_test_document(content);
214 let rule = MD009::new();
215 let violations = rule.check(&document).unwrap();
216
217 assert_eq!(violations.len(), 1);
218 assert_eq!(violations[0].rule_id, "MD009");
219 assert_eq!(violations[0].line, 3);
220 assert_eq!(violations[0].column, 33);
221 assert!(violations[0].message.contains("1 trailing space"));
222 }
223
224 #[test]
225 fn test_md009_multiple_trailing_spaces() {
226 let content = "# Heading\n\nLine with spaces. \nAnother line. ";
227 let document = create_test_document(content);
228 let rule = MD009::new();
229 let violations = rule.check(&document).unwrap();
230
231 assert_eq!(violations.len(), 2);
232 assert_eq!(violations[0].line, 3);
233 assert!(violations[0].message.contains("3 trailing spaces"));
234 assert_eq!(violations[1].line, 4);
235 assert!(violations[1].message.contains("4 trailing spaces"));
236 }
237
238 #[test]
239 fn test_md009_trailing_tabs() {
240 let content = "# Heading\n\nLine with trailing tab.\t\nClean line.";
241 let document = create_test_document(content);
242 let rule = MD009::new();
243 let violations = rule.check(&document).unwrap();
244
245 assert_eq!(violations.len(), 1);
246 assert_eq!(violations[0].line, 3);
247 assert!(violations[0].message.contains("1 trailing space"));
248 }
249
250 #[test]
251 fn test_md009_line_break_spaces() {
252 let content = "# Heading\n\nLine with two spaces for break. \nNext line.";
253 let document = create_test_document(content);
254 let rule = MD009::new();
255 let violations = rule.check(&document).unwrap();
256
257 assert_eq!(violations.len(), 0);
259 }
260
261 #[test]
262 fn test_md009_strict_mode() {
263 let content = "# Heading\n\nLine with two spaces. \nThree spaces. ";
264 let document = create_test_document(content);
265 let rule = MD009::with_config(2, false, true);
266 let violations = rule.check(&document).unwrap();
267
268 assert_eq!(violations.len(), 2);
270 }
271
272 #[test]
273 fn test_md009_code_block_ignored() {
274 let content = "# Heading\n\n```rust\nlet x = 1; \n```\n\nRegular line. ";
275 let document = create_test_document(content);
276 let rule = MD009::new();
277 let violations = rule.check(&document).unwrap();
278
279 assert_eq!(violations.len(), 1);
281 assert_eq!(violations[0].line, 7);
282 }
283
284 #[test]
285 fn test_md009_code_block_strict() {
286 let content = "# Heading\n\n```rust\nlet x = 1; \n```\n\nRegular line. ";
287 let document = create_test_document(content);
288 let rule = MD009::with_config(2, false, true);
289 let violations = rule.check(&document).unwrap();
290
291 assert_eq!(violations.len(), 2);
293 }
294}