1use std::rc::Rc;
2
3use tree_sitter::Node;
4
5use crate::{
6 linter::{range_from_tree_sitter, RuleViolation},
7 rules::{Context, Rule, RuleLinter, RuleType},
8};
9
10pub(crate) struct MD005Linter {
11 context: Rc<Context>,
12 violations: Vec<RuleViolation>,
13}
14
15impl MD005Linter {
16 pub fn new(context: Rc<Context>) -> Self {
17 Self {
18 context,
19 violations: Vec::new(),
20 }
21 }
22}
23
24impl RuleLinter for MD005Linter {
25 fn feed(&mut self, node: &Node) {
26 if node.kind() == "list" {
27 self.check_list_indentation(node);
28 }
29 }
30
31 fn finalize(&mut self) -> Vec<RuleViolation> {
32 std::mem::take(&mut self.violations)
33 }
34}
35
36impl MD005Linter {
37 fn check_list_indentation(&mut self, list_node: &Node) {
38 let list_items = Self::get_direct_list_items_static(list_node);
39 if list_items.len() < 2 {
40 return;
42 }
43
44 let is_ordered = Self::is_ordered_list_static(
45 list_node,
46 self.context.document_content.borrow().as_bytes(),
47 );
48
49 if is_ordered {
50 self.check_ordered_list_indentation(list_node, &list_items);
51 } else {
52 self.check_unordered_list_indentation(list_node, &list_items);
53 }
54 }
55
56 fn get_direct_list_items_static<'a>(list_node: &Node<'a>) -> Vec<Node<'a>> {
57 let mut cursor = list_node.walk();
58 list_node
59 .children(&mut cursor)
60 .filter(|c| c.kind() == "list_item")
61 .collect()
62 }
63
64 fn is_ordered_list_static(list_node: &Node, content: &[u8]) -> bool {
65 let mut list_cursor = list_node.walk();
66 if let Some(first_item) = list_node
67 .children(&mut list_cursor)
68 .find(|c| c.kind() == "list_item")
69 {
70 let mut item_cursor = first_item.walk();
71 for child in first_item.children(&mut item_cursor) {
73 if child.kind().starts_with("list_marker") {
74 if let Ok(text) = child.utf8_text(content) {
75 return text.contains('.');
76 }
77 return false;
79 }
80 }
81 }
82 false
83 }
84
85 fn check_unordered_list_indentation(&mut self, _list_node: &Node, list_items: &[Node]) {
86 let expected_indent = self.get_list_item_indentation(&list_items[0]);
87
88 for item in list_items.iter().skip(1) {
89 let actual_indent = self.get_list_item_indentation(item);
90
91 if actual_indent != expected_indent {
92 let message = format!(
93 "{} [Expected: {}; Actual: {}]",
94 MD005.description, expected_indent, actual_indent
95 );
96
97 self.violations.push(RuleViolation::new(
98 &MD005,
99 message,
100 self.context.file_path.clone(),
101 range_from_tree_sitter(&item.range()),
102 ));
103 }
104 }
105 }
106
107 fn check_ordered_list_indentation(&mut self, _list_node: &Node, list_items: &[Node]) {
108 let expected_indent = self.get_list_item_indentation(&list_items[0]);
110 let mut expected_end = 0;
111 let mut end_matching = false;
112
113 for item in list_items {
114 let actual_indent = self.get_list_item_indentation(item);
115 let marker_length = self.get_list_marker_text_length(item);
116 let actual_end = actual_indent + marker_length;
117
118 expected_end = if expected_end == 0 {
119 actual_end
120 } else {
121 expected_end
122 };
123
124 if expected_indent != actual_indent || end_matching {
125 if expected_end == actual_end {
126 end_matching = true;
127 } else {
128 let detail = if end_matching {
129 format!("Expected: ({expected_end}); Actual: ({actual_end})")
130 } else {
131 format!("Expected: {expected_indent}; Actual: {actual_indent}")
132 };
133
134 self.violations.push(RuleViolation::new(
135 &MD005,
136 format!("{} [{}]", MD005.description, detail),
137 self.context.file_path.clone(),
138 range_from_tree_sitter(&item.range()),
139 ));
140 }
141 }
142 }
143 }
144
145 fn get_list_marker_text_length(&self, list_item: &Node) -> usize {
146 let mut cursor = list_item.walk();
147 if let Some(marker_node) = list_item
148 .children(&mut cursor)
149 .find(|c| c.kind().starts_with("list_marker"))
150 {
151 let content = self.context.document_content.borrow();
152 if let Ok(text) = marker_node.utf8_text(content.as_bytes()) {
153 return text.trim().len();
154 }
155 }
156 0
157 }
158
159 fn get_list_item_indentation(&self, list_item: &Node) -> usize {
160 let content = self.context.document_content.borrow();
161 let start_line = list_item.start_position().row;
162
163 if let Some(line) = content.lines().nth(start_line) {
164 line.chars().take_while(|&c| c == ' ' || c == '\t').count()
166 } else {
167 0
168 }
169 }
170}
171
172pub const MD005: Rule = Rule {
173 id: "MD005",
174 alias: "list-indent",
175 tags: &["bullet", "ul", "indentation"],
176 description: "Inconsistent indentation for list items at the same level",
177 rule_type: RuleType::Token,
178 required_nodes: &["list"],
179 new_linter: |context| Box::new(MD005Linter::new(context)),
180};
181
182#[cfg(test)]
183mod test {
184 use std::path::PathBuf;
185
186 use crate::config::{QuickmarkConfig, RuleSeverity};
187 use crate::linter::MultiRuleLinter;
188 use crate::test_utils::test_helpers::test_config_with_rules;
189
190 fn test_config() -> QuickmarkConfig {
191 test_config_with_rules(vec![("list-indent", RuleSeverity::Error)])
192 }
193
194 #[test]
195 fn test_consistent_unordered_list_indentation_no_violations() {
196 let input = "* Item 1
197* Item 2
198* Item 3
199";
200
201 let config = test_config();
202 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
203 let violations = linter.analyze();
204 assert_eq!(
205 0,
206 violations.len(),
207 "Consistent indentation should have no violations"
208 );
209 }
210
211 #[test]
212 fn test_inconsistent_unordered_list_indentation_has_violations() {
213 let input = "* Item 1
214 * Item 2 (1 space instead of 0)
215* Item 3
216";
217
218 let config = test_config();
219 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
220 let violations = linter.analyze();
221 assert!(
222 !violations.is_empty(),
223 "Inconsistent indentation should have violations"
224 );
225 }
226
227 #[test]
228 fn test_consistent_ordered_list_left_aligned_no_violations() {
229 let input = "1. Item 1
2302. Item 2
23110. Item 10
23211. Item 11
233";
234
235 let config = test_config();
236 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
237 let violations = linter.analyze();
238 assert_eq!(
239 0,
240 violations.len(),
241 "Left-aligned ordered list should have no violations"
242 );
243 }
244
245 #[test]
246 fn test_consistent_ordered_list_right_aligned_no_violations() {
247 let input = " 1. Item 1
248 2. Item 2
24910. Item 10
25011. Item 11
251";
252
253 let config = test_config();
254 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
255 let violations = linter.analyze();
256 assert_eq!(
257 0,
258 violations.len(),
259 "Right-aligned ordered list should have no violations"
260 );
261 }
262
263 #[test]
264 fn test_inconsistent_ordered_list_has_violations() {
265 let input = "1. Item 1
266 2. Item 2 (should be at same indent as item 1)
2673. Item 3
268";
269
270 let config = test_config();
271 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
272 let violations = linter.analyze();
273 assert!(
274 !violations.is_empty(),
275 "Inconsistent ordered list indentation should have violations"
276 );
277 }
278
279 #[test]
280 fn test_nested_lists_different_levels_no_violations() {
281 let input = "* Item 1
282 * Nested item 1
283 * Nested item 2
284* Item 2
285 * Nested item 3
286";
287
288 let config = test_config();
289 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
290 let violations = linter.analyze();
291 assert_eq!(
292 0,
293 violations.len(),
294 "Items at different nesting levels should not be compared"
295 );
296 }
297
298 #[test]
299 fn test_nested_lists_same_level_inconsistent() {
300 let input = "* Item 1
301 * Nested item 1
302 * Nested item 2 (should be 2 spaces like item 1)
303* Item 2
304";
305
306 let config = test_config();
307 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
308 let violations = linter.analyze();
309 assert!(
310 !violations.is_empty(),
311 "Nested items at same level with inconsistent indent should have violations"
312 );
313 }
314
315 #[test]
316 fn test_mixed_ordered_unordered_lists() {
317 let input = "1. Ordered item 1
3182. Ordered item 2
319
320* Unordered item 1
321* Unordered item 2
322";
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_eq!(
328 0,
329 violations.len(),
330 "Different list types should not interfere with each other"
331 );
332 }
333
334 #[test]
335 fn test_single_item_list_no_violations() {
336 let input = "* Single item
337";
338
339 let config = test_config();
340 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
341 let violations = linter.analyze();
342 assert_eq!(
343 0,
344 violations.len(),
345 "Single item lists should not have violations"
346 );
347 }
348
349 #[test]
350 fn test_empty_document_no_violations() {
351 let input = "";
352
353 let config = test_config();
354 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
355 let violations = linter.analyze();
356 assert_eq!(
357 0,
358 violations.len(),
359 "Empty documents should not have violations"
360 );
361 }
362
363 #[test]
364 fn test_ordered_list_with_different_number_lengths() {
365 let input = " 1. Item 1
366 2. Item 2
367 3. Item 3
368 4. Item 4
369 5. Item 5
370 6. Item 6
371 7. Item 7
372 8. Item 8
373 9. Item 9
37410. Item 10
37511. Item 11
37612. Item 12
377";
378
379 let config = test_config();
380 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
381 let violations = linter.analyze();
382 assert_eq!(
383 0,
384 violations.len(),
385 "Right-aligned numbers should be consistent"
386 );
387 }
388
389 #[test]
390 fn test_ordered_list_inconsistent_right_alignment() {
391 let input = " 1. Item 1
392 2. Item 2
39310. Item 10
394 11. Item 11 (should align with 10, not with 1/2)
395";
396
397 let config = test_config();
398 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
399 let violations = linter.analyze();
400 assert!(
401 !violations.is_empty(),
402 "Inconsistent right alignment should have violations"
403 );
404 }
405}