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