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