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