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. A task checkbox alone is
357    // not text, the item holds no paragraph yet. A line of an HTML block is
358    // HTML, also where the block opens on a marker line, and only a paragraph
359    // continues lazily.
360    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/// How a rule positions nested list markers, which decides whether they move
376/// with their parent.
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum Nesting {
379    /// Nested markers keep their place relative to the parent's content, so they
380    /// move with every enclosing item that moves (MD029 renumbering).
381    Relative,
382    /// The rule places every marker at an absolute column through its own
383    /// warning, so nested marker lines are left alone and any other line moves
384    /// with its innermost owning item only (MD005, MD007).
385    Absolute,
386}
387
388/// Whether the line at `line_idx` (0-based) is a list item's marker line. A
389/// fence can open on the marker line (`- ```js`), which the parser flags as
390/// code, but the line is still the item's.
391fn 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
405/// Move every line a list item owns along with that item's content column.
406/// `moves` maps the 0-based line of each item whose content column the rule's
407/// fix moves to the index of the warning carrying that fix and the number of
408/// columns it moves; each line's edit is attached to that warning's fix, so a
409/// single quick fix is correct on its own and all of them compose.
410pub 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    // Where the outermost list being walked starts, and whether a move in it
422    // cannot be made. Its items shift only against each other, so a list that
423    // cannot be fixed leaves the lists around it fixable.
424    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
460/// Drop the fix of every warning that moves an item on the `list` lines.
461/// Moving only some of a list's items would shift them relative to the ones
462/// left alone and change the nesting, so when one move cannot be made safely
463/// the list's warnings stay unfixed together.
464fn 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        // Two owners widening one line by the same amount must not produce two
541        // identical edits, which applying the fixes together merges into one.
542        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}