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 MD007UlIndentTable {
14 #[serde(default)]
15 pub indent: usize,
16 #[serde(default)]
17 pub start_indent: usize,
18 #[serde(default)]
19 pub start_indented: bool,
20}
21
22impl Default for MD007UlIndentTable {
23 fn default() -> Self {
24 Self {
25 indent: 2,
26 start_indent: 2,
27 start_indented: false,
28 }
29 }
30}
31
32pub(crate) struct MD007Linter {
33 context: Rc<Context>,
34 violations: Vec<RuleViolation>,
35}
36
37impl MD007Linter {
38 pub fn new(context: Rc<Context>) -> Self {
39 Self {
40 context,
41 violations: Vec::new(),
42 }
43 }
44}
45
46impl RuleLinter for MD007Linter {
47 fn feed(&mut self, node: &Node) {
48 if node.kind() == "list" && self.is_unordered_list(node) {
49 self.check_list_indentation(node);
50 }
51 }
52
53 fn finalize(&mut self) -> Vec<RuleViolation> {
54 std::mem::take(&mut self.violations)
55 }
56}
57
58impl MD007Linter {
59 fn is_unordered_list(&self, list_node: &Node) -> bool {
61 let mut list_cursor = list_node.walk();
62 if let Some(first_item) = list_node
63 .children(&mut list_cursor)
64 .find(|c| c.kind() == "list_item")
65 {
66 let mut item_cursor = first_item.walk();
67 for child in first_item.children(&mut item_cursor) {
68 if child.kind().starts_with("list_marker") {
69 let content = self.context.document_content.borrow();
70 if let Ok(text) = child.utf8_text(content.as_bytes()) {
71 if let Some(marker_char) = text.trim().chars().next() {
73 return matches!(marker_char, '*' | '+' | '-');
74 }
75 }
76 return false;
78 }
79 }
80 }
81 false
82 }
83
84 fn check_list_indentation(&mut self, list_node: &Node) {
85 let nesting_level = self.calculate_nesting_level(list_node);
86
87 if nesting_level > 0 && !self.all_parents_unordered(list_node) {
89 return;
90 }
91
92 let mut cursor = list_node.walk();
93 for list_item in list_node.children(&mut cursor) {
94 if list_item.kind() == "list_item" {
95 let item_nesting_level = self.calculate_list_item_nesting_level(&list_item);
98 self.check_list_item_indentation(list_item, item_nesting_level);
99 }
100 }
101 }
102
103 fn check_list_item_indentation(&mut self, list_item: Node, nesting_level: usize) {
104 let config = &self.context.config.linters.settings.ul_indent;
105 let actual_indent = self.get_list_item_indentation(&list_item);
106 let expected_indent = self.calculate_expected_indent(nesting_level, config);
107
108 if actual_indent != expected_indent {
109 let message = format!(
110 "{} [Expected: {}; Actual: {}]",
111 MD007.description, expected_indent, actual_indent
112 );
113
114 self.violations.push(RuleViolation::new(
115 &MD007,
116 message,
117 self.context.file_path.clone(),
118 range_from_tree_sitter(&list_item.range()),
119 ));
120 }
121 }
122
123 fn get_list_item_indentation(&self, list_item: &Node) -> usize {
124 let content = self.context.document_content.borrow();
125 let start_line = list_item.start_position().row;
126
127 if let Some(line) = content.lines().nth(start_line) {
128 line.chars().take_while(|&c| c == ' ' || c == '\t').count()
130 } else {
131 0
132 }
133 }
134
135 fn calculate_expected_indent(
136 &self,
137 nesting_level: usize,
138 config: &MD007UlIndentTable,
139 ) -> usize {
140 if nesting_level == 0 {
141 if config.start_indented {
143 config.start_indent
144 } else {
145 0
146 }
147 } else {
148 let base_indent = if config.start_indented {
150 config.start_indent
151 } else {
152 0
153 };
154 base_indent + (nesting_level * config.indent)
155 }
156 }
157
158 fn calculate_nesting_level(&self, list_node: &Node) -> usize {
159 let mut nesting_level = 0;
160 let mut current_node = *list_node;
161
162 while let Some(parent) = current_node.parent() {
164 if parent.kind() == "list" {
165 nesting_level += 1;
166 }
167 current_node = parent;
168 }
169
170 nesting_level
171 }
172
173 fn calculate_list_item_nesting_level(&self, list_item: &Node) -> usize {
174 let mut nesting_level: usize = 0;
175 let mut current_node = *list_item;
176
177 while let Some(parent) = current_node.parent() {
179 if parent.kind() == "list" {
180 nesting_level += 1;
181 }
182 current_node = parent;
183 }
184
185 nesting_level.saturating_sub(1)
188 }
189
190 fn all_parents_unordered(&self, list_node: &Node) -> bool {
191 let mut current_node = *list_node;
192
193 while let Some(parent) = current_node.parent() {
195 if parent.kind() == "list" && !self.is_unordered_list(&parent) {
196 return false;
197 }
198 current_node = parent;
199 }
200
201 true
202 }
203}
204
205pub const MD007: Rule = Rule {
206 id: "MD007",
207 alias: "ul-indent",
208 tags: &["bullet", "indentation", "ul"],
209 description: "Unordered list indentation",
210 rule_type: RuleType::Token,
211 required_nodes: &["list"],
212 new_linter: |context| Box::new(MD007Linter::new(context)),
213};
214
215#[cfg(test)]
216mod test {
217 use std::path::PathBuf;
218
219 use super::MD007UlIndentTable; use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
221 use crate::linter::MultiRuleLinter;
222 use crate::test_utils::test_helpers::test_config_with_rules;
223 use std::collections::HashMap;
224
225 fn test_config() -> QuickmarkConfig {
226 test_config_with_rules(vec![("ul-indent", RuleSeverity::Error)])
227 }
228
229 fn test_config_custom(
230 indent: usize,
231 start_indent: usize,
232 start_indented: bool,
233 ) -> QuickmarkConfig {
234 let severity: HashMap<String, RuleSeverity> =
235 vec![("ul-indent".to_string(), RuleSeverity::Error)]
236 .into_iter()
237 .collect();
238
239 QuickmarkConfig::new(LintersTable {
240 severity,
241 settings: LintersSettingsTable {
242 ul_indent: MD007UlIndentTable {
243 indent,
244 start_indent,
245 start_indented,
246 },
247 ..Default::default()
248 },
249 })
250 }
251
252 #[test]
253 fn test_default_settings_values() {
254 let config = test_config();
255 assert_eq!(2, config.linters.settings.ul_indent.indent);
256 assert_eq!(2, config.linters.settings.ul_indent.start_indent);
257 assert!(!config.linters.settings.ul_indent.start_indented);
258 }
259
260 #[test]
261 fn test_custom_settings_values() {
262 let config = test_config_custom(4, 3, true);
263 assert_eq!(4, config.linters.settings.ul_indent.indent);
264 assert_eq!(3, config.linters.settings.ul_indent.start_indent);
265 assert!(config.linters.settings.ul_indent.start_indented);
266 }
267
268 #[test]
269 fn test_proper_indentation_default_settings() {
270 let input = "* Item 1
271 * Item 2
272 * Item 3
273 * Item 4
274* Item 5
275";
276
277 let config = test_config();
278 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
279 let violations = linter.analyze();
280 assert_eq!(0, violations.len());
281 }
282
283 #[test]
284 fn test_improper_indentation_default_settings() {
285 let input = "* Item 1
286 * Item 2 (1 space, should be 2)
287 * Item 3 (3 spaces, should be 2)
288 * Item 4 (4 spaces, should be 4 for level 2)
289* Item 5
290";
291
292 let config = test_config();
293 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
294 let violations = linter.analyze();
295 assert!(
296 !violations.is_empty(),
297 "Should have violations for improper indentation"
298 );
299 }
300
301 #[test]
302 fn test_start_indented_false_default() {
303 let input = "* Item 1
304 * Item 2
305* Item 3
306";
307
308 let config = test_config();
309 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
310 let violations = linter.analyze();
311 assert_eq!(
312 0,
313 violations.len(),
314 "Top-level items should not be indented by default"
315 );
316 }
317
318 #[test]
319 fn test_start_indented_true() {
320 let input = " * Item 1
321 * Item 2
322 * Item 3
323";
324
325 let config = test_config_custom(2, 2, true);
326 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
327 let violations = linter.analyze();
328 assert_eq!(
329 0,
330 violations.len(),
331 "Top-level items should be indented when start_indented=true"
332 );
333 }
334
335 #[test]
336 fn test_start_indented_true_wrong_indentation() {
337 let input = "* Item 1 (should be indented by start_indent=2)
338 * Item 2
339";
340
341 let config = test_config_custom(2, 2, true);
342 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
343 let violations = linter.analyze();
344 assert!(
345 !violations.is_empty(),
346 "Should have violations when start_indented=true but top-level not indented"
347 );
348 }
349
350 #[test]
351 fn test_different_start_indent_value() {
352 let input = " * Item 1
353 * Item 2
354 * Item 3
355";
356
357 let config = test_config_custom(2, 3, true);
358 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
359 let violations = linter.analyze();
360 assert_eq!(
361 0,
362 violations.len(),
363 "Should use start_indent=3 for first level when start_indented=true"
364 );
365 }
366
367 #[test]
368 fn test_custom_indent_value() {
369 let input = "* Item 1
370 * Item 2 (4 spaces for indent=4)
371 * Item 3 (8 spaces for level 2 with indent=4)
372 * Item 4
373* Item 5
374";
375
376 let config = test_config_custom(4, 2, false);
377 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
378 let violations = linter.analyze();
379 assert_eq!(0, violations.len(), "Should accept custom indent=4");
380 }
381
382 #[test]
383 fn test_mixed_lists_only_ul() {
384 let input = "* Unordered item 1
385 * Unordered item 2
386
3871. Ordered item 1
388 2. Ordered item 2 (this should be ignored)
389";
390
391 let config = test_config();
392 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
393 let violations = linter.analyze();
394 assert_eq!(
395 0,
396 violations.len(),
397 "Should only check unordered lists, ignore ordered lists"
398 );
399 }
400
401 #[test]
402 fn test_nested_unordered_in_ordered() {
403 let input = "1. Ordered item
404 * Unordered nested (should be checked for indentation)
405 * Deeper unordered nested
4062. Another ordered item
407";
408
409 let config = test_config();
410 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
411 let violations = linter.analyze();
412 assert_eq!(
415 0,
416 violations.len(),
417 "Should ignore unordered lists nested in ordered lists"
418 );
419 }
420
421 #[test]
422 fn test_single_item_list() {
423 let input = "* Single item
424";
425
426 let config = test_config();
427 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
428 let violations = linter.analyze();
429 assert_eq!(0, violations.len());
430 }
431
432 #[test]
433 fn test_empty_document() {
434 let input = "";
435
436 let config = test_config();
437 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
438 let violations = linter.analyze();
439 assert_eq!(0, violations.len());
440 }
441
442 #[test]
443 fn test_multiple_list_blocks() {
444 let input = "* List 1 item 1
445 * List 1 item 2
446
447Some text
448
449* List 2 item 1
450 * List 2 item 2
451";
452
453 let config = test_config();
454 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
455 let violations = linter.analyze();
456 assert_eq!(0, violations.len());
457 }
458}