1use serde::Deserialize;
2use std::rc::Rc;
3
4use tree_sitter::Node;
5
6use crate::{
7 linter::{range_from_tree_sitter, RuleViolation},
8 rules::{Context, Rule, RuleLinter, RuleType},
9};
10
11#[derive(Debug, PartialEq, Clone, Deserialize)]
13pub struct MD030ListMarkerSpaceTable {
14 #[serde(default)]
15 pub ul_single: usize,
16 #[serde(default)]
17 pub ol_single: usize,
18 #[serde(default)]
19 pub ul_multi: usize,
20 #[serde(default)]
21 pub ol_multi: usize,
22}
23
24impl Default for MD030ListMarkerSpaceTable {
25 fn default() -> Self {
26 Self {
27 ul_single: 1,
28 ol_single: 1,
29 ul_multi: 1,
30 ol_multi: 1,
31 }
32 }
33}
34
35pub(crate) struct MD030Linter {
36 context: Rc<Context>,
37 violations: Vec<RuleViolation>,
38}
39
40impl MD030Linter {
41 pub fn new(context: Rc<Context>) -> Self {
42 Self {
43 context,
44 violations: Vec::new(),
45 }
46 }
47}
48
49impl RuleLinter for MD030Linter {
50 fn feed(&mut self, node: &Node) {
51 if node.kind() == "list" {
52 self.check_list_marker_spacing(node);
53 }
54 }
55
56 fn finalize(&mut self) -> Vec<RuleViolation> {
57 std::mem::take(&mut self.violations)
58 }
59}
60
61impl MD030Linter {
62 fn check_list_marker_spacing(&mut self, list_node: &Node) {
63 let list_items: Vec<Node> = {
64 let mut cursor = list_node.walk();
65 list_node
66 .children(&mut cursor)
67 .filter(|c| c.kind() == "list_item")
68 .collect()
69 };
70
71 if list_items.is_empty() {
72 return;
73 }
74
75 let is_ordered = self.is_ordered_list(&list_items[0]);
76 let is_single_line = self.is_single_line_list(&list_items);
77
78 let expected_spaces = self.get_expected_spaces(is_ordered, is_single_line);
79
80 for list_item in &list_items {
81 self.check_list_item_spacing(list_item, expected_spaces);
82 }
83 }
84
85 fn is_ordered_list(&self, list_item_node: &Node) -> bool {
86 let mut cursor = list_item_node.walk();
87 let result = list_item_node
88 .children(&mut cursor)
89 .find(|c| c.kind().starts_with("list_marker"))
90 .is_some_and(|marker_node| {
91 let kind = marker_node.kind();
92 kind == "list_marker_dot" || kind == "list_marker_parenthesis"
93 });
94 result
95 }
96
97 fn is_single_line_list(&self, list_items: &[Node]) -> bool {
98 list_items
101 .iter()
102 .all(|item| item.start_position().row == item.end_position().row)
103 }
104
105 fn get_expected_spaces(&self, is_ordered: bool, is_single_line: bool) -> usize {
106 let config = &self.context.config.linters.settings.list_marker_space;
107 match (is_ordered, is_single_line) {
108 (true, true) => config.ol_single,
109 (true, false) => config.ol_multi,
110 (false, true) => config.ul_single,
111 (false, false) => config.ul_multi,
112 }
113 }
114
115 fn check_list_item_spacing(&mut self, list_item: &Node, expected_spaces: usize) {
116 let content = self.context.document_content.borrow();
117 let item_text = match list_item.utf8_text(content.as_bytes()) {
118 Ok(text) => text,
119 Err(_) => return, };
121
122 if let Some(first_line) = item_text.lines().next() {
123 if let Some(actual_spaces) = self.extract_spaces_after_marker(first_line) {
124 if actual_spaces != expected_spaces {
125 let message = format!(
126 "{} [Expected: {}; Actual: {}]",
127 MD030.description, expected_spaces, actual_spaces
128 );
129
130 self.violations.push(RuleViolation::new(
131 &MD030,
132 message,
133 self.context.file_path.clone(),
134 range_from_tree_sitter(&list_item.range()),
135 ));
136 }
137 }
138 }
139 }
140
141 fn extract_spaces_after_marker(&self, line: &str) -> Option<usize> {
142 let line = line.trim_start(); if line.starts_with(['*', '+', '-']) {
146 let after_marker = &line[1..];
147 return Some(after_marker.chars().take_while(|&c| c == ' ').count());
148 }
149
150 if let Some(dot_pos) = line.find('.') {
152 let before_dot = &line[..dot_pos];
153 if !before_dot.is_empty() && before_dot.chars().all(|c| c.is_ascii_digit()) {
154 let after_marker = &line[dot_pos + 1..];
155 return Some(after_marker.chars().take_while(|&c| c == ' ').count());
156 }
157 }
158
159 None
160 }
161}
162
163pub const MD030: Rule = Rule {
164 id: "MD030",
165 alias: "list-marker-space",
166 tags: &["ol", "ul", "whitespace"],
167 description: "Spaces after list markers",
168 rule_type: RuleType::Token,
169 required_nodes: &["list"],
170 new_linter: |context| Box::new(MD030Linter::new(context)),
171};
172
173#[cfg(test)]
174mod test {
175 use std::path::PathBuf;
176
177 use crate::config::{QuickmarkConfig, RuleSeverity};
178 use crate::linter::MultiRuleLinter;
179 use crate::test_utils::test_helpers::test_config_with_rules;
180
181 fn test_config() -> QuickmarkConfig {
182 test_config_with_rules(vec![("list-marker-space", RuleSeverity::Error)])
183 }
184
185 #[test]
186 fn test_default_unordered_list_single_space_no_violations() {
187 let input = "* Item 1\n* Item 2\n* Item 3\n";
188
189 let config = test_config();
190 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
191 let violations = linter.analyze();
192 assert_eq!(
193 0,
194 violations.len(),
195 "Default single space after unordered list marker should have no violations"
196 );
197 }
198
199 #[test]
200 fn test_default_ordered_list_single_space_no_violations() {
201 let input = "1. Item 1\n2. Item 2\n3. Item 3\n";
202
203 let config = test_config();
204 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
205 let violations = linter.analyze();
206 assert_eq!(
207 0,
208 violations.len(),
209 "Default single space after ordered list marker should have no violations"
210 );
211 }
212
213 #[test]
214 fn test_unordered_list_double_space_has_violations() {
215 let input = "* Item 1\n* Item 2\n* Item 3\n";
216
217 let config = test_config();
218 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
219 let violations = linter.analyze();
220 assert!(
221 !violations.is_empty(),
222 "Double space after unordered list marker should have violations"
223 );
224 }
225
226 #[test]
227 fn test_ordered_list_double_space_has_violations() {
228 let input = "1. Item 1\n2. Item 2\n3. Item 3\n";
229
230 let config = test_config();
231 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
232 let violations = linter.analyze();
233 assert!(
234 !violations.is_empty(),
235 "Double space after ordered list marker should have violations"
236 );
237 }
238
239 #[test]
240 fn test_mixed_list_types_independent() {
241 let input = "* Item 1\n* Item 2\n\n1. Item 1\n2. Item 2\n";
242
243 let config = test_config();
244 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
245 let violations = linter.analyze();
246 assert_eq!(
247 0,
248 violations.len(),
249 "Mixed list types with correct spacing should have no violations"
250 );
251 }
252
253 #[test]
254 fn test_single_line_vs_multi_line_lists() {
255 let input_single = "* Item 1\n* Item 2\n* Item 3\n";
257
258 let input_multi = "* Item 1\n\n Second paragraph\n\n* Item 2\n";
260
261 let config = test_config();
262
263 let mut linter = MultiRuleLinter::new_for_document(
265 PathBuf::from("test.md"),
266 config.clone(),
267 input_single,
268 );
269 let violations = linter.analyze();
270 assert_eq!(
271 0,
272 violations.len(),
273 "Single-line list with 1 space should be valid"
274 );
275
276 let mut linter =
278 MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input_multi);
279 let violations = linter.analyze();
280 assert!(
281 !violations.is_empty(),
282 "Multi-line list with 3 spaces should have violations when expecting 1"
283 );
284 }
285
286 #[test]
287 fn test_nested_lists_not_affected() {
288 let input = "* Item 1\n * Nested item 1\n * Nested item 2\n* Item 2\n";
289
290 let config = test_config();
291 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
292 let violations = linter.analyze();
293 assert_eq!(
294 0,
295 violations.len(),
296 "Nested lists with correct spacing should have no violations"
297 );
298 }
299
300 #[test]
301 fn test_no_space_after_marker_has_violations() {
302 let input = "* Item 1\n* Item 2\n";
310
311 let config = test_config();
312 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
313 let violations = linter.analyze();
314 assert!(
315 !violations.is_empty(),
316 "Double space after list marker should have violations with default config expecting 1 space"
317 );
318 }
319
320 #[test]
321 fn test_three_spaces_after_marker_has_violations() {
322 let input = "* Item 1\n* Item 2\n";
323
324 let config = test_config();
325 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
326 let violations = linter.analyze();
327 assert!(
328 !violations.is_empty(),
329 "Three spaces after list marker should have violations with default config"
330 );
331 }
332
333 #[test]
334 fn test_plus_marker_type() {
335 let input = "+ Item 1\n+ Item 2\n";
336
337 let config = test_config();
338 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
339 let violations = linter.analyze();
340 assert_eq!(
341 0,
342 violations.len(),
343 "Plus marker with single space should have no violations"
344 );
345 }
346
347 #[test]
348 fn test_dash_marker_type() {
349 let input = "- Item 1\n- Item 2\n";
350
351 let config = test_config();
352 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
353 let violations = linter.analyze();
354 assert_eq!(
355 0,
356 violations.len(),
357 "Dash marker with single space should have no violations"
358 );
359 }
360}