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) -> Option<char> {
86 let mut asterisk_count = 0;
87 let mut dash_count = 0;
88 let mut plus_count = 0;
89
90 for list_block in &ctx.list_blocks {
91 for &item_line in &list_block.item_lines {
92 if let Some(line_info) = ctx.line_info(item_line)
93 && let Some(list_item) = &line_info.list_item
94 && !list_item.is_ordered
95 {
96 match list_item.marker.chars().next()? {
97 '*' => asterisk_count += 1,
98 '-' => dash_count += 1,
99 '+' => plus_count += 1,
100 _ => {}
101 }
102 }
103 }
104 }
105
106 if dash_count >= asterisk_count && dash_count >= plus_count {
109 Some('-')
110 } else if asterisk_count >= plus_count {
111 Some('*')
112 } else {
113 Some('+')
114 }
115 }
116}
117
118impl Rule for MD004UnorderedListStyle {
119 fn name(&self) -> &'static str {
120 "MD004"
121 }
122
123 fn description(&self) -> &'static str {
124 "Use consistent style for unordered list markers"
125 }
126
127 fn check(&self, ctx: &LintContext) -> LintResult {
128 if ctx.content.is_empty() {
130 return Ok(Vec::new());
131 }
132
133 if !ctx.likely_has_lists() {
135 return Ok(Vec::new());
136 }
137
138 let mut warnings = Vec::new();
139
140 let target_marker_for_consistent = if self.config.style == UnorderedListStyle::Consistent {
142 self.count_marker_prevalence(ctx)
143 } else {
144 None
145 };
146
147 for list_block in &ctx.list_blocks {
149 for &item_line in &list_block.item_lines {
152 if let Some(line_info) = ctx.line_info(item_line)
153 && let Some(list_item) = &line_info.list_item
154 {
155 if line_info.in_pymdown_block {
157 continue;
158 }
159
160 if list_item.is_ordered {
162 continue;
163 }
164
165 let marker = list_item.marker.chars().next().unwrap();
167
168 let offset = line_info.byte_offset + list_item.marker_column;
170
171 match self.config.style {
172 UnorderedListStyle::Consistent => {
173 if let Some(target) = target_marker_for_consistent
175 && marker != target
176 {
177 let (line, col) = ctx.offset_to_line_col(offset);
178 warnings.push(LintWarning {
179 line,
180 column: col,
181 end_line: line,
182 end_column: col + 1,
183 message: format!("List marker '{marker}' does not match expected style '{target}'"),
184 severity: Severity::Warning,
185 rule_name: Some(self.name().to_string()),
186 fix: Some(Fix {
187 range: offset..offset + 1,
188 replacement: target.to_string(),
189 }),
190 });
191 }
192 }
193 UnorderedListStyle::Sublist => {
194 let nesting_level = list_item.marker_column / 2;
197 let expected_marker = match nesting_level % 3 {
198 0 => '*',
199 1 => '+',
200 2 => '-',
201 _ => {
202 '*'
205 }
206 };
207 if marker != expected_marker {
208 let (line, col) = ctx.offset_to_line_col(offset);
209 warnings.push(LintWarning {
210 line,
211 column: col,
212 end_line: line,
213 end_column: col + 1,
214 message: format!(
215 "List marker '{marker}' does not match expected style '{expected_marker}' for nesting level {nesting_level}"
216 ),
217 severity: Severity::Warning,
218 rule_name: Some(self.name().to_string()),
219 fix: Some(Fix {
220 range: offset..offset + 1,
221 replacement: expected_marker.to_string(),
222 }),
223 });
224 }
225 }
226 _ => {
227 let target_marker = match self.config.style {
229 UnorderedListStyle::Asterisk => '*',
230 UnorderedListStyle::Dash => '-',
231 UnorderedListStyle::Plus => '+',
232 UnorderedListStyle::Consistent | UnorderedListStyle::Sublist => {
233 '*'
236 }
237 };
238 if marker != target_marker {
239 let (line, col) = ctx.offset_to_line_col(offset);
240 warnings.push(LintWarning {
241 line,
242 column: col,
243 end_line: line,
244 end_column: col + 1,
245 message: format!(
246 "List marker '{marker}' does not match expected style '{target_marker}'"
247 ),
248 severity: Severity::Warning,
249 rule_name: Some(self.name().to_string()),
250 fix: Some(Fix {
251 range: offset..offset + 1,
252 replacement: target_marker.to_string(),
253 }),
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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
294 let mut map = toml::map::Map::new();
295 map.insert(
296 "style".to_string(),
297 toml::Value::String(match self.config.style {
298 UnorderedListStyle::Asterisk => "asterisk".to_string(),
299 UnorderedListStyle::Dash => "dash".to_string(),
300 UnorderedListStyle::Plus => "plus".to_string(),
301 UnorderedListStyle::Consistent => "consistent".to_string(),
302 UnorderedListStyle::Sublist => "sublist".to_string(),
303 }),
304 );
305 Some((self.name().to_string(), toml::Value::Table(map)))
306 }
307
308 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
309 where
310 Self: Sized,
311 {
312 let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
313 .unwrap_or_else(|| "consistent".to_string());
314 let style = match style.as_str() {
315 "asterisk" => UnorderedListStyle::Asterisk,
316 "dash" => UnorderedListStyle::Dash,
317 "plus" => UnorderedListStyle::Plus,
318 "sublist" => UnorderedListStyle::Sublist,
319 _ => UnorderedListStyle::Consistent,
320 };
321 Box::new(MD004UnorderedListStyle::new(style))
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::lint_context::LintContext;
329 use crate::rule::Rule;
330
331 #[test]
332 fn test_consistent_asterisk_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_consistent_dash_style() {
342 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
343 let content = "- Item 1\n- Item 2\n - Nested\n- Item 3";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346 assert!(result.is_empty());
347 }
348
349 #[test]
350 fn test_consistent_plus_style() {
351 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
352 let content = "+ Item 1\n+ Item 2\n + Nested\n+ Item 3";
353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
354 let result = rule.check(&ctx).unwrap();
355 assert!(result.is_empty());
356 }
357
358 #[test]
359 fn test_inconsistent_style_tie_prefers_dash() {
360 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
361 let content = "* Item 1\n- Item 2\n+ Item 3";
363 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
364 let result = rule.check(&ctx).unwrap();
365 assert_eq!(result.len(), 2);
366 assert_eq!(result[0].line, 1);
368 assert_eq!(result[1].line, 3);
369 }
370
371 #[test]
372 fn test_asterisk_style_enforced() {
373 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
374 let content = "* Item 1\n- Item 2\n+ Item 3";
375 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
376 let result = rule.check(&ctx).unwrap();
377 assert_eq!(result.len(), 2);
378 assert_eq!(result[0].message, "List marker '-' does not match expected style '*'");
379 assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
380 }
381
382 #[test]
383 fn test_dash_style_enforced() {
384 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
385 let content = "* Item 1\n- Item 2\n+ Item 3";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert_eq!(result.len(), 2);
389 assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
390 assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
391 }
392
393 #[test]
394 fn test_plus_style_enforced() {
395 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
396 let content = "* Item 1\n- Item 2\n+ Item 3";
397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert_eq!(result.len(), 2);
400 assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
401 assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
402 }
403
404 #[test]
405 fn test_fix_consistent_style_tie_prefers_dash() {
406 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
407 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_asterisk_style() {
416 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
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_fix_dash_style() {
425 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
426 let content = "* Item 1\n+ Item 2\n* Item 3";
427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
428 let fixed = rule.fix(&ctx).unwrap();
429 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
430 }
431
432 #[test]
433 fn test_fix_plus_style() {
434 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
435 let content = "* Item 1\n- Item 2\n* Item 3";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let fixed = rule.fix(&ctx).unwrap();
438 assert_eq!(fixed, "+ Item 1\n+ Item 2\n+ Item 3");
439 }
440
441 #[test]
442 fn test_nested_lists() {
443 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
444 let content = "* Item 1\n * Nested 1\n * Double nested\n - Wrong marker\n* Item 2";
445 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 1);
448 assert_eq!(result[0].line, 4);
449 }
450
451 #[test]
452 fn test_fix_nested_lists() {
453 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
454 let content = "* Item 1\n - Nested 1\n + Double nested\n - Nested 2\n* Item 2";
457 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
458 let fixed = rule.fix(&ctx).unwrap();
459 assert_eq!(
460 fixed,
461 "- Item 1\n - Nested 1\n - Double nested\n - Nested 2\n- Item 2"
462 );
463 }
464
465 #[test]
466 fn test_with_code_blocks() {
467 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
468 let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470 let result = rule.check(&ctx).unwrap();
471 assert_eq!(result.len(), 1);
472 assert_eq!(result[0].line, 8);
473 }
474
475 #[test]
476 fn test_with_blockquotes() {
477 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
478 let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.check(&ctx).unwrap();
481 assert!(result.len() >= 2);
483 }
484
485 #[test]
486 fn test_empty_document() {
487 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
488 let content = "";
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_no_lists() {
496 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
497 let content = "This is a paragraph.\n\nAnother paragraph.";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(result.is_empty());
501 }
502
503 #[test]
504 fn test_ordered_lists_ignored() {
505 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
506 let content = "1. Item 1\n2. Item 2\n 1. Nested\n3. Item 3";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert!(result.is_empty());
510 }
511
512 #[test]
513 fn test_mixed_ordered_unordered() {
514 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
515 let content = "1. Ordered\n * Unordered nested\n - Wrong marker\n2. Another ordered";
516 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
517 let result = rule.check(&ctx).unwrap();
518 assert_eq!(result.len(), 1);
519 assert_eq!(result[0].line, 3);
520 }
521
522 #[test]
523 fn test_fix_preserves_content() {
524 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
525 let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let fixed = rule.fix(&ctx).unwrap();
528 assert_eq!(
529 fixed,
530 "- Item with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
531 );
532 }
533
534 #[test]
535 fn test_fix_preserves_indentation() {
536 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
537 let content = " - Indented item\n + Nested item\n - Another indented";
538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
539 let fixed = rule.fix(&ctx).unwrap();
540 assert_eq!(fixed, " * Indented item\n * Nested item\n * Another indented");
541 }
542
543 #[test]
544 fn test_multiple_spaces_after_marker() {
545 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
546 let content = "* Item 1\n- Item 2\n+ Item 3";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549 let result = rule.check(&ctx).unwrap();
550 assert_eq!(result.len(), 2);
551 let fixed = rule.fix(&ctx).unwrap();
552 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
553 }
554
555 #[test]
556 fn test_tab_after_marker() {
557 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
558 let content = "*\tItem 1\n-\tItem 2";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562 assert_eq!(result.len(), 1);
563 let fixed = rule.fix(&ctx).unwrap();
564 assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
565 }
566
567 #[test]
568 fn test_edge_case_marker_at_end() {
569 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
570 let content = "* \n- \n+ ";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
576 assert_eq!(fixed, "* \n* \n* ");
577 }
578
579 #[test]
580 fn test_from_config() {
581 let mut config = crate::config::Config::default();
582 let mut rule_config = crate::config::RuleConfig::default();
583 rule_config
584 .values
585 .insert("style".to_string(), toml::Value::String("plus".to_string()));
586 config.rules.insert("MD004".to_string(), rule_config);
587
588 let rule = MD004UnorderedListStyle::from_config(&config);
589 let content = "* Item 1\n- Item 2";
590 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
591 let result = rule.check(&ctx).unwrap();
592 assert_eq!(result.len(), 2);
593 }
594
595 #[test]
596 fn test_default_config_section() {
597 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
598 let config = rule.default_config_section();
599 assert!(config.is_some());
600 let (name, value) = config.unwrap();
601 assert_eq!(name, "MD004");
602 if let toml::Value::Table(table) = value {
603 assert_eq!(table.get("style").and_then(|v| v.as_str()), Some("dash"));
604 } else {
605 panic!("Expected table");
606 }
607 }
608
609 #[test]
610 fn test_sublist_style() {
611 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
612 let content = "* Item 1\n + Item 2\n - Item 3\n * Item 4\n + Item 5";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616 assert!(result.is_empty(), "Sublist style should accept cycling markers");
617 }
618
619 #[test]
620 fn test_sublist_style_incorrect() {
621 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
622 let content = "- Item 1\n * Item 2\n + Item 3";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert_eq!(result.len(), 3);
627 assert_eq!(
628 result[0].message,
629 "List marker '-' does not match expected style '*' for nesting level 0"
630 );
631 assert_eq!(
632 result[1].message,
633 "List marker '*' does not match expected style '+' for nesting level 1"
634 );
635 assert_eq!(
636 result[2].message,
637 "List marker '+' does not match expected style '-' for nesting level 2"
638 );
639 }
640
641 #[test]
642 fn test_fix_sublist_style() {
643 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
644 let content = "- Item 1\n - Item 2\n - Item 3\n - Item 4";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let fixed = rule.fix(&ctx).unwrap();
647 assert_eq!(fixed, "* Item 1\n + Item 2\n - Item 3\n * Item 4");
648 }
649
650 #[test]
651 fn test_performance_large_document() {
652 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
653 let mut content = String::new();
654 for i in 0..1000 {
655 content.push_str(&format!(
656 "{}Item {}\n",
657 if i % 3 == 0 {
658 "* "
659 } else if i % 3 == 1 {
660 "- "
661 } else {
662 "+ "
663 },
664 i
665 ));
666 }
667 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert!(result.len() > 600);
671 }
672}