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