1use crate::lint_context::LintContext;
2use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
3
4#[derive(Debug, Clone, PartialEq, Eq, Default)]
23pub enum ListItemSpacingStyle {
24 #[default]
25 Consistent,
26 Loose,
27 Tight,
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct MD076Config {
32 pub style: ListItemSpacingStyle,
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct MD076ListItemSpacing {
37 config: MD076Config,
38}
39
40struct BlockAnalysis {
42 items: Vec<usize>,
44 gaps: Vec<bool>,
46 warn_loose_gaps: bool,
48 warn_tight_gaps: bool,
50}
51
52impl MD076ListItemSpacing {
53 pub fn new(style: ListItemSpacingStyle) -> Self {
54 Self {
55 config: MD076Config { style },
56 }
57 }
58
59 fn is_effectively_blank(ctx: &LintContext, line_num: usize) -> bool {
64 if let Some(info) = ctx.line_info(line_num) {
65 let content = info.content(ctx.content);
66 if content.trim().is_empty() {
67 return true;
68 }
69 if let Some(ref bq) = info.blockquote {
71 return bq.content.trim().is_empty();
72 }
73 false
74 } else {
75 false
76 }
77 }
78
79 fn gap_is_loose(ctx: &LintContext, first: usize, next: usize) -> bool {
86 if next <= first + 1 {
87 return false;
88 }
89 Self::is_effectively_blank(ctx, next - 1)
93 }
94
95 fn inter_item_blanks(ctx: &LintContext, first: usize, next: usize) -> Vec<usize> {
101 let mut blanks = Vec::new();
102 let mut line_num = next - 1;
103 while line_num > first && Self::is_effectively_blank(ctx, line_num) {
104 blanks.push(line_num);
105 line_num -= 1;
106 }
107 blanks.reverse();
108 blanks
109 }
110
111 fn analyze_block(
116 ctx: &LintContext,
117 block: &crate::lint_context::types::ListBlock,
118 style: &ListItemSpacingStyle,
119 ) -> Option<BlockAnalysis> {
120 let items: Vec<usize> = block
124 .item_lines
125 .iter()
126 .copied()
127 .filter(|&line_num| {
128 ctx.line_info(line_num)
129 .and_then(|li| li.list_item.as_ref())
130 .map(|item| item.marker_column / 2 == block.nesting_level)
131 .unwrap_or(false)
132 })
133 .collect();
134
135 if items.len() < 2 {
136 return None;
137 }
138
139 let gaps: Vec<bool> = items.windows(2).map(|w| Self::gap_is_loose(ctx, w[0], w[1])).collect();
141
142 let loose_count = gaps.iter().filter(|&&g| g).count();
143 let tight_count = gaps.len() - loose_count;
144
145 let (warn_loose_gaps, warn_tight_gaps) = match style {
146 ListItemSpacingStyle::Loose => (false, true),
147 ListItemSpacingStyle::Tight => (true, false),
148 ListItemSpacingStyle::Consistent => {
149 if loose_count == 0 || tight_count == 0 {
150 return None; }
152 if loose_count >= tight_count {
154 (false, true)
155 } else {
156 (true, false)
157 }
158 }
159 };
160
161 Some(BlockAnalysis {
162 items,
163 gaps,
164 warn_loose_gaps,
165 warn_tight_gaps,
166 })
167 }
168}
169
170impl Rule for MD076ListItemSpacing {
171 fn name(&self) -> &'static str {
172 "MD076"
173 }
174
175 fn description(&self) -> &'static str {
176 "List item spacing should be consistent"
177 }
178
179 fn check(&self, ctx: &LintContext) -> LintResult {
180 if ctx.content.is_empty() {
181 return Ok(Vec::new());
182 }
183
184 let mut warnings = Vec::new();
185
186 for block in &ctx.list_blocks {
187 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
188 continue;
189 };
190
191 for (i, &is_loose) in analysis.gaps.iter().enumerate() {
192 if is_loose && analysis.warn_loose_gaps {
193 let blanks = Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]);
195 if let Some(&blank_line) = blanks.first() {
196 let line_content = ctx
197 .line_info(blank_line)
198 .map(|li| li.content(ctx.content))
199 .unwrap_or("");
200 warnings.push(LintWarning {
201 rule_name: Some(self.name().to_string()),
202 line: blank_line,
203 column: 1,
204 end_line: blank_line,
205 end_column: line_content.len() + 1,
206 message: "Unexpected blank line between list items".to_string(),
207 severity: Severity::Warning,
208 fix: None,
209 });
210 }
211 } else if !is_loose && analysis.warn_tight_gaps {
212 let next_item = analysis.items[i + 1];
214 let line_content = ctx.line_info(next_item).map(|li| li.content(ctx.content)).unwrap_or("");
215 warnings.push(LintWarning {
216 rule_name: Some(self.name().to_string()),
217 line: next_item,
218 column: 1,
219 end_line: next_item,
220 end_column: line_content.len() + 1,
221 message: "Missing blank line between list items".to_string(),
222 severity: Severity::Warning,
223 fix: None,
224 });
225 }
226 }
227 }
228
229 Ok(warnings)
230 }
231
232 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
233 if ctx.content.is_empty() {
234 return Ok(ctx.content.to_string());
235 }
236
237 let mut insert_before: std::collections::HashSet<usize> = std::collections::HashSet::new();
239 let mut remove_lines: std::collections::HashSet<usize> = std::collections::HashSet::new();
240
241 for block in &ctx.list_blocks {
242 let Some(analysis) = Self::analyze_block(ctx, block, &self.config.style) else {
243 continue;
244 };
245
246 for (i, &is_loose) in analysis.gaps.iter().enumerate() {
247 if is_loose && analysis.warn_loose_gaps {
248 for blank_line in Self::inter_item_blanks(ctx, analysis.items[i], analysis.items[i + 1]) {
250 remove_lines.insert(blank_line);
251 }
252 } else if !is_loose && analysis.warn_tight_gaps {
253 insert_before.insert(analysis.items[i + 1]);
254 }
255 }
256 }
257
258 if insert_before.is_empty() && remove_lines.is_empty() {
259 return Ok(ctx.content.to_string());
260 }
261
262 let lines = ctx.raw_lines();
263 let mut result: Vec<String> = Vec::with_capacity(lines.len());
264
265 for (i, line) in lines.iter().enumerate() {
266 let line_num = i + 1;
267
268 if remove_lines.contains(&line_num) {
269 continue;
270 }
271
272 if insert_before.contains(&line_num) {
273 let bq_prefix = ctx.blockquote_prefix_for_blank_line(i);
274 result.push(bq_prefix);
275 }
276
277 result.push((*line).to_string());
278 }
279
280 let mut output = result.join("\n");
281 if ctx.content.ends_with('\n') {
282 output.push('\n');
283 }
284 Ok(output)
285 }
286
287 fn as_any(&self) -> &dyn std::any::Any {
288 self
289 }
290
291 fn default_config_section(&self) -> Option<(String, toml::Value)> {
292 let mut map = toml::map::Map::new();
293 let style_str = match self.config.style {
294 ListItemSpacingStyle::Consistent => "consistent",
295 ListItemSpacingStyle::Loose => "loose",
296 ListItemSpacingStyle::Tight => "tight",
297 };
298 map.insert("style".to_string(), toml::Value::String(style_str.to_string()));
299 Some((self.name().to_string(), toml::Value::Table(map)))
300 }
301
302 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
303 where
304 Self: Sized,
305 {
306 let style = crate::config::get_rule_config_value::<String>(config, "MD076", "style")
307 .unwrap_or_else(|| "consistent".to_string());
308 let style = match style.as_str() {
309 "loose" => ListItemSpacingStyle::Loose,
310 "tight" => ListItemSpacingStyle::Tight,
311 _ => ListItemSpacingStyle::Consistent,
312 };
313 Box::new(Self::new(style))
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320
321 fn check(content: &str, style: ListItemSpacingStyle) -> Vec<LintWarning> {
322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
323 let rule = MD076ListItemSpacing::new(style);
324 rule.check(&ctx).unwrap()
325 }
326
327 fn fix(content: &str, style: ListItemSpacingStyle) -> String {
328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
329 let rule = MD076ListItemSpacing::new(style);
330 rule.fix(&ctx).unwrap()
331 }
332
333 #[test]
336 fn tight_list_tight_style_no_warnings() {
337 let content = "- Item 1\n- Item 2\n- Item 3\n";
338 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
339 }
340
341 #[test]
342 fn loose_list_loose_style_no_warnings() {
343 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
344 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
345 }
346
347 #[test]
348 fn tight_list_loose_style_warns() {
349 let content = "- Item 1\n- Item 2\n- Item 3\n";
350 let warnings = check(content, ListItemSpacingStyle::Loose);
351 assert_eq!(warnings.len(), 2);
352 assert!(warnings.iter().all(|w| w.message.contains("Missing")));
353 }
354
355 #[test]
356 fn loose_list_tight_style_warns() {
357 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
358 let warnings = check(content, ListItemSpacingStyle::Tight);
359 assert_eq!(warnings.len(), 2);
360 assert!(warnings.iter().all(|w| w.message.contains("Unexpected")));
361 }
362
363 #[test]
366 fn consistent_all_tight_no_warnings() {
367 let content = "- Item 1\n- Item 2\n- Item 3\n";
368 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
369 }
370
371 #[test]
372 fn consistent_all_loose_no_warnings() {
373 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
374 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
375 }
376
377 #[test]
378 fn consistent_mixed_majority_loose_warns_tight() {
379 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
381 let warnings = check(content, ListItemSpacingStyle::Consistent);
382 assert_eq!(warnings.len(), 1);
383 assert!(warnings[0].message.contains("Missing"));
384 }
385
386 #[test]
387 fn consistent_mixed_majority_tight_warns_loose() {
388 let content = "- Item 1\n\n- Item 2\n- Item 3\n- Item 4\n";
390 let warnings = check(content, ListItemSpacingStyle::Consistent);
391 assert_eq!(warnings.len(), 1);
392 assert!(warnings[0].message.contains("Unexpected"));
393 }
394
395 #[test]
396 fn consistent_tie_prefers_loose() {
397 let content = "- Item 1\n\n- Item 2\n- Item 3\n";
398 let warnings = check(content, ListItemSpacingStyle::Consistent);
399 assert_eq!(warnings.len(), 1);
400 assert!(warnings[0].message.contains("Missing"));
401 }
402
403 #[test]
406 fn single_item_list_no_warnings() {
407 let content = "- Only item\n";
408 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
409 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
410 assert!(check(content, ListItemSpacingStyle::Consistent).is_empty());
411 }
412
413 #[test]
414 fn empty_content_no_warnings() {
415 assert!(check("", ListItemSpacingStyle::Consistent).is_empty());
416 }
417
418 #[test]
419 fn ordered_list_tight_gaps_loose_style_warns() {
420 let content = "1. First\n2. Second\n3. Third\n";
421 let warnings = check(content, ListItemSpacingStyle::Loose);
422 assert_eq!(warnings.len(), 2);
423 }
424
425 #[test]
426 fn task_list_works() {
427 let content = "- [x] Task 1\n- [ ] Task 2\n- [x] Task 3\n";
428 let warnings = check(content, ListItemSpacingStyle::Loose);
429 assert_eq!(warnings.len(), 2);
430 let fixed = fix(content, ListItemSpacingStyle::Loose);
431 assert_eq!(fixed, "- [x] Task 1\n\n- [ ] Task 2\n\n- [x] Task 3\n");
432 }
433
434 #[test]
435 fn no_trailing_newline() {
436 let content = "- Item 1\n- Item 2";
437 let warnings = check(content, ListItemSpacingStyle::Loose);
438 assert_eq!(warnings.len(), 1);
439 let fixed = fix(content, ListItemSpacingStyle::Loose);
440 assert_eq!(fixed, "- Item 1\n\n- Item 2");
441 }
442
443 #[test]
444 fn two_separate_lists() {
445 let content = "- A\n- B\n\nText\n\n1. One\n2. Two\n";
446 let warnings = check(content, ListItemSpacingStyle::Loose);
447 assert_eq!(warnings.len(), 2);
448 let fixed = fix(content, ListItemSpacingStyle::Loose);
449 assert_eq!(fixed, "- A\n\n- B\n\nText\n\n1. One\n\n2. Two\n");
450 }
451
452 #[test]
453 fn no_list_content() {
454 let content = "Just a paragraph.\n\nAnother paragraph.\n";
455 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
456 assert!(check(content, ListItemSpacingStyle::Tight).is_empty());
457 }
458
459 #[test]
462 fn continuation_lines_tight_detected() {
463 let content = "- Item 1\n continuation\n- Item 2\n";
464 let warnings = check(content, ListItemSpacingStyle::Loose);
465 assert_eq!(warnings.len(), 1);
466 assert!(warnings[0].message.contains("Missing"));
467 }
468
469 #[test]
470 fn continuation_lines_loose_detected() {
471 let content = "- Item 1\n continuation\n\n- Item 2\n";
472 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
473 let warnings = check(content, ListItemSpacingStyle::Tight);
474 assert_eq!(warnings.len(), 1);
475 assert!(warnings[0].message.contains("Unexpected"));
476 }
477
478 #[test]
479 fn multi_paragraph_item_not_treated_as_inter_item_gap() {
480 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
483 let warnings = check(content, ListItemSpacingStyle::Tight);
485 assert_eq!(
486 warnings.len(),
487 1,
488 "Should warn only on the inter-item blank, not the intra-item blank"
489 );
490 let fixed = fix(content, ListItemSpacingStyle::Tight);
493 assert_eq!(fixed, "- Item 1\n\n Second paragraph\n- Item 2\n");
494 }
495
496 #[test]
497 fn multi_paragraph_item_loose_style_no_warnings() {
498 let content = "- Item 1\n\n Second paragraph\n\n- Item 2\n";
500 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
501 }
502
503 #[test]
506 fn blockquote_tight_list_loose_style_warns() {
507 let content = "> - Item 1\n> - Item 2\n> - Item 3\n";
508 let warnings = check(content, ListItemSpacingStyle::Loose);
509 assert_eq!(warnings.len(), 2);
510 }
511
512 #[test]
513 fn blockquote_loose_list_detected() {
514 let content = "> - Item 1\n>\n> - Item 2\n";
516 let warnings = check(content, ListItemSpacingStyle::Tight);
517 assert_eq!(warnings.len(), 1, "Blockquote-only line should be detected as blank");
518 assert!(warnings[0].message.contains("Unexpected"));
519 }
520
521 #[test]
522 fn blockquote_loose_list_no_warnings_when_loose() {
523 let content = "> - Item 1\n>\n> - Item 2\n";
524 assert!(check(content, ListItemSpacingStyle::Loose).is_empty());
525 }
526
527 #[test]
530 fn multiple_blanks_all_removed() {
531 let content = "- Item 1\n\n\n- Item 2\n";
532 let fixed = fix(content, ListItemSpacingStyle::Tight);
533 assert_eq!(fixed, "- Item 1\n- Item 2\n");
534 }
535
536 #[test]
537 fn multiple_blanks_fix_is_idempotent() {
538 let content = "- Item 1\n\n\n\n- Item 2\n";
539 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
540 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
541 assert_eq!(fixed_once, fixed_twice);
542 assert_eq!(fixed_once, "- Item 1\n- Item 2\n");
543 }
544
545 #[test]
548 fn fix_adds_blank_lines() {
549 let content = "- Item 1\n- Item 2\n- Item 3\n";
550 let fixed = fix(content, ListItemSpacingStyle::Loose);
551 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n");
552 }
553
554 #[test]
555 fn fix_removes_blank_lines() {
556 let content = "- Item 1\n\n- Item 2\n\n- Item 3\n";
557 let fixed = fix(content, ListItemSpacingStyle::Tight);
558 assert_eq!(fixed, "- Item 1\n- Item 2\n- Item 3\n");
559 }
560
561 #[test]
562 fn fix_consistent_adds_blank() {
563 let content = "- Item 1\n\n- Item 2\n- Item 3\n\n- Item 4\n";
565 let fixed = fix(content, ListItemSpacingStyle::Consistent);
566 assert_eq!(fixed, "- Item 1\n\n- Item 2\n\n- Item 3\n\n- Item 4\n");
567 }
568
569 #[test]
570 fn fix_idempotent_loose() {
571 let content = "- Item 1\n- Item 2\n";
572 let fixed_once = fix(content, ListItemSpacingStyle::Loose);
573 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Loose);
574 assert_eq!(fixed_once, fixed_twice);
575 }
576
577 #[test]
578 fn fix_idempotent_tight() {
579 let content = "- Item 1\n\n- Item 2\n";
580 let fixed_once = fix(content, ListItemSpacingStyle::Tight);
581 let fixed_twice = fix(&fixed_once, ListItemSpacingStyle::Tight);
582 assert_eq!(fixed_once, fixed_twice);
583 }
584
585 #[test]
588 fn nested_list_does_not_affect_parent() {
589 let content = "- Item 1\n - Nested A\n - Nested B\n- Item 2\n";
591 let warnings = check(content, ListItemSpacingStyle::Tight);
592 assert!(
593 warnings.is_empty(),
594 "Nested items should not cause parent-level warnings"
595 );
596 }
597
598 #[test]
601 fn default_config_section_provides_style_key() {
602 let rule = MD076ListItemSpacing::new(ListItemSpacingStyle::Consistent);
603 let section = rule.default_config_section();
604 assert!(section.is_some());
605 let (name, value) = section.unwrap();
606 assert_eq!(name, "MD076");
607 if let toml::Value::Table(map) = value {
608 assert!(map.contains_key("style"));
609 } else {
610 panic!("Expected Table value from default_config_section");
611 }
612 }
613}