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 if self.should_skip(ctx) {
271 return Ok(ctx.content.to_string());
272 }
273 let warnings = self.check(ctx)?;
274 if warnings.is_empty() {
275 return Ok(ctx.content.to_string());
276 }
277 let warnings =
278 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
279 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
280 .map_err(crate::rule::LintError::InvalidInput)
281 }
282
283 fn category(&self) -> RuleCategory {
285 RuleCategory::List
286 }
287
288 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
290 ctx.content.is_empty() || !ctx.likely_has_lists()
291 }
292
293 fn as_any(&self) -> &dyn std::any::Any {
294 self
295 }
296
297 fn default_config_section(&self) -> Option<(String, toml::Value)> {
298 let mut map = toml::map::Map::new();
299 map.insert(
300 "style".to_string(),
301 toml::Value::String(match self.config.style {
302 UnorderedListStyle::Asterisk => "asterisk".to_string(),
303 UnorderedListStyle::Dash => "dash".to_string(),
304 UnorderedListStyle::Plus => "plus".to_string(),
305 UnorderedListStyle::Consistent => "consistent".to_string(),
306 UnorderedListStyle::Sublist => "sublist".to_string(),
307 }),
308 );
309 Some((self.name().to_string(), toml::Value::Table(map)))
310 }
311
312 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
313 where
314 Self: Sized,
315 {
316 let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
317 .unwrap_or_else(|| "consistent".to_string());
318 let style = match style.as_str() {
319 "asterisk" => UnorderedListStyle::Asterisk,
320 "dash" => UnorderedListStyle::Dash,
321 "plus" => UnorderedListStyle::Plus,
322 "sublist" => UnorderedListStyle::Sublist,
323 _ => UnorderedListStyle::Consistent,
324 };
325 Box::new(MD004UnorderedListStyle::new(style))
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::lint_context::LintContext;
333 use crate::rule::Rule;
334
335 #[test]
336 fn test_consistent_asterisk_style() {
337 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
338 let content = "* Item 1\n* Item 2\n * Nested\n* Item 3";
339 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
340 let result = rule.check(&ctx).unwrap();
341 assert!(result.is_empty());
342 }
343
344 #[test]
345 fn test_consistent_dash_style() {
346 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
347 let content = "- Item 1\n- Item 2\n - Nested\n- Item 3";
348 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
349 let result = rule.check(&ctx).unwrap();
350 assert!(result.is_empty());
351 }
352
353 #[test]
354 fn test_consistent_plus_style() {
355 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
356 let content = "+ Item 1\n+ Item 2\n + Nested\n+ Item 3";
357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
358 let result = rule.check(&ctx).unwrap();
359 assert!(result.is_empty());
360 }
361
362 #[test]
363 fn test_inconsistent_style_tie_prefers_dash() {
364 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
365 let content = "* Item 1\n- Item 2\n+ Item 3";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let result = rule.check(&ctx).unwrap();
369 assert_eq!(result.len(), 2);
370 assert_eq!(result[0].line, 1);
372 assert_eq!(result[1].line, 3);
373 }
374
375 #[test]
376 fn test_asterisk_style_enforced() {
377 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
378 let content = "* Item 1\n- Item 2\n+ Item 3";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381 assert_eq!(result.len(), 2);
382 assert_eq!(result[0].message, "List marker '-' does not match expected style '*'");
383 assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
384 }
385
386 #[test]
387 fn test_dash_style_enforced() {
388 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
389 let content = "* Item 1\n- Item 2\n+ Item 3";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert_eq!(result.len(), 2);
393 assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
394 assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
395 }
396
397 #[test]
398 fn test_plus_style_enforced() {
399 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
400 let content = "* Item 1\n- Item 2\n+ Item 3";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403 assert_eq!(result.len(), 2);
404 assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
405 assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
406 }
407
408 #[test]
409 fn test_fix_consistent_style_tie_prefers_dash() {
410 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
411 let content = "* Item 1\n- Item 2\n+ Item 3";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let fixed = rule.fix(&ctx).unwrap();
415 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
416 }
417
418 #[test]
419 fn test_fix_asterisk_style() {
420 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
421 let content = "- Item 1\n+ Item 2\n- Item 3";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let fixed = rule.fix(&ctx).unwrap();
424 assert_eq!(fixed, "* Item 1\n* Item 2\n* Item 3");
425 }
426
427 #[test]
428 fn test_fix_dash_style() {
429 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
430 let content = "* Item 1\n+ Item 2\n* Item 3";
431 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
432 let fixed = rule.fix(&ctx).unwrap();
433 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
434 }
435
436 #[test]
437 fn test_fix_plus_style() {
438 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
439 let content = "* Item 1\n- Item 2\n* Item 3";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441 let fixed = rule.fix(&ctx).unwrap();
442 assert_eq!(fixed, "+ Item 1\n+ Item 2\n+ Item 3");
443 }
444
445 #[test]
446 fn test_nested_lists() {
447 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
448 let content = "* Item 1\n * Nested 1\n * Double nested\n - Wrong marker\n* Item 2";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert_eq!(result.len(), 1);
452 assert_eq!(result[0].line, 4);
453 }
454
455 #[test]
456 fn test_fix_nested_lists() {
457 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
458 let content = "* Item 1\n - Nested 1\n + Double nested\n - Nested 2\n* Item 2";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let fixed = rule.fix(&ctx).unwrap();
463 assert_eq!(
464 fixed,
465 "- Item 1\n - Nested 1\n - Double nested\n - Nested 2\n- Item 2"
466 );
467 }
468
469 #[test]
470 fn test_with_code_blocks() {
471 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
472 let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert_eq!(result.len(), 1);
476 assert_eq!(result[0].line, 8);
477 }
478
479 #[test]
480 fn test_with_blockquotes() {
481 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
482 let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
483 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
484 let result = rule.check(&ctx).unwrap();
485 assert!(result.len() >= 2);
487 }
488
489 #[test]
490 fn test_empty_document() {
491 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
492 let content = "";
493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
494 let result = rule.check(&ctx).unwrap();
495 assert!(result.is_empty());
496 }
497
498 #[test]
499 fn test_no_lists() {
500 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
501 let content = "This is a paragraph.\n\nAnother paragraph.";
502 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
503 let result = rule.check(&ctx).unwrap();
504 assert!(result.is_empty());
505 }
506
507 #[test]
508 fn test_ordered_lists_ignored() {
509 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
510 let content = "1. Item 1\n2. Item 2\n 1. Nested\n3. Item 3";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(result.is_empty());
514 }
515
516 #[test]
517 fn test_mixed_ordered_unordered() {
518 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
519 let content = "1. Ordered\n * Unordered nested\n - Wrong marker\n2. Another ordered";
520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1);
523 assert_eq!(result[0].line, 3);
524 }
525
526 #[test]
527 fn test_fix_preserves_content() {
528 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
529 let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let fixed = rule.fix(&ctx).unwrap();
532 assert_eq!(
533 fixed,
534 "- Item with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
535 );
536 }
537
538 #[test]
539 fn test_fix_preserves_indentation() {
540 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
541 let content = " - Indented item\n + Nested item\n - Another indented";
542 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let fixed = rule.fix(&ctx).unwrap();
544 assert_eq!(fixed, " * Indented item\n * Nested item\n * Another indented");
545 }
546
547 #[test]
548 fn test_multiple_spaces_after_marker() {
549 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
550 let content = "* Item 1\n- Item 2\n+ Item 3";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 2);
555 let fixed = rule.fix(&ctx).unwrap();
556 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
557 }
558
559 #[test]
560 fn test_tab_after_marker() {
561 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
562 let content = "*\tItem 1\n-\tItem 2";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert_eq!(result.len(), 1);
567 let fixed = rule.fix(&ctx).unwrap();
568 assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
569 }
570
571 #[test]
572 fn test_edge_case_marker_at_end() {
573 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
574 let content = "* \n- \n+ ";
576 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
577 let result = rule.check(&ctx).unwrap();
578 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
580 assert_eq!(fixed, "* \n* \n* ");
581 }
582
583 #[test]
584 fn test_from_config() {
585 let mut config = crate::config::Config::default();
586 let mut rule_config = crate::config::RuleConfig::default();
587 rule_config
588 .values
589 .insert("style".to_string(), toml::Value::String("plus".to_string()));
590 config.rules.insert("MD004".to_string(), rule_config);
591
592 let rule = MD004UnorderedListStyle::from_config(&config);
593 let content = "* Item 1\n- Item 2";
594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
595 let result = rule.check(&ctx).unwrap();
596 assert_eq!(result.len(), 2);
597 }
598
599 #[test]
600 fn test_default_config_section() {
601 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
602 let config = rule.default_config_section();
603 assert!(config.is_some());
604 let (name, value) = config.unwrap();
605 assert_eq!(name, "MD004");
606 if let toml::Value::Table(table) = value {
607 assert_eq!(table.get("style").and_then(|v| v.as_str()), Some("dash"));
608 } else {
609 panic!("Expected table");
610 }
611 }
612
613 #[test]
614 fn test_sublist_style() {
615 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
616 let content = "* Item 1\n + Item 2\n - Item 3\n * Item 4\n + Item 5";
618 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
619 let result = rule.check(&ctx).unwrap();
620 assert!(result.is_empty(), "Sublist style should accept cycling markers");
621 }
622
623 #[test]
624 fn test_sublist_style_incorrect() {
625 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
626 let content = "- Item 1\n * Item 2\n + Item 3";
628 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(result.len(), 3);
631 assert_eq!(
632 result[0].message,
633 "List marker '-' does not match expected style '*' for nesting level 0"
634 );
635 assert_eq!(
636 result[1].message,
637 "List marker '*' does not match expected style '+' for nesting level 1"
638 );
639 assert_eq!(
640 result[2].message,
641 "List marker '+' does not match expected style '-' for nesting level 2"
642 );
643 }
644
645 #[test]
646 fn test_fix_sublist_style() {
647 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
648 let content = "- Item 1\n - Item 2\n - Item 3\n - Item 4";
649 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650 let fixed = rule.fix(&ctx).unwrap();
651 assert_eq!(fixed, "* Item 1\n + Item 2\n - Item 3\n * Item 4");
652 }
653
654 #[test]
655 fn test_performance_large_document() {
656 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
657 let mut content = String::new();
658 for i in 0..1000 {
659 content.push_str(&format!(
660 "{}Item {}\n",
661 if i % 3 == 0 {
662 "* "
663 } else if i % 3 == 1 {
664 "- "
665 } else {
666 "+ "
667 },
668 i
669 ));
670 }
671 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(result.len() > 600);
675 }
676}