Skip to main content

rumdl_lib/utils/
list_indent_shift.rs

1//! Moving the lines a list item owns when a fix moves the item's content column.
2//!
3//! A list item's content column decides what its following lines mean: a fence
4//! indented up to three columns past it is a fence, four or more is an indented
5//! code block, and a nested marker is a child only when it sits at or past it.
6//! A fix that re-spaces a marker (MD030) or re-indents one (MD007) moves that
7//! column, so every line the item owns has to move by the same amount, or the
8//! document renders differently: a nested fence that ends up four columns past
9//! the new content column turns into an indented code block holding the fence
10//! markers as text.
11//!
12//! Several items can move one line: it moves with each item that owns it, outer
13//! to inner. [`indent_shift_edits`] turns those contributions into edits that do
14//! not overlap, so each one can ride on the fix of the item that caused it and
15//! still compose when every fix is applied together.
16
17use crate::lint_context::LintContext;
18use crate::rule::{Fix, LintWarning};
19use crate::utils::blockquote::{effective_indent_in_blockquote, parse_blockquote_prefix};
20
21/// How a following line relates to a list item.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Continuation {
24    /// Part of the item (a continuation line or nested content).
25    Belongs,
26    /// A blank line, which neither continues nor ends the item.
27    Skip,
28    /// The item is over (a sibling/ancestor marker or under-indented content).
29    Ends,
30}
31
32/// An open list item whose content the lines being walked may continue, kept on
33/// a stack from outermost to innermost. `own` is how far the fix moves this
34/// item's content column; `warning` indexes the warning whose fix carries the
35/// moves of the lines the item owns, and is set whenever `own` is non-zero.
36#[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    /// The frame for the list item on `line_num` (1-based), or `None` if the line
47    /// holds no list item.
48    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
60/// The marker column, blockquote nesting level, and minimum (blockquote-aware)
61/// indent a following line needs to continue the list item on `line_num`
62/// (1-based). `None` if the line isn't a list item. Inside a blockquote the
63/// indent excludes the prefix so it stays in the coordinate system of
64/// [`effective_indent_in_blockquote`].
65pub 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
75/// Classify the line at `next_line_num` (1-based) relative to a list item whose
76/// marker is at `marker_column` with continuation threshold (`bq_level`,
77/// `min_indent`). The single source of truth for what belongs to a list item.
78pub 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    // A deeper marker is nested content; one at the same or a shallower column
90    // ends the item.
91    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    // Blank lines don't decide on their own, and inside the item's blockquote a
100    // bare `>` is a blank line.
101    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
117/// Pop the items the line at `line_num` (1-based) ends. A lazy continuation
118/// line, one that falls short of the innermost item's content column yet still
119/// continues its open paragraph, ends nothing. Returns whether the line is such
120/// a lazy continuation, which also means it does not move with the items.
121pub 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/// What keeps a line that ends list items outside them once the fix moves them.
145#[derive(Debug, Clone, Copy)]
146pub enum EndedItems {
147    /// The line still falls short of every item it ends.
148    StayClosed,
149    /// The line would reach an item's narrowed content column and join it, so it
150    /// has to move by this extra frame's `own` as well.
151    ShiftOut(OwnerFrame),
152    /// The line would join an item and cannot be moved on its own: it opens a
153    /// code or math block whose content its indentation positions.
154    Unfixable,
155}
156
157/// How far a fix moves the content column of each item on `stack`, outermost
158/// first. With [`Nesting::Absolute`] a rule places every marker itself, so an
159/// item's column moves by its own amount; with [`Nesting::Relative`] it also
160/// moves with every enclosing item.
161fn 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
175/// Check the line at `line_idx` (0-based), which ends the items `before[kept..]`
176/// and stays in `before[..kept]`, against the columns the fix moves those items
177/// to. A line ends an item by falling short of its content column, so when the
178/// fix narrows that column far enough the line lands inside the item instead: a
179/// paragraph that followed a nested list at its parent's indentation would
180/// become part of the nested list's last item. Such a line moves to the content
181/// column of the item that keeps it: it is not code, so indentation short of
182/// four columns past that column carries no meaning, and the narrowed item's
183/// marker sits at or past it.
184pub 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    // An item the line ends by leaving its blockquote stays ended whatever the
208    // indentation, so only items in the line's own blockquote count.
209    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
240/// Byte offset on `line` where an item's shiftable indent begins: column 0 when
241/// the item is at top level, or just past the blockquote prefix when it sits
242/// inside a blockquote (its indent lives after the `>` markers).
243fn 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
250/// Move the owned `line` (0-based `line_idx`) with every item in `owners` whose
251/// content column moves, attaching each item's edit to that item's own warning.
252/// Each item's move applies within its own coordinate system: past the
253/// blockquote prefix when the item sits inside a blockquote.
254pub 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
288/// The edits that shift the indentation of `line` by each of `contributions` in
289/// turn. `line_start` is the line's byte offset in the document and `offset`
290/// the byte offset within the line where its shiftable indentation begins (past
291/// any blockquote prefix).
292///
293/// Returns one entry per contribution, `None` where it moves nothing. A negative
294/// contribution deletes its own slice of the leading spaces, counted from the
295/// front, so the deletions of different contributions never touch the same
296/// bytes; a deletion never removes more than the spaces actually present, and
297/// stops at a tab. A positive contribution inserts spaces at its own position,
298/// counted back from the end of the leading spaces and never inside a deletion:
299/// two identical inserts at one position would be merged into one when the
300/// fixes are applied together.
301pub 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
334/// Whether the 0-based line `line_idx` continues the paragraph on the line
335/// right above it. CommonMark keeps such a line in the open paragraph whatever
336/// its indentation, so when it falls short of the item's content column (a lazy
337/// continuation) it neither ends the list item nor needs to move with it.
338pub 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    // The line above must be open paragraph text, which a list item's own marker
356    // line is when it carries text after the marker.
357    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/// How a rule positions nested list markers, which decides whether they move
371/// with their parent.
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum Nesting {
374    /// Nested markers keep their place relative to the parent's content, so they
375    /// move with every enclosing item that moves (MD029 renumbering).
376    Relative,
377    /// The rule places every marker at an absolute column through its own
378    /// warning, so nested marker lines are left alone and any other line moves
379    /// with its innermost owning item only (MD005, MD007).
380    Absolute,
381}
382
383/// Whether the line at `line_idx` (0-based) is a list item's marker line. A
384/// fence can open on the marker line (`- ```js`), which the parser flags as
385/// code, but the line is still the item's.
386fn 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
400/// Move every line a list item owns along with that item's content column.
401/// `moves` maps the 0-based line of each item whose content column the rule's
402/// fix moves to the index of the warning carrying that fix and the number of
403/// columns it moves; each line's edit is attached to that warning's fix, so a
404/// single quick fix is correct on its own and all of them compose.
405pub 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    // Where the outermost list being walked starts, and whether a move in it
417    // cannot be made. Its items shift only against each other, so a list that
418    // cannot be fixed leaves the lists around it fixable.
419    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
455/// Drop the fix of every warning that moves an item on the `list` lines.
456/// Moving only some of a list's items would shift them relative to the ones
457/// left alone and change the nesting, so when one move cannot be made safely
458/// the list's warnings stay unfixed together.
459fn 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        // Two owners widening one line by the same amount must not produce two
536        // identical edits, which applying the fixes together merges into one.
537        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}