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::new(offset..offset + 1, target.to_string())),
187 });
188 }
189 }
190 UnorderedListStyle::Sublist => {
191 let nesting_level = list_item.marker_column / 2;
194 let expected_marker = match nesting_level % 3 {
195 0 => '*',
196 1 => '+',
197 2 => '-',
198 _ => {
199 '*'
202 }
203 };
204 if marker != expected_marker {
205 let (line, col) = ctx.offset_to_line_col(offset);
206 warnings.push(LintWarning {
207 line,
208 column: col,
209 end_line: line,
210 end_column: col + 1,
211 message: format!(
212 "List marker '{marker}' does not match expected style '{expected_marker}' for nesting level {nesting_level}"
213 ),
214 severity: Severity::Warning,
215 rule_name: Some(self.name().to_string()),
216 fix: Some(Fix::new(offset..offset + 1, expected_marker.to_string())),
217 });
218 }
219 }
220 _ => {
221 let target_marker = match self.config.style {
223 UnorderedListStyle::Asterisk => '*',
224 UnorderedListStyle::Dash => '-',
225 UnorderedListStyle::Plus => '+',
226 UnorderedListStyle::Consistent | UnorderedListStyle::Sublist => {
227 '*'
230 }
231 };
232 if marker != target_marker {
233 let (line, col) = ctx.offset_to_line_col(offset);
234 warnings.push(LintWarning {
235 line,
236 column: col,
237 end_line: line,
238 end_column: col + 1,
239 message: format!(
240 "List marker '{marker}' does not match expected style '{target_marker}'"
241 ),
242 severity: Severity::Warning,
243 rule_name: Some(self.name().to_string()),
244 fix: Some(Fix::new(offset..offset + 1, target_marker.to_string())),
245 });
246 }
247 }
248 }
249 }
250 }
251 }
252
253 Ok(warnings)
254 }
255
256 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
257 if self.should_skip(ctx) {
258 return Ok(ctx.content.to_string());
259 }
260 let warnings = self.check(ctx)?;
261 if warnings.is_empty() {
262 return Ok(ctx.content.to_string());
263 }
264 let warnings =
265 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
266 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
267 .map_err(crate::rule::LintError::InvalidInput)
268 }
269
270 fn category(&self) -> RuleCategory {
272 RuleCategory::List
273 }
274
275 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
277 ctx.content.is_empty() || !ctx.likely_has_lists()
278 }
279
280 fn as_any(&self) -> &dyn std::any::Any {
281 self
282 }
283
284 fn default_config_section(&self) -> Option<(String, toml::Value)> {
285 let mut map = toml::map::Map::new();
286 map.insert(
287 "style".to_string(),
288 toml::Value::String(match self.config.style {
289 UnorderedListStyle::Asterisk => "asterisk".to_string(),
290 UnorderedListStyle::Dash => "dash".to_string(),
291 UnorderedListStyle::Plus => "plus".to_string(),
292 UnorderedListStyle::Consistent => "consistent".to_string(),
293 UnorderedListStyle::Sublist => "sublist".to_string(),
294 }),
295 );
296 Some((self.name().to_string(), toml::Value::Table(map)))
297 }
298
299 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
300 where
301 Self: Sized,
302 {
303 let style = crate::config::get_rule_config_value::<String>(config, "MD004", "style")
304 .unwrap_or_else(|| "consistent".to_string());
305 let style = match style.as_str() {
306 "asterisk" => UnorderedListStyle::Asterisk,
307 "dash" => UnorderedListStyle::Dash,
308 "plus" => UnorderedListStyle::Plus,
309 "sublist" => UnorderedListStyle::Sublist,
310 _ => UnorderedListStyle::Consistent,
311 };
312 Box::new(MD004UnorderedListStyle::new(style))
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::lint_context::LintContext;
320 use crate::rule::Rule;
321
322 #[test]
323 fn test_consistent_asterisk_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_dash_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_plus_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_inconsistent_style_tie_prefers_dash() {
351 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
352 let content = "* Item 1\n- Item 2\n+ Item 3";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355 let result = rule.check(&ctx).unwrap();
356 assert_eq!(result.len(), 2);
357 assert_eq!(result[0].line, 1);
359 assert_eq!(result[1].line, 3);
360 }
361
362 #[test]
363 fn test_asterisk_style_enforced() {
364 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
365 let content = "* Item 1\n- Item 2\n+ Item 3";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(result.len(), 2);
369 assert_eq!(result[0].message, "List marker '-' does not match expected style '*'");
370 assert_eq!(result[1].message, "List marker '+' does not match expected style '*'");
371 }
372
373 #[test]
374 fn test_dash_style_enforced() {
375 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
376 let content = "* Item 1\n- Item 2\n+ Item 3";
377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
378 let result = rule.check(&ctx).unwrap();
379 assert_eq!(result.len(), 2);
380 assert_eq!(result[0].message, "List marker '*' does not match expected style '-'");
381 assert_eq!(result[1].message, "List marker '+' does not match expected style '-'");
382 }
383
384 #[test]
385 fn test_plus_style_enforced() {
386 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
387 let content = "* Item 1\n- Item 2\n+ Item 3";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390 assert_eq!(result.len(), 2);
391 assert_eq!(result[0].message, "List marker '*' does not match expected style '+'");
392 assert_eq!(result[1].message, "List marker '-' does not match expected style '+'");
393 }
394
395 #[test]
396 fn test_fix_consistent_style_tie_prefers_dash() {
397 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
398 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_asterisk_style() {
407 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
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_dash_style() {
416 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
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_plus_style() {
425 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Plus);
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_nested_lists() {
434 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
435 let content = "* Item 1\n * Nested 1\n * Double nested\n - Wrong marker\n* Item 2";
436 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert_eq!(result.len(), 1);
439 assert_eq!(result[0].line, 4);
440 }
441
442 #[test]
443 fn test_fix_nested_lists() {
444 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
445 let content = "* Item 1\n - Nested 1\n + Double nested\n - Nested 2\n* Item 2";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let fixed = rule.fix(&ctx).unwrap();
450 assert_eq!(
451 fixed,
452 "- Item 1\n - Nested 1\n - Double nested\n - Nested 2\n- Item 2"
453 );
454 }
455
456 #[test]
457 fn test_with_code_blocks() {
458 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
459 let content = "* Item 1\n\n```\n- This is in code\n+ Not a list\n```\n\n- Item 2";
460 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
461 let result = rule.check(&ctx).unwrap();
462 assert_eq!(result.len(), 1);
463 assert_eq!(result[0].line, 8);
464 }
465
466 #[test]
467 fn test_with_blockquotes() {
468 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
469 let content = "> * Item 1\n> - Item 2\n\n* Regular item\n+ Different marker";
470 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
471 let result = rule.check(&ctx).unwrap();
472 assert!(result.len() >= 2);
474 }
475
476 #[test]
477 fn test_empty_document() {
478 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
479 let content = "";
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_no_lists() {
487 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
488 let content = "This is a paragraph.\n\nAnother paragraph.";
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_ordered_lists_ignored() {
496 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
497 let content = "1. Item 1\n2. Item 2\n 1. Nested\n3. Item 3";
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_mixed_ordered_unordered() {
505 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
506 let content = "1. Ordered\n * Unordered nested\n - Wrong marker\n2. Another ordered";
507 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert_eq!(result.len(), 1);
510 assert_eq!(result[0].line, 3);
511 }
512
513 #[test]
514 fn test_fix_preserves_content() {
515 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
516 let content = "* Item with **bold** and *italic*\n+ Item with `code`\n* Item with [link](url)";
517 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let fixed = rule.fix(&ctx).unwrap();
519 assert_eq!(
520 fixed,
521 "- Item with **bold** and *italic*\n- Item with `code`\n- Item with [link](url)"
522 );
523 }
524
525 #[test]
526 fn test_fix_preserves_indentation() {
527 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
528 let content = " - Indented item\n + Nested item\n - Another indented";
529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let fixed = rule.fix(&ctx).unwrap();
531 assert_eq!(fixed, " * Indented item\n * Nested item\n * Another indented");
532 }
533
534 #[test]
535 fn test_multiple_spaces_after_marker() {
536 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
537 let content = "* Item 1\n- Item 2\n+ Item 3";
539 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
540 let result = rule.check(&ctx).unwrap();
541 assert_eq!(result.len(), 2);
542 let fixed = rule.fix(&ctx).unwrap();
543 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3");
544 }
545
546 #[test]
547 fn test_tab_after_marker() {
548 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Consistent);
549 let content = "*\tItem 1\n-\tItem 2";
551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert_eq!(result.len(), 1);
554 let fixed = rule.fix(&ctx).unwrap();
555 assert_eq!(fixed, "-\tItem 1\n-\tItem 2");
556 }
557
558 #[test]
559 fn test_edge_case_marker_at_end() {
560 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
561 let content = "* \n- \n+ ";
563 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert_eq!(result.len(), 2); let fixed = rule.fix(&ctx).unwrap();
567 assert_eq!(fixed, "* \n* \n* ");
568 }
569
570 #[test]
571 fn test_from_config() {
572 let mut config = crate::config::Config::default();
573 let mut rule_config = crate::config::RuleConfig::default();
574 rule_config
575 .values
576 .insert("style".to_string(), toml::Value::String("plus".to_string()));
577 config.rules.insert("MD004".to_string(), rule_config);
578
579 let rule = MD004UnorderedListStyle::from_config(&config);
580 let content = "* Item 1\n- Item 2";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(result.len(), 2);
584 }
585
586 #[test]
587 fn test_default_config_section() {
588 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Dash);
589 let config = rule.default_config_section();
590 assert!(config.is_some());
591 let (name, value) = config.unwrap();
592 assert_eq!(name, "MD004");
593 if let toml::Value::Table(table) = value {
594 assert_eq!(table.get("style").and_then(|v| v.as_str()), Some("dash"));
595 } else {
596 panic!("Expected table");
597 }
598 }
599
600 #[test]
601 fn test_sublist_style() {
602 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
603 let content = "* Item 1\n + Item 2\n - Item 3\n * Item 4\n + Item 5";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607 assert!(result.is_empty(), "Sublist style should accept cycling markers");
608 }
609
610 #[test]
611 fn test_sublist_style_incorrect() {
612 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
613 let content = "- Item 1\n * Item 2\n + Item 3";
615 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert_eq!(result.len(), 3);
618 assert_eq!(
619 result[0].message,
620 "List marker '-' does not match expected style '*' for nesting level 0"
621 );
622 assert_eq!(
623 result[1].message,
624 "List marker '*' does not match expected style '+' for nesting level 1"
625 );
626 assert_eq!(
627 result[2].message,
628 "List marker '+' does not match expected style '-' for nesting level 2"
629 );
630 }
631
632 #[test]
633 fn test_fix_sublist_style() {
634 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Sublist);
635 let content = "- Item 1\n - Item 2\n - Item 3\n - Item 4";
636 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637 let fixed = rule.fix(&ctx).unwrap();
638 assert_eq!(fixed, "* Item 1\n + Item 2\n - Item 3\n * Item 4");
639 }
640
641 #[test]
642 fn test_performance_large_document() {
643 let rule = MD004UnorderedListStyle::new(UnorderedListStyle::Asterisk);
644 let mut content = String::new();
645 for i in 0..1000 {
646 content.push_str(&format!(
647 "{}Item {}\n",
648 if i % 3 == 0 {
649 "* "
650 } else if i % 3 == 1 {
651 "- "
652 } else {
653 "+ "
654 },
655 i
656 ));
657 }
658 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660 assert!(result.len() > 600);
662 }
663}