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