1use crate::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
53use toml;
54
55mod md004_config;
56use md004_config::MD004Config;
57use serde::{Deserialize, Serialize};
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum UnorderedListStyle {
62 Asterisk, Plus, Dash, #[default]
66 Consistent, Sublist, }
69
70#[derive(Clone, Default)]
72pub struct MD004UnorderedListStyle {
73 config: MD004Config,
74}
75
76impl MD004UnorderedListStyle {
77 pub fn new(style: UnorderedListStyle) -> Self {
78 Self {
79 config: MD004Config { style },
80 }
81 }
82
83 pub fn from_config_struct(config: MD004Config) -> Self {
84 Self { config }
85 }
86}
87
88impl Rule for MD004UnorderedListStyle {
89 fn name(&self) -> &'static str {
90 "MD004"
91 }
92
93 fn description(&self) -> &'static str {
94 "Use consistent style for unordered list markers"
95 }
96
97 fn check(&self, ctx: &LintContext) -> LintResult {
98 if ctx.content.is_empty() {
100 return Ok(Vec::new());
101 }
102
103 if !ctx.likely_has_lists() {
105 return Ok(Vec::new());
106 }
107
108 let mut warnings = Vec::new();
109
110 let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
112 let mut asterisk_count = 0;
113 let mut dash_count = 0;
114 let mut plus_count = 0;
115
116 for list_block in &ctx.list_blocks {
117 for &item_line in &list_block.item_lines {
118 if let Some(line_info) = ctx.line_info(item_line)
119 && let Some(list_item) = &line_info.list_item
120 && !list_item.is_ordered
121 {
122 match list_item.marker.chars().next().unwrap() {
123 '*' => asterisk_count += 1,
124 '-' => dash_count += 1,
125 '+' => plus_count += 1,
126 _ => {}
127 }
128 }
129 }
130 }
131
132 if dash_count >= asterisk_count && dash_count >= plus_count {
135 Some('-')
136 } else if asterisk_count >= plus_count {
137 Some('*')
138 } else {
139 Some('+')
140 }
141 } else {
142 None
143 };
144
145 for list_block in &ctx.list_blocks {
147 for &item_line in &list_block.item_lines {
150 if let Some(line_info) = ctx.line_info(item_line)
151 && let Some(list_item) = &line_info.list_item
152 {
153 if list_item.is_ordered {
155 continue;
156 }
157
158 let marker = list_item.marker.chars().next().unwrap();
160
161 let offset = line_info.byte_offset + list_item.marker_column;
163
164 match self.config.style {
165 UnorderedListStyle::Consistent => {
166 if let Some(target) = target_marker_for_consistent
168 && marker != target
169 {
170 let (line, col) = ctx.offset_to_line_col(offset);
171 warnings.push(LintWarning {
172 line,
173 column: col,
174 end_line: line,
175 end_column: col + 1,
176 message: format!("List marker '{marker}' does not match expected style '{target}'"),
177 severity: Severity::Warning,
178 rule_name: Some(self.name().to_string()),
179 fix: Some(Fix {
180 range: offset..offset + 1,
181 replacement: target.to_string(),
182 }),
183 });
184 }
185 }
186 UnorderedListStyle::Sublist => {
187 let nesting_level = list_item.marker_column / 2;
190 let expected_marker = match nesting_level % 3 {
191 0 => '*',
192 1 => '+',
193 2 => '-',
194 _ => {
195 '*'
198 }
199 };
200 if marker != expected_marker {
201 let (line, col) = ctx.offset_to_line_col(offset);
202 warnings.push(LintWarning {
203 line,
204 column: col,
205 end_line: line,
206 end_column: col + 1,
207 message: format!(
208 "List marker '{marker}' does not match expected style '{expected_marker}' for nesting level {nesting_level}"
209 ),
210 severity: Severity::Warning,
211 rule_name: Some(self.name().to_string()),
212 fix: Some(Fix {
213 range: offset..offset + 1,
214 replacement: expected_marker.to_string(),
215 }),
216 });
217 }
218 }
219 _ => {
220 let target_marker = match self.config.style {
222 UnorderedListStyle::Asterisk => '*',
223 UnorderedListStyle::Dash => '-',
224 UnorderedListStyle::Plus => '+',
225 UnorderedListStyle::Consistent | UnorderedListStyle::Sublist => {
226 '*'
229 }
230 };
231 if marker != target_marker {
232 let (line, col) = ctx.offset_to_line_col(offset);
233 warnings.push(LintWarning {
234 line,
235 column: col,
236 end_line: line,
237 end_column: col + 1,
238 message: format!(
239 "List marker '{marker}' does not match expected style '{target_marker}'"
240 ),
241 severity: Severity::Warning,
242 rule_name: Some(self.name().to_string()),
243 fix: Some(Fix {
244 range: offset..offset + 1,
245 replacement: target_marker.to_string(),
246 }),
247 });
248 }
249 }
250 }
251 }
252 }
253 }
254
255 Ok(warnings)
256 }
257
258 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
259 let mut lines: Vec<String> = ctx.content.lines().map(String::from).collect();
260
261 let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
263 let mut asterisk_count = 0;
264 let mut dash_count = 0;
265 let mut plus_count = 0;
266
267 for list_block in &ctx.list_blocks {
268 for &item_line in &list_block.item_lines {
269 if let Some(line_info) = ctx.line_info(item_line)
270 && let Some(list_item) = &line_info.list_item
271 && !list_item.is_ordered
272 {
273 match list_item.marker.chars().next().unwrap() {
274 '*' => asterisk_count += 1,
275 '-' => dash_count += 1,
276 '+' => plus_count += 1,
277 _ => {}
278 }
279 }
280 }
281 }
282
283 if dash_count >= asterisk_count && dash_count >= plus_count {
286 Some('-')
287 } else if asterisk_count >= plus_count {
288 Some('*')
289 } else {
290 Some('+')
291 }
292 } else {
293 None
294 };
295
296 for list_block in &ctx.list_blocks {
298 for &item_line in &list_block.item_lines {
301 if let Some(line_info) = ctx.line_info(item_line)
302 && let Some(list_item) = &line_info.list_item
303 {
304 if list_item.is_ordered {
306 continue;
307 }
308
309 let line_idx = item_line - 1;
310 if line_idx >= lines.len() {
311 continue;
312 }
313
314 let line = &lines[line_idx];
315 let marker = list_item.marker.chars().next().unwrap();
316
317 let target_marker = match self.config.style {
319 UnorderedListStyle::Consistent => target_marker_for_consistent.unwrap_or(marker),
320 UnorderedListStyle::Sublist => {
321 let nesting_level = list_item.marker_column / 2;
324 match nesting_level % 3 {
325 0 => '*',
326 1 => '+',
327 2 => '-',
328 _ => {
329 '*'
332 }
333 }
334 }
335 UnorderedListStyle::Asterisk => '*',
336 UnorderedListStyle::Dash => '-',
337 UnorderedListStyle::Plus => '+',
338 };
339
340 if marker != target_marker {
342 let marker_pos = list_item.marker_column;
343 if marker_pos < line.len() {
344 let mut new_line = String::new();
345 new_line.push_str(&line[..marker_pos]);
346 new_line.push(target_marker);
347 new_line.push_str(&line[marker_pos + 1..]);
348 lines[line_idx] = new_line;
349 }
350 }
351 }
352 }
353 }
354
355 let mut result = lines.join("\n");
356 if ctx.content.ends_with('\n') {
357 result.push('\n');
358 }
359 Ok(result)
360 }
361
362 fn category(&self) -> RuleCategory {
364 RuleCategory::List
365 }
366
367 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
369 ctx.content.is_empty() || !ctx.likely_has_lists()
370 }
371
372 fn as_any(&self) -> &dyn std::any::Any {
373 self
374 }
375
376 fn default_config_section(&self) -> Option<(String, toml::Value)> {
377 let mut map = toml::map::Map::new();
378 map.insert(
379 "style".to_string(),
380 toml::Value::String(match self.config.style {
381 UnorderedListStyle::Asterisk => "asterisk".to_string(),
382 UnorderedListStyle::Dash => "dash".to_string(),
383 UnorderedListStyle::Plus => "plus".to_string(),
384 UnorderedListStyle::Consistent => "consistent".to_string(),
385 UnorderedListStyle::Sublist => "sublist".to_string(),
386 }),
387 );
388 Some((self.name().to_string(), toml::Value::Table(map)))
389 }
390
391 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
392 where
393 Self: Sized,
394 {
395 let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
396 .unwrap_or_else(|| "consistent".to_string());
397 let style = match style.as_str() {
398 "asterisk" => UnorderedListStyle::Asterisk,
399 "dash" => UnorderedListStyle::Dash,
400 "plus" => UnorderedListStyle::Plus,
401 "sublist" => UnorderedListStyle::Sublist,
402 _ => UnorderedListStyle::Consistent,
403 };
404 Box::new(MD004UnorderedListStyle::new(style))
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::lint_context::LintContext;
412 use crate::rule::Rule;
413
414 #[test]
415 fn test_consistent_asterisk_style() {
416 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
417 let content = "* Item 1\n* Item 2\n * Nested\n* Item 3";
418 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
419 let result = rule.check(&ctx).unwrap();
420 assert!(result.is_empty());
421 }
422
423 #[test]
424 fn test_consistent_dash_style() {
425 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
426 let content = "- Item 1\n- Item 2\n - Nested\n- Item 3";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
428 let result = rule.check(&ctx).unwrap();
429 assert!(result.is_empty());
430 }
431
432 #[test]
433 fn test_consistent_plus_style() {
434 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
435 let content = "+ Item 1\n+ Item 2\n + Nested\n+ Item 3";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
437 let result = rule.check(&ctx).unwrap();
438 assert!(result.is_empty());
439 }
440
441 #[test]
442 fn test_inconsistent_style_tie_prefers_dash() {
443 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
444 let content = "* Item 1\n- Item 2\n+ Item 3";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
447 let result = rule.check(&ctx).unwrap();
448 assert_eq!(result.len(), 2);
449 assert_eq!(result[0].line, 1);
451 assert_eq!(result[1].line, 3);
452 }
453
454 #[test]
455 fn test_asterisk_style_enforced() {
456 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
457 let content = "* Item 1\n- Item 2\n+ Item 3";
458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
459 let result = rule.check(&ctx).unwrap();
460 assert_eq!(result.len(), 2);
461 assert_eq!(result[0].message, "List marker '-' does not match expected style '*'");
462 assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
463 }
464
465 #[test]
466 fn test_dash_style_enforced() {
467 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
468 let content = "* Item 1\n- Item 2\n+ Item 3";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(result.len(), 2);
472 assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
473 assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
474 }
475
476 #[test]
477 fn test_plus_style_enforced() {
478 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
479 let content = "* Item 1\n- Item 2\n+ Item 3";
480 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
481 let result = rule.check(&ctx).unwrap();
482 assert_eq!(result.len(), 2);
483 assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
484 assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
485 }
486
487 #[test]
488 fn test_fix_consistent_style_tie_prefers_dash() {
489 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
490 let content = "* Item 1\n- Item 2\n+ Item 3";
492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
493 let fixed = rule.fix(&ctx).unwrap();
494 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
495 }
496
497 #[test]
498 fn test_fix_asterisk_style() {
499 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
500 let content = "- Item 1\n+ Item 2\n- Item 3";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
502 let fixed = rule.fix(&ctx).unwrap();
503 assert_eq!(fixed, "* Item 1\n* Item 2\n* Item 3");
504 }
505
506 #[test]
507 fn test_fix_dash_style() {
508 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
509 let content = "* Item 1\n+ Item 2\n* Item 3";
510 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511 let fixed = rule.fix(&ctx).unwrap();
512 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
513 }
514
515 #[test]
516 fn test_fix_plus_style() {
517 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
518 let content = "* Item 1\n- Item 2\n* Item 3";
519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
520 let fixed = rule.fix(&ctx).unwrap();
521 assert_eq!(fixed, "+ Item 1\n+ Item 2\n+ Item 3");
522 }
523
524 #[test]
525 fn test_nested_lists() {
526 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
527 let content = "* Item 1\n * Nested 1\n * Double nested\n - Wrong marker\n* Item 2";
528 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
529 let result = rule.check(&ctx).unwrap();
530 assert_eq!(result.len(), 1);
531 assert_eq!(result[0].line, 4);
532 }
533
534 #[test]
535 fn test_fix_nested_lists() {
536 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
537 let content = "* Item 1\n - Nested 1\n + Double nested\n - Nested 2\n* Item 2";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
541 let fixed = rule.fix(&ctx).unwrap();
542 assert_eq!(
543 fixed,
544 "- Item 1\n - Nested 1\n - Double nested\n - Nested 2\n- Item 2"
545 );
546 }
547
548 #[test]
549 fn test_with_code_blocks() {
550 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
551 let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
555 assert_eq!(result[0].line, 8);
556 }
557
558 #[test]
559 fn test_with_blockquotes() {
560 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
561 let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
563 let result = rule.check(&ctx).unwrap();
564 assert!(result.len() >= 2);
566 }
567
568 #[test]
569 fn test_empty_document() {
570 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
571 let content = "";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574 assert!(result.is_empty());
575 }
576
577 #[test]
578 fn test_no_lists() {
579 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
580 let content = "This is a paragraph.\n\nAnother paragraph.";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
582 let result = rule.check(&ctx).unwrap();
583 assert!(result.is_empty());
584 }
585
586 #[test]
587 fn test_ordered_lists_ignored() {
588 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
589 let content = "1. Item 1\n2. Item 2\n 1. Nested\n3. Item 3";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
591 let result = rule.check(&ctx).unwrap();
592 assert!(result.is_empty());
593 }
594
595 #[test]
596 fn test_mixed_ordered_unordered() {
597 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
598 let content = "1. Ordered\n * Unordered nested\n - Wrong marker\n2. Another ordered";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601 assert_eq!(result.len(), 1);
602 assert_eq!(result[0].line, 3);
603 }
604
605 #[test]
606 fn test_fix_preserves_content() {
607 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
608 let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
610 let fixed = rule.fix(&ctx).unwrap();
611 assert_eq!(
612 fixed,
613 "- Item with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
614 );
615 }
616
617 #[test]
618 fn test_fix_preserves_indentation() {
619 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
620 let content = " - Indented item\n + Nested item\n - Another indented";
621 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
622 let fixed = rule.fix(&ctx).unwrap();
623 assert_eq!(fixed, " * Indented item\n * Nested item\n * Another indented");
624 }
625
626 #[test]
627 fn test_multiple_spaces_after_marker() {
628 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
629 let content = "* Item 1\n- Item 2\n+ Item 3";
631 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
632 let result = rule.check(&ctx).unwrap();
633 assert_eq!(result.len(), 2);
634 let fixed = rule.fix(&ctx).unwrap();
635 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
636 }
637
638 #[test]
639 fn test_tab_after_marker() {
640 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
641 let content = "*\tItem 1\n-\tItem 2";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(result.len(), 1);
646 let fixed = rule.fix(&ctx).unwrap();
647 assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
648 }
649
650 #[test]
651 fn test_edge_case_marker_at_end() {
652 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
653 let content = "* \n- \n+ ";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656 let result = rule.check(&ctx).unwrap();
657 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
659 assert_eq!(fixed, "* \n* \n* ");
660 }
661
662 #[test]
663 fn test_from_config() {
664 let mut config = crate::config::Config::default();
665 let mut rule_config = crate::config::RuleConfig::default();
666 rule_config
667 .values
668 .insert("style".to_string(), toml::Value::String("plus".to_string()));
669 config.rules.insert("MD004".to_string(), rule_config);
670
671 let rule = MD004UnorderedListStyle::from_config(&config);
672 let content = "* Item 1\n- Item 2";
673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
674 let result = rule.check(&ctx).unwrap();
675 assert_eq!(result.len(), 2);
676 }
677
678 #[test]
679 fn test_default_config_section() {
680 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
681 let config = rule.default_config_section();
682 assert!(config.is_some());
683 let (name, value) = config.unwrap();
684 assert_eq!(name, "MD004");
685 if let toml::Value::Table(table) = value {
686 assert_eq!(table.get("style").and_then(|v| v.as_str()), Some("dash"));
687 } else {
688 panic!("Expected table");
689 }
690 }
691
692 #[test]
693 fn test_sublist_style() {
694 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
695 let content = "* Item 1\n + Item 2\n - Item 3\n * Item 4\n + Item 5";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
698 let result = rule.check(&ctx).unwrap();
699 assert!(result.is_empty(), "Sublist style should accept cycling markers");
700 }
701
702 #[test]
703 fn test_sublist_style_incorrect() {
704 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
705 let content = "- Item 1\n * Item 2\n + Item 3";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
708 let result = rule.check(&ctx).unwrap();
709 assert_eq!(result.len(), 3);
710 assert_eq!(
711 result[0].message,
712 "List marker '-' does not match expected style '*' for nesting level 0"
713 );
714 assert_eq!(
715 result[1].message,
716 "List marker '*' does not match expected style '+' for nesting level 1"
717 );
718 assert_eq!(
719 result[2].message,
720 "List marker '+' does not match expected style '-' for nesting level 2"
721 );
722 }
723
724 #[test]
725 fn test_fix_sublist_style() {
726 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
727 let content = "- Item 1\n - Item 2\n - Item 3\n - Item 4";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
729 let fixed = rule.fix(&ctx).unwrap();
730 assert_eq!(fixed, "* Item 1\n + Item 2\n - Item 3\n * Item 4");
731 }
732
733 #[test]
734 fn test_performance_large_document() {
735 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
736 let mut content = String::new();
737 for i in 0..1000 {
738 content.push_str(&format!(
739 "{}Item {}\n",
740 if i % 3 == 0 {
741 "* "
742 } else if i % 3 == 1 {
743 "- "
744 } else {
745 "+ "
746 },
747 i
748 ));
749 }
750 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard);
751 let result = rule.check(&ctx).unwrap();
752 assert!(result.len() > 600);
754 }
755}