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 is_lazy_continuation_source(&ctx.native_list_source(), line_idx)
340}
341
342pub(crate) fn is_lazy_continuation_source(
344 ctx: &crate::lint_context::NativeListSource<'_, '_>,
345 line_idx: usize,
346) -> bool {
347 let Some(prev_idx) = line_idx.checked_sub(1) else {
348 return false;
349 };
350 let (Some(prev), Some(cur)) = (ctx.lines.get(prev_idx), ctx.lines.get(line_idx)) else {
351 return false;
352 };
353 let bq_depth = |info: &crate::lint_context::LineInfo| info.blockquote.as_ref().map_or(0, |bq| bq.nesting_level);
354 let opens_block = |info: &crate::lint_context::LineInfo| {
355 info.list_item.is_some()
356 || info.in_code_block
357 || info.heading.is_some()
358 || info.is_horizontal_rule
359 || info.in_html_block
360 || info.in_html_comment
361 || info.is_div_marker
362 };
363 let prev_is_paragraph = !ctx.line_holds_html_block(prev_idx)
369 && match &prev.list_item {
370 Some(item) => {
371 !prev.in_code_block
372 && prev
373 .content(ctx.content)
374 .get(item.content_column..)
375 .map(str::trim)
376 .is_some_and(|text| !text.is_empty() && !matches!(text, "[ ]" | "[x]" | "[X]"))
377 }
378 None => !opens_block(prev),
379 };
380 !prev.is_blank && !cur.is_blank && bq_depth(prev) == bq_depth(cur) && !opens_block(cur) && prev_is_paragraph
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub enum Nesting {
387 Relative,
390 Absolute,
394}
395
396fn is_marker_line(ctx: &LintContext, line_idx: usize) -> bool {
400 let Some(info) = ctx.lines.get(line_idx) else {
401 return false;
402 };
403 let Some(item) = &info.list_item else {
404 return false;
405 };
406 !info.in_code_block
407 || info
408 .content(ctx.content)
409 .get(item.content_column..)
410 .is_some_and(|rest| rest.starts_with("```") || rest.starts_with("~~~"))
411}
412
413pub fn move_owned_lines(
419 ctx: &LintContext,
420 moves: &std::collections::HashMap<usize, (usize, isize)>,
421 nesting: Nesting,
422 warnings: &mut [LintWarning],
423) {
424 if moves.values().all(|&(_, delta)| delta == 0) {
425 return;
426 }
427 let lines = ctx.raw_lines();
428 let mut stack: Vec<OwnerFrame> = Vec::new();
429 let mut list_start = 0;
433 let mut list_unfixable = false;
434 for (line_idx, line) in lines.iter().enumerate() {
435 let before = stack.clone();
436 let lazy = close_ended_items(ctx, line_idx + 1, lines, &mut stack);
437 if lazy || line.trim().is_empty() {
438 continue;
439 }
440 let marker = is_marker_line(ctx, line_idx);
441 if marker && stack.is_empty() {
442 if std::mem::take(&mut list_unfixable) {
443 decline_fixes(list_start..line_idx, moves, warnings);
444 }
445 list_start = line_idx;
446 }
447 let mut owners = match (marker, nesting) {
448 (true, Nesting::Absolute) => Vec::new(),
449 (false, Nesting::Absolute) => stack[stack.len().saturating_sub(1)..].to_vec(),
450 (_, Nesting::Relative) => stack.clone(),
451 };
452 match keep_ended_items_closed(ctx, line_idx, &before, stack.len(), nesting) {
453 EndedItems::StayClosed => {}
454 EndedItems::ShiftOut(frame) => owners.push(frame),
455 EndedItems::Unfixable => list_unfixable = true,
456 }
457 move_owned_line(ctx, line, line_idx, &owners, warnings);
458 if marker {
459 let (warning, own) = moves.get(&line_idx).map_or((None, 0), |&(w, d)| (Some(w), d));
460 stack.extend(OwnerFrame::for_item(ctx, line_idx + 1, own, warning));
461 }
462 }
463 if list_unfixable {
464 decline_fixes(list_start..lines.len(), moves, warnings);
465 }
466}
467
468fn decline_fixes(
473 list: std::ops::Range<usize>,
474 moves: &std::collections::HashMap<usize, (usize, isize)>,
475 warnings: &mut [LintWarning],
476) {
477 for (line_idx, &(index, _)) in moves {
478 if list.contains(line_idx)
479 && let Some(warning) = warnings.get_mut(index)
480 {
481 warning.fix = None;
482 }
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn apply(line: &str, edits: &[Option<Fix>]) -> String {
491 let mut edits: Vec<&Fix> = edits.iter().flatten().collect();
492 edits.sort_by_key(|f| std::cmp::Reverse((f.range.start, f.range.end)));
493 let mut out = line.to_string();
494 for fix in edits {
495 out.replace_range(fix.range.clone(), &fix.replacement);
496 }
497 out
498 }
499
500 #[test]
501 fn narrowing_contributions_delete_disjoint_slices() {
502 let line = " text";
503 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
504 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
505 assert_eq!(ranges, vec![0..2, 2..4]);
506 assert_eq!(apply(line, &edits), " text");
507 }
508
509 #[test]
510 fn each_narrowing_contribution_is_correct_alone() {
511 let line = " text";
512 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
513 assert_eq!(apply(line, &edits[..1]), " text");
514 assert_eq!(apply(line, &edits[1..]), " text");
515 }
516
517 #[test]
518 fn widening_and_narrowing_compose() {
519 let line = " text";
520 let edits = indent_shift_edits(0, line, 0, &[3, -2]);
521 assert_eq!(apply(line, &edits), " text");
522 }
523
524 #[test]
525 fn deletion_never_exceeds_the_leading_spaces() {
526 let edits = indent_shift_edits(0, " text", 0, &[-2, -2]);
527 assert!(edits[0].is_some());
528 assert!(edits[1].is_none());
529 }
530
531 #[test]
532 fn deletion_stops_at_a_tab() {
533 let edits = indent_shift_edits(0, " \ttext", 0, &[-2]);
534 assert_eq!(edits[0].as_ref().unwrap().range, 0..1);
535 }
536
537 #[test]
538 fn edits_start_after_the_offset() {
539 let line = "> text";
540 let edits = indent_shift_edits(10, line, 2, &[-2]);
541 assert_eq!(edits[0].as_ref().unwrap().range, 12..14);
542 let edits = indent_shift_edits(10, line, 2, &[1]);
543 assert_eq!(edits[0].as_ref().unwrap().range, 16..16);
544 }
545
546 #[test]
547 fn equal_widening_contributions_survive_applying_every_fix_together() {
548 let content = "1. a\n 1. b\n z\n";
551 let line_start = content.find(" z").unwrap();
552 let edits = indent_shift_edits(line_start, " z", 0, &[1, 1]);
553 let warnings: Vec<LintWarning> = edits
554 .into_iter()
555 .map(|fix| LintWarning {
556 message: String::new(),
557 line: 3,
558 column: 1,
559 end_line: 3,
560 end_column: 1,
561 severity: crate::rule::Severity::Warning,
562 fix,
563 rule_name: None,
564 })
565 .collect();
566 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
567 assert_eq!(fixed, "1. a\n 1. b\n z\n");
568 for warning in &warnings {
569 let alone = crate::utils::fix_utils::apply_warning_fixes(content, std::slice::from_ref(warning)).unwrap();
570 assert_eq!(alone, "1. a\n 1. b\n z\n");
571 }
572 }
573
574 #[test]
575 fn inserts_never_land_inside_a_deletion() {
576 let edits = indent_shift_edits(0, " text", 0, &[-2, 1]);
577 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
578 assert_eq!(ranges, vec![0..2, 3..3]);
579 assert_eq!(apply(" text", &edits), " text");
580 }
581
582 #[test]
583 fn lazy_continuation_is_paragraph_text_right_after_the_item() {
584 let content = "* item\nlazy\n\nnot lazy\n";
585 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 assert!(is_lazy_continuation(&ctx, 1));
587 assert!(!is_lazy_continuation(&ctx, 3));
588 }
589
590 #[test]
591 fn a_line_after_an_item_html_block_is_not_a_lazy_continuation() {
592 let content = "- <div>\n b\n}\n- a\n b\n}\n";
593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 assert!(!is_lazy_continuation(&ctx, 2));
595 assert!(is_lazy_continuation(&ctx, 5));
596 }
597
598 #[test]
599 fn a_line_after_a_lone_task_checkbox_is_not_a_lazy_continuation() {
600 let content = "- [ ]\nfoo\n- [x] \nfoo\n- [ ] a\nfoo\n- [-]\nfoo\n";
601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
602 assert!(!is_lazy_continuation(&ctx, 1));
603 assert!(!is_lazy_continuation(&ctx, 3));
604 assert!(is_lazy_continuation(&ctx, 5));
605 assert!(is_lazy_continuation(&ctx, 7));
606 }
607
608 #[test]
609 fn a_block_start_is_not_a_lazy_continuation() {
610 let content = "* item\n# heading\n* next\n";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 assert!(!is_lazy_continuation(&ctx, 1));
613 assert!(!is_lazy_continuation(&ctx, 2));
614 }
615}