1use crate::lint_context::LintContext;
18use crate::rule::{Fix, LintWarning};
19use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Continuation {
24 Belongs,
26 Skip,
28 Ends,
30}
31
32#[derive(Debug, Clone, Copy)]
37pub struct OwnerFrame {
38 pub marker_column: usize,
39 pub bq_level: usize,
40 pub min_indent: usize,
41 pub own: isize,
42 pub warning: Option<usize>,
43}
44
45impl OwnerFrame {
46 pub fn for_item(ctx: &LintContext, line_num: usize, own: isize, warning: Option<usize>) -> Option<Self> {
49 let (marker_column, bq_level, min_indent) = continuation_params(ctx, line_num)?;
50 Some(Self {
51 marker_column,
52 bq_level,
53 min_indent,
54 own,
55 warning,
56 })
57 }
58}
59
60pub fn continuation_params(ctx: &LintContext, line_num: usize) -> Option<(usize, usize, usize)> {
66 let info = ctx.line_info(line_num)?;
67 let list = info.list_item.as_ref()?;
68 let (bq_level, min_indent) = match &info.blockquote {
69 Some(bq) if bq.nesting_level > 0 => (bq.nesting_level, list.content_column.saturating_sub(bq.prefix.len())),
70 _ => (0, list.content_column),
71 };
72 Some((list.marker_column, bq_level, min_indent))
73}
74
75pub fn classify_continuation(
79 ctx: &LintContext,
80 next_line_num: usize,
81 lines: &[&str],
82 marker_column: usize,
83 bq_level: usize,
84 min_indent: usize,
85) -> Continuation {
86 let Some(info) = ctx.line_info(next_line_num) else {
87 return Continuation::Skip;
88 };
89 if let Some(next_list) = &info.list_item {
92 return if next_list.marker_column <= marker_column {
93 Continuation::Ends
94 } else {
95 Continuation::Belongs
96 };
97 }
98 let content = lines.get(next_line_num - 1).copied().unwrap_or("");
99 let blank_in_blockquote = bq_level > 0
102 && info
103 .blockquote
104 .as_ref()
105 .is_some_and(|bq| bq.nesting_level == bq_level && bq.content.trim().is_empty());
106 if content.trim().is_empty() || blank_in_blockquote {
107 return Continuation::Skip;
108 }
109 let raw_indent = content.len() - content.trim_start().len();
110 if effective_indent_in_blockquote(content, bq_level, raw_indent) < min_indent {
111 Continuation::Ends
112 } else {
113 Continuation::Belongs
114 }
115}
116
117pub fn close_ended_items(ctx: &LintContext, line_num: usize, lines: &[&str], stack: &mut Vec<OwnerFrame>) -> bool {
122 let mut innermost = true;
123 while let Some(frame) = stack.last() {
124 let continuation = classify_continuation(
125 ctx,
126 line_num,
127 lines,
128 frame.marker_column,
129 frame.bq_level,
130 frame.min_indent,
131 );
132 if continuation != Continuation::Ends {
133 break;
134 }
135 if innermost && is_lazy_continuation(ctx, line_num - 1) {
136 return true;
137 }
138 innermost = false;
139 stack.pop();
140 }
141 false
142}
143
144#[derive(Debug, Clone, Copy)]
146pub enum EndedItems {
147 StayClosed,
149 ShiftOut(OwnerFrame),
152 Unfixable,
155}
156
157fn content_column_shifts(stack: &[OwnerFrame], nesting: Nesting) -> Vec<isize> {
162 let mut total = 0;
163 stack
164 .iter()
165 .map(|frame| match nesting {
166 Nesting::Absolute => frame.own,
167 Nesting::Relative => {
168 total += frame.own;
169 total
170 }
171 })
172 .collect()
173}
174
175pub fn keep_ended_items_closed(
185 ctx: &LintContext,
186 line_idx: usize,
187 before: &[OwnerFrame],
188 kept: usize,
189 nesting: Nesting,
190) -> EndedItems {
191 let Some(info) = ctx.lines.get(line_idx) else {
192 return EndedItems::StayClosed;
193 };
194 if kept >= before.len() || is_marker_line(ctx, line_idx) {
195 return EndedItems::StayClosed;
196 }
197 let lines = ctx.raw_lines();
198 let Some(&content) = lines.get(line_idx) else {
199 return EndedItems::StayClosed;
200 };
201 let bq_level = info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
202 let shifts = content_column_shifts(before, nesting);
203 let raw_indent = content.len() - content.trim_start().len();
204 let line_shift = kept.checked_sub(1).map_or(0, |owner| shifts[owner]);
205 let indent = effective_indent_in_blockquote(content, bq_level, raw_indent) as isize + line_shift;
206
207 let Some((captor, limit)) = (kept..before.len())
210 .filter(|&i| before[i].bq_level == bq_level)
211 .map(|i| (i, before[i].min_indent as isize + shifts[i]))
212 .min_by_key(|&(_, column)| column)
213 else {
214 return EndedItems::StayClosed;
215 };
216 if indent < limit {
217 return EndedItems::StayClosed;
218 }
219 if info.in_code_block || info.in_math_block {
220 return EndedItems::Unfixable;
221 }
222 let floor = kept
223 .checked_sub(1)
224 .map_or(0, |owner| before[owner].min_indent as isize + shifts[owner]);
225 let Some(warning) = before[..=captor].iter().rev().find_map(|frame| frame.warning) else {
226 return EndedItems::Unfixable;
227 };
228 if floor >= limit {
229 return EndedItems::Unfixable;
230 }
231 EndedItems::ShiftOut(OwnerFrame {
232 marker_column: 0,
233 bq_level,
234 min_indent: 0,
235 own: floor - indent,
236 warning: Some(warning),
237 })
238}
239
240fn write_offset(bq_level: usize, line: &str) -> usize {
244 match bq_level {
245 0 => 0,
246 _ => parse_blockquote_prefix(line).map_or(0, |p| p.prefix.len()),
247 }
248}
249
250pub fn move_owned_line(
255 ctx: &LintContext,
256 line: &str,
257 line_idx: usize,
258 owners: &[OwnerFrame],
259 warnings: &mut [LintWarning],
260) {
261 if owners.iter().all(|frame| frame.own == 0) {
262 return;
263 }
264 let line_start = ctx.line_offsets.get(line_idx).copied().unwrap_or(0);
265 let offsets: Vec<usize> = owners.iter().map(|f| write_offset(f.bq_level, line)).collect();
266 let mut done: Vec<usize> = Vec::new();
267 for &offset in &offsets {
268 if done.contains(&offset) {
269 continue;
270 }
271 done.push(offset);
272 let contributions: Vec<isize> = owners
273 .iter()
274 .zip(&offsets)
275 .map(|(frame, &o)| if o == offset { frame.own } else { 0 })
276 .collect();
277 let edits = indent_shift_edits(line_start, line, offset, &contributions);
278 for (frame, edit) in owners.iter().zip(edits) {
279 if let (Some(edit), Some(index)) = (edit, frame.warning)
280 && let Some(fix) = warnings.get_mut(index).and_then(|w| w.fix.as_mut())
281 {
282 fix.additional_edits.push(edit);
283 }
284 }
285 }
286}
287
288pub fn indent_shift_edits(line_start: usize, line: &str, offset: usize, contributions: &[isize]) -> Vec<Option<Fix>> {
302 let Some(rest) = line.get(offset..) else {
303 return vec![None; contributions.len()];
304 };
305 let available = rest.len() - rest.trim_start_matches(' ').len();
306 let at = line_start + offset;
307 let mut consumed = 0;
308 let mut edits: Vec<Option<Fix>> = contributions
309 .iter()
310 .map(|&delta| {
311 if delta >= 0 {
312 return None;
313 }
314 let take = delta.unsigned_abs().min(available - consumed);
315 if take == 0 {
316 return None;
317 }
318 let start = at + consumed;
319 consumed += take;
320 Some(Fix::new(start..start + take, String::new()))
321 })
322 .collect();
323 let mut inserted = 0;
324 for (edit, &delta) in edits.iter_mut().zip(contributions) {
325 if delta > 0 {
326 let position = at + available.saturating_sub(inserted).max(consumed);
327 inserted += 1;
328 *edit = Some(Fix::new(position..position, " ".repeat(delta as usize)));
329 }
330 }
331 edits
332}
333
334pub fn is_lazy_continuation(ctx: &LintContext, line_idx: usize) -> bool {
339 let Some(prev_idx) = line_idx.checked_sub(1) else {
340 return false;
341 };
342 let (Some(prev), Some(cur)) = (ctx.lines.get(prev_idx), ctx.lines.get(line_idx)) else {
343 return false;
344 };
345 let bq_depth = |info: &crate::lint_context::LineInfo| info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
346 let opens_block = |info: &crate::lint_context::LineInfo| {
347 info.list_item.is_some()
348 || info.in_code_block
349 || info.heading.is_some()
350 || info.is_horizontal_rule
351 || info.in_html_block
352 || info.in_html_comment
353 || info.is_div_marker
354 };
355 let prev_is_paragraph = !ctx.line_holds_html_block(prev_idx)
361 && match &prev.list_item {
362 Some(item) => {
363 !prev.in_code_block
364 && prev
365 .content(ctx.content)
366 .get(item.content_column..)
367 .map(str::trim)
368 .is_some_and(|text| !text.is_empty() && !matches!(text, "[ ]" | "[x]" | "[X]"))
369 }
370 None => !opens_block(prev),
371 };
372 !prev.is_blank && !cur.is_blank && bq_depth(prev) == bq_depth(cur) && !opens_block(cur) && prev_is_paragraph
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum Nesting {
379 Relative,
382 Absolute,
386}
387
388fn is_marker_line(ctx: &LintContext, line_idx: usize) -> bool {
392 let Some(info) = ctx.lines.get(line_idx) else {
393 return false;
394 };
395 let Some(item) = &info.list_item else {
396 return false;
397 };
398 !info.in_code_block
399 || info
400 .content(ctx.content)
401 .get(item.content_column..)
402 .is_some_and(|rest| rest.starts_with("```") || rest.starts_with("~~~"))
403}
404
405pub fn move_owned_lines(
411 ctx: &LintContext,
412 moves: &std::collections::HashMap<usize, (usize, isize)>,
413 nesting: Nesting,
414 warnings: &mut [LintWarning],
415) {
416 if moves.values().all(|&(_, delta)| delta == 0) {
417 return;
418 }
419 let lines = ctx.raw_lines();
420 let mut stack: Vec<OwnerFrame> = Vec::new();
421 let mut list_start = 0;
425 let mut list_unfixable = false;
426 for (line_idx, line) in lines.iter().enumerate() {
427 let before = stack.clone();
428 let lazy = close_ended_items(ctx, line_idx + 1, lines, &mut stack);
429 if lazy || line.trim().is_empty() {
430 continue;
431 }
432 let marker = is_marker_line(ctx, line_idx);
433 if marker && stack.is_empty() {
434 if std::mem::take(&mut list_unfixable) {
435 decline_fixes(list_start..line_idx, moves, warnings);
436 }
437 list_start = line_idx;
438 }
439 let mut owners = match (marker, nesting) {
440 (true, Nesting::Absolute) => Vec::new(),
441 (false, Nesting::Absolute) => stack[stack.len().saturating_sub(1)..].to_vec(),
442 (_, Nesting::Relative) => stack.clone(),
443 };
444 match keep_ended_items_closed(ctx, line_idx, &before, stack.len(), nesting) {
445 EndedItems::StayClosed => {}
446 EndedItems::ShiftOut(frame) => owners.push(frame),
447 EndedItems::Unfixable => list_unfixable = true,
448 }
449 move_owned_line(ctx, line, line_idx, &owners, warnings);
450 if marker {
451 let (warning, own) = moves.get(&line_idx).map_or((None, 0), |&(w, d)| (Some(w), d));
452 stack.extend(OwnerFrame::for_item(ctx, line_idx + 1, own, warning));
453 }
454 }
455 if list_unfixable {
456 decline_fixes(list_start..lines.len(), moves, warnings);
457 }
458}
459
460fn decline_fixes(
465 list: std::ops::Range<usize>,
466 moves: &std::collections::HashMap<usize, (usize, isize)>,
467 warnings: &mut [LintWarning],
468) {
469 for (line_idx, &(index, _)) in moves {
470 if list.contains(line_idx)
471 && let Some(warning) = warnings.get_mut(index)
472 {
473 warning.fix = None;
474 }
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 fn apply(line: &str, edits: &[Option<Fix>]) -> String {
483 let mut edits: Vec<&Fix> = edits.iter().flatten().collect();
484 edits.sort_by_key(|f| std::cmp::Reverse((f.range.start, f.range.end)));
485 let mut out = line.to_string();
486 for fix in edits {
487 out.replace_range(fix.range.clone(), &fix.replacement);
488 }
489 out
490 }
491
492 #[test]
493 fn narrowing_contributions_delete_disjoint_slices() {
494 let line = " text";
495 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
496 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
497 assert_eq!(ranges, vec![0..2, 2..4]);
498 assert_eq!(apply(line, &edits), " text");
499 }
500
501 #[test]
502 fn each_narrowing_contribution_is_correct_alone() {
503 let line = " text";
504 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
505 assert_eq!(apply(line, &edits[..1]), " text");
506 assert_eq!(apply(line, &edits[1..]), " text");
507 }
508
509 #[test]
510 fn widening_and_narrowing_compose() {
511 let line = " text";
512 let edits = indent_shift_edits(0, line, 0, &[3, -2]);
513 assert_eq!(apply(line, &edits), " text");
514 }
515
516 #[test]
517 fn deletion_never_exceeds_the_leading_spaces() {
518 let edits = indent_shift_edits(0, " text", 0, &[-2, -2]);
519 assert!(edits[0].is_some());
520 assert!(edits[1].is_none());
521 }
522
523 #[test]
524 fn deletion_stops_at_a_tab() {
525 let edits = indent_shift_edits(0, " \ttext", 0, &[-2]);
526 assert_eq!(edits[0].as_ref().unwrap().range, 0..1);
527 }
528
529 #[test]
530 fn edits_start_after_the_offset() {
531 let line = "> text";
532 let edits = indent_shift_edits(10, line, 2, &[-2]);
533 assert_eq!(edits[0].as_ref().unwrap().range, 12..14);
534 let edits = indent_shift_edits(10, line, 2, &[1]);
535 assert_eq!(edits[0].as_ref().unwrap().range, 16..16);
536 }
537
538 #[test]
539 fn equal_widening_contributions_survive_applying_every_fix_together() {
540 let content = "1. a\n 1. b\n z\n";
543 let line_start = content.find(" z").unwrap();
544 let edits = indent_shift_edits(line_start, " z", 0, &[1, 1]);
545 let warnings: Vec<LintWarning> = edits
546 .into_iter()
547 .map(|fix| LintWarning {
548 message: String::new(),
549 line: 3,
550 column: 1,
551 end_line: 3,
552 end_column: 1,
553 severity: crate::rule::Severity::Warning,
554 fix,
555 rule_name: None,
556 })
557 .collect();
558 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
559 assert_eq!(fixed, "1. a\n 1. b\n z\n");
560 for warning in &warnings {
561 let alone = crate::utils::fix_utils::apply_warning_fixes(content, std::slice::from_ref(warning)).unwrap();
562 assert_eq!(alone, "1. a\n 1. b\n z\n");
563 }
564 }
565
566 #[test]
567 fn inserts_never_land_inside_a_deletion() {
568 let edits = indent_shift_edits(0, " text", 0, &[-2, 1]);
569 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
570 assert_eq!(ranges, vec![0..2, 3..3]);
571 assert_eq!(apply(" text", &edits), " text");
572 }
573
574 #[test]
575 fn lazy_continuation_is_paragraph_text_right_after_the_item() {
576 let content = "* item\nlazy\n\nnot lazy\n";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 assert!(is_lazy_continuation(&ctx, 1));
579 assert!(!is_lazy_continuation(&ctx, 3));
580 }
581
582 #[test]
583 fn a_line_after_an_item_html_block_is_not_a_lazy_continuation() {
584 let content = "- <div>\n b\n}\n- a\n b\n}\n";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 assert!(!is_lazy_continuation(&ctx, 2));
587 assert!(is_lazy_continuation(&ctx, 5));
588 }
589
590 #[test]
591 fn a_line_after_a_lone_task_checkbox_is_not_a_lazy_continuation() {
592 let content = "- [ ]\nfoo\n- [x] \nfoo\n- [ ] a\nfoo\n- [-]\nfoo\n";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 assert!(!is_lazy_continuation(&ctx, 1));
595 assert!(!is_lazy_continuation(&ctx, 3));
596 assert!(is_lazy_continuation(&ctx, 5));
597 assert!(is_lazy_continuation(&ctx, 7));
598 }
599
600 #[test]
601 fn a_block_start_is_not_a_lazy_continuation() {
602 let content = "* item\n# heading\n* next\n";
603 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
604 assert!(!is_lazy_continuation(&ctx, 1));
605 assert!(!is_lazy_continuation(&ctx, 2));
606 }
607}