1use crate::LintContext;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
53use crate::utils::list_fix_guard::is_inside_literal_code;
54use toml;
55
56mod md004_config;
57use md004_config::MD004Config;
58use serde::{Deserialize, Serialize};
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum UnorderedListStyle {
63 Asterisk, Plus, Dash, #[default]
67 Consistent, Sublist, }
70
71#[derive(Clone, Default)]
73pub struct MD004UnorderedListStyle {
74 config: MD004Config,
75}
76
77impl MD004UnorderedListStyle {
78 pub fn new(style: UnorderedListStyle) -> Self {
79 Self {
80 config: MD004Config { style },
81 }
82 }
83
84 fn count_marker_prevalence(&self, ctx: &crate::lint_context::LintContext) -> char {
88 let mut asterisk_count = 0;
89 let mut dash_count = 0;
90 let mut plus_count = 0;
91
92 for list_block in ctx.parsed_list_blocks() {
93 for list_item in list_block.items() {
94 if !list_item.is_ordered()
95 && !is_inside_literal_code(ctx, list_item.marker_byte_offset())
96 && let Some(marker) = list_item.marker_char()
97 {
98 match marker {
102 '*' => asterisk_count += 1,
103 '-' => dash_count += 1,
104 '+' => plus_count += 1,
105 _ => {}
106 }
107 }
108 }
109 }
110
111 if dash_count >= asterisk_count && dash_count >= plus_count {
114 '-'
115 } else if asterisk_count >= plus_count {
116 '*'
117 } else {
118 '+'
119 }
120 }
121}
122
123impl Rule for MD004UnorderedListStyle {
124 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
125 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(4))
126 }
127
128 fn name(&self) -> &'static str {
129 "MD004"
130 }
131
132 fn description(&self) -> &'static str {
133 "Use consistent style for unordered list markers"
134 }
135
136 fn check(&self, ctx: &LintContext) -> LintResult {
137 if ctx.content.is_empty() {
139 return Ok(Vec::new());
140 }
141
142 if !ctx.likely_has_lists() {
144 return Ok(Vec::new());
145 }
146
147 let mut warnings = Vec::new();
148
149 let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
151 Some(self.count_marker_prevalence(ctx))
152 } else {
153 None
154 };
155
156 for list_block in ctx.parsed_list_blocks() {
158 for list_item in list_block.items() {
161 let line_info = list_item.line_info();
162 if line_info.in_pymdown_block || is_inside_literal_code(ctx, list_item.marker_byte_offset()) {
164 continue;
165 }
166
167 if list_item.is_ordered() {
169 continue;
170 }
171
172 let Some(marker) = list_item.marker_char() else {
176 continue;
177 };
178
179 let offset = list_item.marker_byte_offset();
181
182 match self.config.style {
183 UnorderedListStyle::Consistent => {
184 if let Some(target) = target_marker_for_consistent
186 && marker != target
187 {
188 let (line, col) = ctx.offset_to_line_col(offset);
189 warnings.push(LintWarning {
190 line,
191 column: col,
192 end_line: line,
193 end_column: col + 1,
194 message: format!("List marker '{marker}' does not match expected style '{target}'"),
195 severity: Severity::Warning,
196 rule_name: Some(self.name().to_string()),
197 fix: Some(Fix::new(offset..offset + 1, target.to_string())),
198 });
199 }
200 }
201 UnorderedListStyle::Sublist => {
202 let nesting_level = list_item.marker_column() / 2;
205 let expected_marker = match nesting_level % 3 {
206 0 => '*',
207 1 => '+',
208 2 => '-',
209 _ => {
210 '*'
213 }
214 };
215 if marker != expected_marker {
216 let (line, col) = ctx.offset_to_line_col(offset);
217 warnings.push(LintWarning {
218 line,
219 column: col,
220 end_line: line,
221 end_column: col + 1,
222 message: format!(
223 "List marker '{marker}' does not match expected style '{expected_marker}' for nesting level {nesting_level}"
224 ),
225 severity: Severity::Warning,
226 rule_name: Some(self.name().to_string()),
227 fix: Some(Fix::new(offset..offset + 1, expected_marker.to_string())),
228 });
229 }
230 }
231 _ => {
232 let target_marker = match self.config.style {
234 UnorderedListStyle::Asterisk => '*',
235 UnorderedListStyle::Dash => '-',
236 UnorderedListStyle::Plus => '+',
237 UnorderedListStyle::Consistent | UnorderedListStyle::Sublist => {
238 '*'
241 }
242 };
243 if marker != target_marker {
244 let (line, col) = ctx.offset_to_line_col(offset);
245 warnings.push(LintWarning {
246 line,
247 column: col,
248 end_line: line,
249 end_column: col + 1,
250 message: format!(
251 "List marker '{marker}' does not match expected style '{target_marker}'"
252 ),
253 severity: Severity::Warning,
254 rule_name: Some(self.name().to_string()),
255 fix: Some(Fix::new(offset..offset + 1, target_marker.to_string())),
256 });
257 }
258 }
259 }
260 }
261 }
262
263 Ok(warnings)
264 }
265
266 fn fixes_from_diagnostics(&self) -> bool {
267 true
268 }
269
270 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
271 if self.should_skip(ctx) {
272 return Ok(ctx.content.to_string());
273 }
274 let warnings = self.check(ctx)?;
275 if warnings.is_empty() {
276 return Ok(ctx.content.to_string());
277 }
278 let warnings =
279 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
280 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
281 .map_err(crate::rule::LintError::InvalidInput)
282 }
283
284 fn category(&self) -> RuleCategory {
286 RuleCategory::List
287 }
288
289 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
291 ctx.content.is_empty() || !ctx.likely_has_lists()
292 }
293
294 fn as_any(&self) -> &dyn std::any::Any {
295 self
296 }
297
298 crate::impl_rule_config_sections!(MD004Config);
299
300 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
301 where
302 Self: Sized,
303 {
304 let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
305 .unwrap_or_else(|| "consistent".to_string());
306 let style = match style.as_str() {
307 "asterisk" => UnorderedListStyle::Asterisk,
308 "dash" => UnorderedListStyle::Dash,
309 "plus" => UnorderedListStyle::Plus,
310 "sublist" => UnorderedListStyle::Sublist,
311 _ => UnorderedListStyle::Consistent,
312 };
313 Box::new(MD004UnorderedListStyle::new(style))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::lint_context::LintContext;
321 use crate::rule::Rule;
322
323 #[test]
324 fn test_consistent_asterisk_style() {
325 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
326 let content = "* Item 1\n* Item 2\n * Nested\n* Item 3";
327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
328 let result = rule.check(&ctx).unwrap();
329 assert!(result.is_empty());
330 }
331
332 #[test]
333 fn test_consistent_dash_style() {
334 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
335 let content = "- Item 1\n- Item 2\n - Nested\n- Item 3";
336 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
337 let result = rule.check(&ctx).unwrap();
338 assert!(result.is_empty());
339 }
340
341 #[test]
342 fn test_consistent_plus_style() {
343 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
344 let content = "+ Item 1\n+ Item 2\n + Nested\n+ Item 3";
345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
346 let result = rule.check(&ctx).unwrap();
347 assert!(result.is_empty());
348 }
349
350 #[test]
351 fn test_inconsistent_style_tie_prefers_dash() {
352 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
353 let content = "* Item 1\n- Item 2\n+ Item 3";
355 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
356 let result = rule.check(&ctx).unwrap();
357 assert_eq!(result.len(), 2);
358 assert_eq!(result[0].line, 1);
360 assert_eq!(result[1].line, 3);
361 }
362
363 #[test]
364 fn test_asterisk_style_enforced() {
365 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
366 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].message, "List marker '-' does not match expected style '*'");
371 assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
372 }
373
374 #[test]
375 fn test_dash_style_enforced() {
376 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
377 let content = "* Item 1\n- Item 2\n+ Item 3";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380 assert_eq!(result.len(), 2);
381 assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
382 assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
383 }
384
385 #[test]
386 fn test_plus_style_enforced() {
387 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
388 let content = "* Item 1\n- Item 2\n+ Item 3";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
390 let result = rule.check(&ctx).unwrap();
391 assert_eq!(result.len(), 2);
392 assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
393 assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
394 }
395
396 #[test]
397 fn test_fix_consistent_style_tie_prefers_dash() {
398 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
399 let content = "* Item 1\n- Item 2\n+ Item 3";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let fixed = rule.fix(&ctx).unwrap();
403 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
404 }
405
406 #[test]
407 fn test_fix_asterisk_style() {
408 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
409 let content = "- Item 1\n+ Item 2\n- Item 3";
410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411 let fixed = rule.fix(&ctx).unwrap();
412 assert_eq!(fixed, "* Item 1\n* Item 2\n* Item 3");
413 }
414
415 #[test]
416 fn test_fix_dash_style() {
417 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
418 let content = "* Item 1\n+ Item 2\n* Item 3";
419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
420 let fixed = rule.fix(&ctx).unwrap();
421 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
422 }
423
424 #[test]
425 fn test_fix_plus_style() {
426 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
427 let content = "* Item 1\n- Item 2\n* Item 3";
428 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
429 let fixed = rule.fix(&ctx).unwrap();
430 assert_eq!(fixed, "+ Item 1\n+ Item 2\n+ Item 3");
431 }
432
433 #[test]
434 fn test_nested_lists() {
435 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
436 let content = "* Item 1\n * Nested 1\n * Double nested\n - Wrong marker\n* Item 2";
437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert_eq!(result.len(), 1);
440 assert_eq!(result[0].line, 4);
441 }
442
443 #[test]
444 fn test_fix_nested_lists() {
445 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
446 let content = "* Item 1\n - Nested 1\n + Double nested\n - Nested 2\n* Item 2";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let fixed = rule.fix(&ctx).unwrap();
451 assert_eq!(
452 fixed,
453 "- Item 1\n - Nested 1\n - Double nested\n - Nested 2\n- Item 2"
454 );
455 }
456
457 #[test]
458 fn test_with_code_blocks() {
459 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
460 let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
461 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
462 let result = rule.check(&ctx).unwrap();
463 assert_eq!(result.len(), 1);
464 assert_eq!(result[0].line, 8);
465 }
466
467 #[test]
468 fn test_with_blockquotes() {
469 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
470 let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert!(result.len() >= 2);
475 }
476
477 #[test]
478 fn test_empty_document() {
479 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
480 let content = "";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert!(result.is_empty());
484 }
485
486 #[test]
487 fn test_no_lists() {
488 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
489 let content = "This is a paragraph.\n\nAnother paragraph.";
490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
491 let result = rule.check(&ctx).unwrap();
492 assert!(result.is_empty());
493 }
494
495 #[test]
496 fn test_ordered_lists_ignored() {
497 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
498 let content = "1. Item 1\n2. Item 2\n 1. Nested\n3. Item 3";
499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
500 let result = rule.check(&ctx).unwrap();
501 assert!(result.is_empty());
502 }
503
504 #[test]
505 fn test_mixed_ordered_unordered() {
506 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
507 let content = "1. Ordered\n * Unordered nested\n - Wrong marker\n2. Another ordered";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510 assert_eq!(result.len(), 1);
511 assert_eq!(result[0].line, 3);
512 }
513
514 #[test]
515 fn test_fix_preserves_content() {
516 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
517 let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
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 with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
523 );
524 }
525
526 #[test]
527 fn test_fix_preserves_indentation() {
528 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
529 let content = " - Indented item\n + Nested item\n - Another indented";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let fixed = rule.fix(&ctx).unwrap();
532 assert_eq!(fixed, " * Indented item\n * Nested item\n * Another indented");
533 }
534
535 #[test]
536 fn test_multiple_spaces_after_marker() {
537 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
538 let content = "* Item 1\n- Item 2\n+ Item 3";
540 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert_eq!(result.len(), 2);
543 let fixed = rule.fix(&ctx).unwrap();
544 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
545 }
546
547 #[test]
548 fn test_tab_after_marker() {
549 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
550 let content = "*\tItem 1\n-\tItem 2";
552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
553 let result = rule.check(&ctx).unwrap();
554 assert_eq!(result.len(), 1);
555 let fixed = rule.fix(&ctx).unwrap();
556 assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
557 }
558
559 #[test]
560 fn test_edge_case_marker_at_end() {
561 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
562 let content = "* \n- \n+ ";
564 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
568 assert_eq!(fixed, "* \n* \n* ");
569 }
570
571 #[test]
572 fn test_from_config() {
573 let mut config = crate::config::Config::default();
574 let mut rule_config = crate::config::RuleConfig::default();
575 rule_config
576 .values
577 .insert("style".to_string(), toml::Value::String("plus".to_string()));
578 config.rules.insert("MD004".to_string(), rule_config);
579
580 let rule = MD004UnorderedListStyle::from_config(&config);
581 let content = "* Item 1\n- Item 2";
582 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
583 let result = rule.check(&ctx).unwrap();
584 assert_eq!(result.len(), 2);
585 }
586
587 #[test]
588 fn test_default_config_section() {
589 let style = |rule: &MD004UnorderedListStyle| {
595 let (name, value) = rule.default_config_section().unwrap();
596 assert_eq!(name, "MD004");
597 let toml::Value::Table(table) = value else {
598 panic!("Expected table");
599 };
600 table.get("style").and_then(|v| v.as_str()).unwrap().to_string()
601 };
602
603 assert_eq!(style(&MD004UnorderedListStyle::default()), "consistent");
604 assert_eq!(
605 style(&MD004UnorderedListStyle::new(UnorderedListStyle::Dash)),
606 "consistent",
607 "a configured instance must still publish the default"
608 );
609 }
610
611 #[test]
612 fn test_sublist_style() {
613 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
614 let content = "* Item 1\n + Item 2\n - Item 3\n * Item 4\n + Item 5";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618 assert!(result.is_empty(), "Sublist style should accept cycling markers");
619 }
620
621 #[test]
622 fn test_sublist_style_incorrect() {
623 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
624 let content = "- Item 1\n * Item 2\n + Item 3";
626 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627 let result = rule.check(&ctx).unwrap();
628 assert_eq!(result.len(), 3);
629 assert_eq!(
630 result[0].message,
631 "List marker '-' does not match expected style '*' for nesting level 0"
632 );
633 assert_eq!(
634 result[1].message,
635 "List marker '*' does not match expected style '+' for nesting level 1"
636 );
637 assert_eq!(
638 result[2].message,
639 "List marker '+' does not match expected style '-' for nesting level 2"
640 );
641 }
642
643 #[test]
644 fn test_fix_sublist_style() {
645 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
646 let content = "- Item 1\n - Item 2\n - Item 3\n - Item 4";
647 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let fixed = rule.fix(&ctx).unwrap();
649 assert_eq!(fixed, "* Item 1\n + Item 2\n - Item 3\n * Item 4");
650 }
651
652 #[test]
653 fn test_performance_large_document() {
654 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
655 let mut content = String::new();
656 for i in 0..1000 {
657 content.push_str(&format!(
658 "{}Item {}\n",
659 if i % 3 == 0 {
660 "* "
661 } else if i % 3 == 1 {
662 "- "
663 } else {
664 "+ "
665 },
666 i
667 ));
668 }
669 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert!(result.len() > 600);
673 }
674
675 #[test]
676 fn test_md004_front_matter() {
677 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
678 let content = "---\n* key: value\n---\n- Item 1\n- Item 2\n";
681 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
682 let result = rule.check(&ctx).unwrap();
683 assert!(
684 result.is_empty(),
685 "Should not flag list-like items in front-matter: {result:?}"
686 );
687 }
688}