1use serde::Deserialize;
2use std::collections::HashMap;
3use std::rc::Rc;
4
5use tree_sitter::Node;
6
7use crate::{
8 linter::{range_from_tree_sitter, RuleViolation},
9 rules::{Context, Rule, RuleLinter, RuleType},
10};
11
12#[derive(Debug, PartialEq, Clone, Deserialize)]
14pub enum UlStyle {
15 #[serde(rename = "asterisk")]
16 Asterisk,
17 #[serde(rename = "consistent")]
18 Consistent,
19 #[serde(rename = "dash")]
20 Dash,
21 #[serde(rename = "plus")]
22 Plus,
23 #[serde(rename = "sublist")]
24 Sublist,
25}
26
27impl Default for UlStyle {
28 fn default() -> Self {
29 Self::Consistent
30 }
31}
32
33#[derive(Debug, PartialEq, Clone, Deserialize)]
34pub struct MD004UlStyleTable {
35 #[serde(default)]
36 pub style: UlStyle,
37}
38
39impl Default for MD004UlStyleTable {
40 fn default() -> Self {
41 Self {
42 style: UlStyle::Consistent,
43 }
44 }
45}
46
47pub(crate) struct MD004Linter {
48 context: Rc<Context>,
49 violations: Vec<RuleViolation>,
50 nesting_styles: HashMap<usize, char>, document_expected_style: Option<char>, }
53
54impl MD004Linter {
55 pub fn new(context: Rc<Context>) -> Self {
56 Self {
57 context,
58 violations: Vec::new(),
59 nesting_styles: HashMap::new(),
60 document_expected_style: None,
61 }
62 }
63
64 fn extract_marker(text: &str) -> Option<char> {
66 text.trim()
67 .chars()
68 .next()
69 .filter(|&c| c == '*' || c == '+' || c == '-')
70 }
71
72 fn marker_to_style_name(marker: char) -> &'static str {
74 match marker {
75 '*' => "asterisk",
76 '+' => "plus",
77 '-' => "dash",
78 _ => "unknown",
79 }
80 }
81
82 fn style_to_marker(style: &UlStyle) -> Option<char> {
84 match style {
85 UlStyle::Asterisk => Some('*'),
86 UlStyle::Dash => Some('-'),
87 UlStyle::Plus => Some('+'),
88 UlStyle::Consistent | UlStyle::Sublist => None, }
90 }
91
92 fn find_list_item_markers<'a>(&self, list_node: &Node<'a>) -> Vec<(Node<'a>, char)> {
94 let mut markers = Vec::new();
95 let content = self.context.document_content.borrow();
96 let source_bytes = content.as_bytes();
97 let mut list_cursor = list_node.walk();
98
99 for list_item in list_node.children(&mut list_cursor) {
100 if list_item.kind() == "list_item" {
101 let mut item_cursor = list_item.walk();
103 for child in list_item.children(&mut item_cursor) {
104 if child.kind().starts_with("list_marker") {
105 if let Some(marker_char) = child
106 .utf8_text(source_bytes)
107 .ok()
108 .and_then(Self::extract_marker)
109 {
110 markers.push((child, marker_char));
111 }
112 break;
114 }
115 }
116 }
117 }
118 markers
119 }
120
121 fn calculate_nesting_level(&self, list_node: &Node) -> usize {
123 let mut nesting_level = 0;
124 let mut current_node = *list_node;
125
126 while let Some(parent) = current_node.parent() {
128 if parent.kind() == "list" {
129 nesting_level += 1;
130 }
131 current_node = parent;
132 }
133
134 nesting_level
135 }
136
137 fn check_list(&mut self, node: &Node) {
138 let style = &self.context.config.linters.settings.ul_style.style;
139
140 let marker_info: Vec<(tree_sitter::Range, char)> = {
142 let markers = self.find_list_item_markers(node);
143 markers
144 .into_iter()
145 .map(|(node, marker)| (node.range(), marker))
146 .collect()
147 };
148
149 if marker_info.is_empty() {
150 return; }
152
153 let nesting_level = self.calculate_nesting_level(node);
154
155 let expected_marker: Option<char>;
159
160 match style {
161 UlStyle::Consistent => {
162 if let Some(document_style) = self.document_expected_style {
164 expected_marker = Some(document_style);
165 } else {
166 expected_marker = Some(marker_info[0].1);
168 self.document_expected_style = expected_marker;
169 }
170 }
171 UlStyle::Asterisk | UlStyle::Dash | UlStyle::Plus => {
172 expected_marker = Self::style_to_marker(style);
173 }
174 UlStyle::Sublist => {
175 if let Some(&parent_marker) =
177 self.nesting_styles.get(&nesting_level.saturating_sub(1))
178 {
179 expected_marker = Some(match parent_marker {
181 '*' => '+',
182 '+' => '-',
183 '-' => '*',
184 _ => '*',
185 });
186 } else {
187 expected_marker = Some(
189 marker_info
190 .first()
191 .map(|(_, marker)| *marker)
192 .unwrap_or('*'),
193 );
194 }
195
196 if let Some(marker) = expected_marker {
198 self.nesting_styles.insert(nesting_level, marker);
199 }
200 }
201 }
202
203 if let Some(expected) = expected_marker {
205 for (range, actual_marker) in marker_info {
206 if actual_marker != expected {
207 let message = format!(
208 "{} [Expected: {}; Actual: {}]",
209 MD004.description,
210 Self::marker_to_style_name(expected),
211 Self::marker_to_style_name(actual_marker)
212 );
213
214 self.violations.push(RuleViolation::new(
215 &MD004,
216 message,
217 self.context.file_path.clone(),
218 range_from_tree_sitter(&range),
219 ));
220 }
221 }
222 }
223 }
224}
225
226impl RuleLinter for MD004Linter {
227 fn feed(&mut self, node: &Node) {
228 if node.kind() == "list" {
229 if self.is_unordered_list(node) {
231 self.check_list(node);
232 }
233 }
234 }
235
236 fn finalize(&mut self) -> Vec<RuleViolation> {
237 std::mem::take(&mut self.violations)
238 }
239}
240
241impl MD004Linter {
242 fn is_unordered_list(&self, list_node: &Node) -> bool {
244 let mut list_cursor = list_node.walk();
245 for list_item in list_node.children(&mut list_cursor) {
246 if list_item.kind() == "list_item" {
247 let mut item_cursor = list_item.walk();
248 for child in list_item.children(&mut item_cursor) {
249 if child.kind().starts_with("list_marker") {
250 let content = self.context.document_content.borrow();
251 if let Ok(text) = child.utf8_text(content.as_bytes()) {
252 if let Some(marker_char) = text.trim().chars().next() {
253 return matches!(marker_char, '*' | '+' | '-');
254 }
255 }
256 return false; }
258 }
259 }
260 }
261 false
262 }
263}
264
265pub const MD004: Rule = Rule {
266 id: "MD004",
267 alias: "ul-style",
268 tags: &["bullet", "ul"],
269 description: "Unordered list style",
270 rule_type: RuleType::Token,
271 required_nodes: &["list"],
272 new_linter: |context| Box::new(MD004Linter::new(context)),
273};
274
275#[cfg(test)]
276mod test {
277 use std::path::PathBuf;
278
279 use crate::config::RuleSeverity;
280 use crate::linter::MultiRuleLinter;
281 use crate::test_utils::test_helpers::test_config_with_rules;
282
283 fn test_config() -> crate::config::QuickmarkConfig {
284 test_config_with_rules(vec![("ul-style", RuleSeverity::Error)])
285 }
286
287 fn test_config_sublist() -> crate::config::QuickmarkConfig {
288 use super::{MD004UlStyleTable, UlStyle}; use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
290 use std::collections::HashMap;
291
292 let severity: HashMap<String, RuleSeverity> =
293 vec![("ul-style".to_string(), RuleSeverity::Error)]
294 .into_iter()
295 .collect();
296
297 QuickmarkConfig::new(LintersTable {
298 severity,
299 settings: LintersSettingsTable {
300 ul_style: MD004UlStyleTable {
301 style: UlStyle::Sublist,
302 },
303 ..Default::default()
304 },
305 })
306 }
307
308 fn test_config_asterisk() -> crate::config::QuickmarkConfig {
309 use super::{MD004UlStyleTable, UlStyle}; use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
311 use std::collections::HashMap;
312
313 let severity: HashMap<String, RuleSeverity> =
314 vec![("ul-style".to_string(), RuleSeverity::Error)]
315 .into_iter()
316 .collect();
317
318 QuickmarkConfig::new(LintersTable {
319 severity,
320 settings: LintersSettingsTable {
321 ul_style: MD004UlStyleTable {
322 style: UlStyle::Asterisk,
323 },
324 ..Default::default()
325 },
326 })
327 }
328
329 fn test_config_dash() -> crate::config::QuickmarkConfig {
330 use super::{MD004UlStyleTable, UlStyle}; use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
332 use std::collections::HashMap;
333
334 let severity: HashMap<String, RuleSeverity> =
335 vec![("ul-style".to_string(), RuleSeverity::Error)]
336 .into_iter()
337 .collect();
338
339 QuickmarkConfig::new(LintersTable {
340 severity,
341 settings: LintersSettingsTable {
342 ul_style: MD004UlStyleTable {
343 style: UlStyle::Dash,
344 },
345 ..Default::default()
346 },
347 })
348 }
349
350 fn test_config_plus() -> crate::config::QuickmarkConfig {
351 use super::{MD004UlStyleTable, UlStyle}; use crate::config::{LintersSettingsTable, LintersTable, QuickmarkConfig, RuleSeverity};
353 use std::collections::HashMap;
354
355 let severity: HashMap<String, RuleSeverity> =
356 vec![("ul-style".to_string(), RuleSeverity::Error)]
357 .into_iter()
358 .collect();
359
360 QuickmarkConfig::new(LintersTable {
361 severity,
362 settings: LintersSettingsTable {
363 ul_style: MD004UlStyleTable {
364 style: UlStyle::Plus,
365 },
366 ..Default::default()
367 },
368 })
369 }
370
371 #[test]
372 fn test_consistent_asterisk_passes() {
373 let input = "* Item 1
374* Item 2
375* Item 3
376";
377
378 let config = test_config();
379 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
380 let violations = linter.analyze();
381 assert_eq!(0, violations.len());
382 }
383
384 #[test]
385 fn test_consistent_dash_passes() {
386 let input = "- Item 1
387- Item 2
388- Item 3
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!(0, violations.len());
395 }
396
397 #[test]
398 fn test_consistent_plus_passes() {
399 let input = "+ Item 1
400+ Item 2
401+ Item 3
402";
403
404 let config = test_config();
405 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
406 let violations = linter.analyze();
407 assert_eq!(0, violations.len());
408 }
409
410 #[test]
411 fn test_inconsistent_mixed_fails() {
412 let input = "* Item 1
413+ Item 2
414- Item 3
415";
416
417 let config = test_config();
418 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
419 let violations = linter.analyze();
420 assert_eq!(2, violations.len());
422 }
423
424 #[test]
425 fn test_asterisk_style_enforced() {
426 let input = "- Item 1
427- Item 2
428";
429
430 let config = test_config_asterisk();
431 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
432 let violations = linter.analyze();
433 assert_eq!(2, violations.len());
435 assert!(violations[0].message().contains("Expected: asterisk"));
436 assert!(violations[0].message().contains("Actual: dash"));
437 }
438
439 #[test]
440 fn test_dash_style_enforced() {
441 let input = "* Item 1
442+ Item 2
443* Item 3
444";
445
446 let config = test_config_dash();
447 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
448 let violations = linter.analyze();
449 assert_eq!(3, violations.len());
451 assert!(violations[0].message().contains("Expected: dash"));
452 assert!(violations[0].message().contains("Actual: asterisk"));
453 }
454
455 #[test]
456 fn test_plus_style_enforced() {
457 let input = "- Item 1
458* Item 2
459";
460
461 let config = test_config_plus();
462 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
463 let violations = linter.analyze();
464 assert_eq!(2, violations.len());
466 assert!(violations[0].message().contains("Expected: plus"));
467 assert!(violations[0].message().contains("Actual: dash"));
468 }
469
470 #[test]
471 fn test_asterisk_style_passes() {
472 let input = "* Item 1
473* Item 2
474* Item 3
475";
476
477 let config = test_config_asterisk();
478 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
479 let violations = linter.analyze();
480 assert_eq!(0, violations.len());
481 }
482
483 #[test]
484 fn test_dash_style_passes() {
485 let input = "- Item 1
486- Item 2
487- Item 3
488";
489
490 let config = test_config_dash();
491 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
492 let violations = linter.analyze();
493 assert_eq!(0, violations.len());
494 }
495
496 #[test]
497 fn test_plus_style_passes() {
498 let input = "+ Item 1
499+ Item 2
500+ Item 3
501";
502
503 let config = test_config_plus();
504 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
505 let violations = linter.analyze();
506 assert_eq!(0, violations.len());
507 }
508
509 #[test]
510 fn test_nested_lists_sublist_style() {
511 let input = "* Item 1
512 + Item 2
513 - Item 3
514 + Item 4
515* Item 5
516 + Item 6
517";
518
519 let config = test_config_sublist();
520 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
521 let violations = linter.analyze();
522 assert_eq!(0, violations.len());
524 }
525
526 #[test]
527 fn test_nested_lists_consistent_within_level() {
528 let input = "* Item 1
529 * Item 2
530 * Item 3
531 * Item 4
532* Item 5
533 * Item 6
534";
535
536 let config = test_config();
537 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
538 let violations = linter.analyze();
539 assert_eq!(0, violations.len());
540 }
541
542 #[test]
543 fn test_nested_lists_inconsistent_within_level_fails() {
544 let input = "* Item 1
545 + Item 2
546 - Item 3
547 + Item 4
548* Item 5
549 - Item 6 // This should fail - inconsistent with level 1 asterisks
550";
551
552 let config = test_config();
553 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
554 let violations = linter.analyze();
555 assert_eq!(4, violations.len());
557 }
558
559 #[test]
560 fn test_single_item_list() {
561 let input = "* Single item
562";
563
564 let config = test_config();
565 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
566 let violations = linter.analyze();
567 assert_eq!(0, violations.len());
568 }
569
570 #[test]
571 fn test_empty_document() {
572 let input = "";
573
574 let config = test_config();
575 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
576 let violations = linter.analyze();
577 assert_eq!(0, violations.len());
578 }
579
580 #[test]
581 fn test_lists_separated_by_content() {
582 let input = "* Item 1
583* Item 2
584
585Some paragraph text
586
587- Item 3
588- Item 4
589";
590
591 let config = test_config();
592 let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
593 let violations = linter.analyze();
594 assert_eq!(2, violations.len());
597 }
598}