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    is_lazy_continuation_source(&ctx.native_list_source(), line_idx)
340}
341
342/// Native paragraph-continuation reading shared with the structural source view.
343pub(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    // The line above must be open paragraph text, which a list item's own marker
364    // line is when it carries text after the marker. A task checkbox alone is
365    // not text, the item holds no paragraph yet. A line of an HTML block is
366    // HTML, also where the block opens on a marker line, and only a paragraph
367    // continues lazily.
368    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/// How a rule positions nested list markers, which decides whether they move
384/// with their parent.
385#[derive(Debug, Clone, Copy, PartialEq, Eq)]
386pub enum Nesting {
387    /// Nested markers keep their place relative to the parent's content, so they
388    /// move with every enclosing item that moves (MD029 renumbering).
389    Relative,
390    /// The rule places every marker at an absolute column through its own
391    /// warning, so nested marker lines are left alone and any other line moves
392    /// with its innermost owning item only (MD005, MD007).
393    Absolute,
394}
395
396/// Whether the line at `line_idx` (0-based) is a list item's marker line. A
397/// fence can open on the marker line (`- ```js`), which the parser flags as
398/// code, but the line is still the item's.
399fn 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
413/// Move every line a list item owns along with that item's content column.
414/// `moves` maps the 0-based line of each item whose content column the rule's
415/// fix moves to the index of the warning carrying that fix and the number of
416/// columns it moves; each line's edit is attached to that warning's fix, so a
417/// single quick fix is correct on its own and all of them compose.
418pub 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    // Where the outermost list being walked starts, and whether a move in it
430    // cannot be made. Its items shift only against each other, so a list that
431    // cannot be fixed leaves the lists around it fixable.
432    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
468/// Drop the fix of every warning that moves an item on the `list` lines.
469/// Moving only some of a list's items would shift them relative to the ones
470/// left alone and change the nesting, so when one move cannot be made safely
471/// the list's warnings stay unfixed together.
472fn 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        // Two owners widening one line by the same amount must not produce two
549        // identical edits, which applying the fixes together merges into one.
550        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}