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 = match &prev.list_item {
358 Some(item) => {
359 !prev.in_code_block
360 && prev
361 .content(ctx.content)
362 .get(item.content_column..)
363 .is_some_and(|text| !text.trim().is_empty())
364 }
365 None => !opens_block(prev),
366 };
367 !prev.is_blank && !cur.is_blank && bq_depth(prev) == bq_depth(cur) && !opens_block(cur) && prev_is_paragraph
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum Nesting {
374 Relative,
377 Absolute,
381}
382
383fn is_marker_line(ctx: &LintContext, line_idx: usize) -> bool {
387 let Some(info) = ctx.lines.get(line_idx) else {
388 return false;
389 };
390 let Some(item) = &info.list_item else {
391 return false;
392 };
393 !info.in_code_block
394 || info
395 .content(ctx.content)
396 .get(item.content_column..)
397 .is_some_and(|rest| rest.starts_with("```") || rest.starts_with("~~~"))
398}
399
400pub fn move_owned_lines(
406 ctx: &LintContext,
407 moves: &std::collections::HashMap<usize, (usize, isize)>,
408 nesting: Nesting,
409 warnings: &mut [LintWarning],
410) {
411 if moves.values().all(|&(_, delta)| delta == 0) {
412 return;
413 }
414 let lines = ctx.raw_lines();
415 let mut stack: Vec<OwnerFrame> = Vec::new();
416 let mut list_start = 0;
420 let mut list_unfixable = false;
421 for (line_idx, line) in lines.iter().enumerate() {
422 let before = stack.clone();
423 let lazy = close_ended_items(ctx, line_idx + 1, lines, &mut stack);
424 if lazy || line.trim().is_empty() {
425 continue;
426 }
427 let marker = is_marker_line(ctx, line_idx);
428 if marker && stack.is_empty() {
429 if std::mem::take(&mut list_unfixable) {
430 decline_fixes(list_start..line_idx, moves, warnings);
431 }
432 list_start = line_idx;
433 }
434 let mut owners = match (marker, nesting) {
435 (true, Nesting::Absolute) => Vec::new(),
436 (false, Nesting::Absolute) => stack[stack.len().saturating_sub(1)..].to_vec(),
437 (_, Nesting::Relative) => stack.clone(),
438 };
439 match keep_ended_items_closed(ctx, line_idx, &before, stack.len(), nesting) {
440 EndedItems::StayClosed => {}
441 EndedItems::ShiftOut(frame) => owners.push(frame),
442 EndedItems::Unfixable => list_unfixable = true,
443 }
444 move_owned_line(ctx, line, line_idx, &owners, warnings);
445 if marker {
446 let (warning, own) = moves.get(&line_idx).map_or((None, 0), |&(w, d)| (Some(w), d));
447 stack.extend(OwnerFrame::for_item(ctx, line_idx + 1, own, warning));
448 }
449 }
450 if list_unfixable {
451 decline_fixes(list_start..lines.len(), moves, warnings);
452 }
453}
454
455fn decline_fixes(
460 list: std::ops::Range<usize>,
461 moves: &std::collections::HashMap<usize, (usize, isize)>,
462 warnings: &mut [LintWarning],
463) {
464 for (line_idx, &(index, _)) in moves {
465 if list.contains(line_idx)
466 && let Some(warning) = warnings.get_mut(index)
467 {
468 warning.fix = None;
469 }
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn apply(line: &str, edits: &[Option<Fix>]) -> String {
478 let mut edits: Vec<&Fix> = edits.iter().flatten().collect();
479 edits.sort_by_key(|f| std::cmp::Reverse((f.range.start, f.range.end)));
480 let mut out = line.to_string();
481 for fix in edits {
482 out.replace_range(fix.range.clone(), &fix.replacement);
483 }
484 out
485 }
486
487 #[test]
488 fn narrowing_contributions_delete_disjoint_slices() {
489 let line = " text";
490 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
491 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
492 assert_eq!(ranges, vec![0..2, 2..4]);
493 assert_eq!(apply(line, &edits), " text");
494 }
495
496 #[test]
497 fn each_narrowing_contribution_is_correct_alone() {
498 let line = " text";
499 let edits = indent_shift_edits(0, line, 0, &[-2, -2]);
500 assert_eq!(apply(line, &edits[..1]), " text");
501 assert_eq!(apply(line, &edits[1..]), " text");
502 }
503
504 #[test]
505 fn widening_and_narrowing_compose() {
506 let line = " text";
507 let edits = indent_shift_edits(0, line, 0, &[3, -2]);
508 assert_eq!(apply(line, &edits), " text");
509 }
510
511 #[test]
512 fn deletion_never_exceeds_the_leading_spaces() {
513 let edits = indent_shift_edits(0, " text", 0, &[-2, -2]);
514 assert!(edits[0].is_some());
515 assert!(edits[1].is_none());
516 }
517
518 #[test]
519 fn deletion_stops_at_a_tab() {
520 let edits = indent_shift_edits(0, " \ttext", 0, &[-2]);
521 assert_eq!(edits[0].as_ref().unwrap().range, 0..1);
522 }
523
524 #[test]
525 fn edits_start_after_the_offset() {
526 let line = "> text";
527 let edits = indent_shift_edits(10, line, 2, &[-2]);
528 assert_eq!(edits[0].as_ref().unwrap().range, 12..14);
529 let edits = indent_shift_edits(10, line, 2, &[1]);
530 assert_eq!(edits[0].as_ref().unwrap().range, 16..16);
531 }
532
533 #[test]
534 fn equal_widening_contributions_survive_applying_every_fix_together() {
535 let content = "1. a\n 1. b\n z\n";
538 let line_start = content.find(" z").unwrap();
539 let edits = indent_shift_edits(line_start, " z", 0, &[1, 1]);
540 let warnings: Vec<LintWarning> = edits
541 .into_iter()
542 .map(|fix| LintWarning {
543 message: String::new(),
544 line: 3,
545 column: 1,
546 end_line: 3,
547 end_column: 1,
548 severity: crate::rule::Severity::Warning,
549 fix,
550 rule_name: None,
551 })
552 .collect();
553 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
554 assert_eq!(fixed, "1. a\n 1. b\n z\n");
555 for warning in &warnings {
556 let alone = crate::utils::fix_utils::apply_warning_fixes(content, std::slice::from_ref(warning)).unwrap();
557 assert_eq!(alone, "1. a\n 1. b\n z\n");
558 }
559 }
560
561 #[test]
562 fn inserts_never_land_inside_a_deletion() {
563 let edits = indent_shift_edits(0, " text", 0, &[-2, 1]);
564 let ranges: Vec<_> = edits.iter().map(|e| e.as_ref().unwrap().range.clone()).collect();
565 assert_eq!(ranges, vec![0..2, 3..3]);
566 assert_eq!(apply(" text", &edits), " text");
567 }
568
569 #[test]
570 fn lazy_continuation_is_paragraph_text_right_after_the_item() {
571 let content = "* item\nlazy\n\nnot lazy\n";
572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 assert!(is_lazy_continuation(&ctx, 1));
574 assert!(!is_lazy_continuation(&ctx, 3));
575 }
576
577 #[test]
578 fn a_block_start_is_not_a_lazy_continuation() {
579 let content = "* item\n# heading\n* next\n";
580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 assert!(!is_lazy_continuation(&ctx, 1));
582 assert!(!is_lazy_continuation(&ctx, 2));
583 }
584}