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