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