Skip to main content

bynk_lsp/
extract.rs

1//! v0.213 (ADR 0239): `textDocument/codeAction` extract-variable —
2//! `CodeActionKind::REFACTOR_EXTRACT`. The smallest expression node whose
3//! span fully contains the requested selection is bound to a fresh `let`
4//! inserted immediately above its enclosing statement (or block tail), and
5//! the selection's span is replaced with the new name.
6//!
7//! `text` is whatever snapshot the caller passes in — for `code_action` that's
8//! the last **committed** analysis round's snapshot (ADR 0235's
9//! stale-while-revalidate posture), not necessarily the very latest keystroke.
10//! Like `crate::structure`, this reparses that snapshot fresh each call — no
11//! cached AST is retained in `Analysis`, and extraction is selection-driven
12//! the same way folding/selection ranges are.
13//!
14//! Track #800 settles as: [`plan_function`] reuses the exact same
15//! smallest-containing-expression selection algorithm, but lifts the
16//! expression into a new top-level `fn` (threading its free identifiers as
17//! parameters) rather than a local `let`. It is capability-free-only — `fn`
18//! has no `given` clause to propagate a capability-using body into, unlike
19//! `Handler`/`Provider` (that language-change question is deliberately not
20//! taken on here) — so it declines whenever the selection's site carries any
21//! recorded [`Requirement`], covered or not.
22//!
23//! #813 extends [`plan_function`] to a contiguous run of one or more full
24//! statements (optionally including the block's tail), not just one
25//! expression — offered when the selection's span aligns exactly with
26//! statement boundaries (whitespace-trimmed at either end, so a real
27//! "select these lines" gesture matches); a selection that doesn't align
28//! falls back to the single-expression algorithm above unchanged. A run that
29//! stops before the tail synthesises a `fn … -> ()` — or `-> Effect[()]`
30//! when the run itself performs an effect, so `~>`/`do`/`<-` stay legal in
31//! the lifted body — with no explicit tail, the same implicit-unit-tail
32//! shape the parser already synthesises for any statements-only block
33//! (v0.146, ADR 0170); the call site becomes `let _ = …(…)` (or `do …(…)`
34//! in the effectful case) rather than a bare expression, since Bynk has no
35//! expression-statement form. It declines whenever a `:=` (`Cell` store
36//! write) statement falls inside the run — a lifted top-level `fn` has no
37//! store fields to write, so this always fails to typecheck, not merely a
38//! conservative guess — or whenever a binding the run introduces is still
39//! referenced later in the same block: lifting it away would strand that
40//! reference.
41
42use bynk_check::checker::{TyId, Types};
43use bynk_check::expr_types::type_at_span;
44use bynk_check::locals::{LocalBinding, locals_at};
45use bynk_check::requirements::Requirement;
46use bynk_syntax::ast::*;
47use bynk_syntax::lexer::tokenize;
48use bynk_syntax::parser::parse_unit_with_recovery;
49use bynk_syntax::span::Span;
50use std::collections::HashMap;
51use tower_lsp::lsp_types::*;
52
53/// Extract-variable actions for the requested range. Only offered for a
54/// genuine selection (`requested.start != requested.end`) — a bare cursor
55/// offers nothing, the refactor-vs-quickfix posture other editors take.
56pub fn extract_variable(
57    text: &str,
58    requested: Span,
59    uri: &Url,
60    version: Option<i32>,
61) -> Vec<CodeActionOrCommand> {
62    if requested.start == requested.end {
63        return Vec::new();
64    }
65    let Ok(tokens) = tokenize(text) else {
66        return Vec::new();
67    };
68    let (Some(unit), _errs) = parse_unit_with_recovery(&tokens, text) else {
69        return Vec::new();
70    };
71    let Some(site) = find_site(&unit, requested) else {
72        return Vec::new();
73    };
74    // #1819: the `let` goes above the enclosing statement, outside any arm
75    // pattern, lambda parameter or `is` binding the descent passed. A
76    // selection reading one of those would bind it out of scope, so offer
77    // nothing, as extract-function declines what it can't lift soundly. The
78    // match is by name, deliberately conservative: a selection that rebinds
79    // the name, or reads an outer binding sharing it, is declined too, never
80    // hoisted wrongly.
81    let mut read = Vec::new();
82    collect_idents(site.expr, &mut read);
83    if read.iter().any(|id| site.binders.binds(&id.name)) {
84        return Vec::new();
85    }
86
87    let selected = &text[site.expr_span.start..site.expr_span.end];
88    let name = fresh_name(text);
89    let indent = line_indent(text, site.insertion_offset);
90    let insert_pos = crate::position::offset_to_position(text, site.insertion_offset);
91
92    let edits = vec![
93        OneOf::Left(TextEdit {
94            range: Range::new(insert_pos, insert_pos),
95            new_text: format!("let {name} = {selected}\n{indent}"),
96        }),
97        OneOf::Left(TextEdit {
98            range: crate::position::span_to_range(text, site.expr_span),
99            new_text: name.clone(),
100        }),
101    ];
102    vec![CodeActionOrCommand::CodeAction(CodeAction {
103        title: format!("Extract variable `{name}`"),
104        kind: Some(CodeActionKind::REFACTOR_EXTRACT),
105        edit: Some(WorkspaceEdit {
106            changes: None,
107            document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
108                text_document: OptionalVersionedTextDocumentIdentifier {
109                    uri: uri.clone(),
110                    version,
111                },
112                edits,
113            }])),
114            change_annotations: None,
115        }),
116        ..Default::default()
117    })]
118}
119
120/// Extract-function actions for the requested range (track #800). Locates
121/// the same smallest-containing-expression the extract-variable selection
122/// algorithm would, but only within a `Commons`/`Context` file's top-level
123/// `fn`/`Provider`/`Service`/`Agent` items — a new top-level `fn` needs
124/// somewhere top-level to live, so an `Adapter` (no Bynk bodies) or a `Suite`
125/// test case (no enclosing top-level item to insert above) offers nothing.
126///
127/// Declines (offers nothing) rather than guessing whenever:
128/// - the selection's site carries any recorded capability [`Requirement`],
129///   covered or not — the settled capability-free-only surface (#2100: the
130///   user's decision); a plain `fn` has no `given` to cover it once lifted.
131///   The refusal carries a reason, shown as a disabled action.
132/// - the selection contains a `?` outside a lambda (#2100): lifted, it would
133///   return from the new `fn` instead.
134/// - the selection's expression type, or any free variable's type, isn't
135///   recorded in `expr_types`/the rendered `locals` types — no action rather
136///   than a guessed type. Since ADR 0094 a file with an unrelated error
137///   elsewhere still records both (best-effort partial types), so the action
138///   is offered there too.
139/// - two distinct outer-scope bindings share a free variable's name (a rare
140///   nested-shadow collision) — threading either one as the parameter would
141///   silently pick the wrong variable for the other occurrence.
142/// - any expression inside the selection failed to type (its recorded type
143///   is `Ty::Error`) — the selection's own type would render as
144///   `<type error>`, and a binding whose value failed to type is never
145///   recorded in `locals`, so a read of one would not be threaded as a
146///   parameter and the lifted `fn` would name an unbound variable.
147///
148/// Free variables are the selection's `Ident` references (walked via
149/// [`expr_children`], the same exhaustive child iterator `locate` uses) whose
150/// nearest enclosing binding — [`locals_at`] at the reference's own offset —
151/// sits outside the selection; an identifier with no local binding at all
152/// (a top-level `fn`, a capability, a type name) is left alone, already
153/// resolvable at the new `fn`'s own top-level scope.
154///
155/// #813: the selection may also resolve to a contiguous statement run (see
156/// [`FunctionSelection::Stmts`]) rather than one expression — free-variable
157/// synthesis is the same walk, just seeded from every selected statement's
158/// values (via [`statement_exprs`]) instead of a single expression.
159///
160/// #2100 slice 2: the rules above live in [`plan_function`], which also
161/// refuses a selection containing `?` (an early return). The server lists the
162/// action lazily through [`function_listed`] and computes the edit in
163/// `codeAction/resolve`. This eager form, the plan as one action with its
164/// edit, is what the unit tests below drive.
165#[cfg(test)]
166#[allow(clippy::too_many_arguments)]
167pub fn extract_function(
168    text: &str,
169    requested: Span,
170    uri: &Url,
171    version: Option<i32>,
172    requirements: &[Requirement],
173    locals: &[LocalBinding],
174    expr_types: &[(Span, TyId)],
175    tys: &Types,
176) -> Vec<CodeActionOrCommand> {
177    let Ok(plan) = plan_function(text, requested, requirements, locals, expr_types, tys) else {
178        return Vec::new();
179    };
180    let edits = plan
181        .edits
182        .iter()
183        .map(|(span, new_text)| {
184            OneOf::Left(TextEdit {
185                range: crate::position::span_to_range(text, *span),
186                new_text: new_text.clone(),
187            })
188        })
189        .collect();
190    vec![CodeActionOrCommand::CodeAction(CodeAction {
191        title: format!("Extract function `{}`", plan.name),
192        kind: Some(CodeActionKind::REFACTOR_EXTRACT),
193        edit: Some(WorkspaceEdit {
194            changes: None,
195            document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
196                text_document: OptionalVersionedTextDocumentIdentifier {
197                    uri: uri.clone(),
198                    version,
199                },
200                edits,
201            }])),
202            change_annotations: None,
203        }),
204        ..Default::default()
205    })]
206}
207
208/// #2100 slice 2: a planned extract-function — the new `fn`'s name and the
209/// two edits (insert the `fn`, replace the selection with its call).
210pub struct FunctionPlan {
211    pub name: String,
212    pub edits: Vec<(Span, String)>,
213}
214
215/// #2100 slice 2: why extract-function is not offered for a selection.
216/// `reason` is `Some` when the selection is a real candidate the user should
217/// be told about (a client that supports it shows a disabled action with the
218/// reason); `None` when there is simply nothing to extract there.
219#[derive(Debug)]
220pub struct FunctionRefusal {
221    pub reason: Option<String>,
222}
223
224impl FunctionRefusal {
225    fn silent() -> Self {
226        FunctionRefusal { reason: None }
227    }
228}
229
230/// #2100 slice 2: the extract-function plan for `requested`, or why there is
231/// none. Extracted functions stay capability-free (a `fn` has no `given`
232/// clause), and a selection containing `?` is refused: lifted into a new
233/// `fn`, the `?` would return from that `fn` instead of the one it was
234/// written in.
235pub fn plan_function(
236    text: &str,
237    requested: Span,
238    requirements: &[Requirement],
239    locals: &[LocalBinding],
240    expr_types: &[(Span, TyId)],
241    tys: &Types,
242) -> Result<FunctionPlan, FunctionRefusal> {
243    let silent = || FunctionRefusal::silent();
244    if requested.start == requested.end {
245        return Err(silent());
246    }
247    let tokens = tokenize(text).map_err(|_| silent())?;
248    let (Some(unit), _errs) = parse_unit_with_recovery(&tokens, text) else {
249        return Err(silent());
250    };
251    let site = find_function_site(&unit, requested, text).ok_or_else(silent)?;
252    let mut caps: Vec<&str> = requirements
253        .iter()
254        .filter(|r| contains(site.span, r.site))
255        .map(|r| r.capability.as_str())
256        .collect();
257    if !caps.is_empty() {
258        caps.sort_unstable();
259        caps.dedup();
260        let names = caps
261            .iter()
262            .map(|c| format!("`{c}`"))
263            .collect::<Vec<_>>()
264            .join(", ");
265        return Err(FunctionRefusal {
266            reason: Some(format!(
267                "the selection uses {names}; an extracted function stays capability-free, \
268                 since a `fn` has no `given` clause to carry {}",
269                if caps.len() == 1 { "it" } else { "them" }
270            )),
271        });
272    }
273    if expr_types
274        .iter()
275        .any(|(s, t)| contains(site.span, *s) && t.is_error(tys))
276    {
277        return Err(silent());
278    }
279
280    let mut exprs: Vec<&Expr> = Vec::new();
281    let (ret_ty_display, call_site_form): (String, CallSiteForm) = match &site.selection {
282        FunctionSelection::Expr(expr) => {
283            let Some(ret_ty) = type_at_span(expr_types, site.span) else {
284                return Err(silent());
285            };
286            exprs.push(expr);
287            (ret_ty.display(tys), CallSiteForm::Bare)
288        }
289        FunctionSelection::Stmts {
290            stmts,
291            tail,
292            remainder,
293        } => {
294            // A lifted top-level `fn` has no `store` fields — a `:=` inside
295            // the run (however deeply nested through `if`/`match`/block
296            // sub-expressions) would always fail to resolve its target.
297            if stmts_contain_assign_stmt(stmts) {
298                return Err(silent());
299            }
300            for s in *stmts {
301                statement_exprs(s, &mut exprs);
302            }
303            match tail {
304                Some(t) => {
305                    let Some(ret_ty) = type_at_span(expr_types, t.span) else {
306                        return Err(silent());
307                    };
308                    exprs.push(t);
309                    (ret_ty.display(tys), CallSiteForm::Bare)
310                }
311                None => {
312                    if leaks_a_binding(stmts, remainder, locals, site.span) {
313                        return Err(silent());
314                    }
315                    if stmts_contain_effect_stmt(stmts) {
316                        ("Effect[()]".to_string(), CallSiteForm::Do)
317                    } else {
318                        ("()".to_string(), CallSiteForm::Discard)
319                    }
320                }
321            }
322        }
323    };
324
325    // An early return can't be lifted: inside a new `fn`, `?` would return
326    // from that `fn` rather than the one the selection was written in.
327    if exprs.iter().any(|e| has_early_return(e)) {
328        return Err(FunctionRefusal {
329            reason: Some(
330                "the selection contains `?`, which would return from the extracted \
331                 function instead of the one it is written in"
332                    .to_string(),
333            ),
334        });
335    }
336
337    let mut idents = Vec::new();
338    for e in &exprs {
339        collect_idents(e, &mut idents);
340    }
341    let mut free: Vec<(&str, Span, &str)> = Vec::new();
342    for id in &idents {
343        let Some(binding) = locals_at(locals, id.span.start)
344            .into_iter()
345            .find(|b| b.name == id.name)
346        else {
347            continue;
348        };
349        if contains(site.span, binding.def_span) {
350            continue; // bound inside the selection itself, not free
351        }
352        free.push((id.name.as_str(), binding.def_span, binding.ty.as_str()));
353    }
354    let mut def_by_name: HashMap<&str, Span> = HashMap::new();
355    for (name, def_span, _) in &free {
356        match def_by_name.get(name) {
357            Some(prev) if *prev != *def_span => return Err(silent()),
358            _ => {
359                def_by_name.insert(name, *def_span);
360            }
361        }
362    }
363    let mut params: Vec<(&str, &str)> = Vec::new();
364    for (name, _, ty) in &free {
365        if !params.iter().any(|(n, _)| n == name) {
366            params.push((name, ty));
367        }
368    }
369
370    let fn_name = fresh_word(text, "extractedFn");
371    let param_list = params
372        .iter()
373        .map(|(n, t)| format!("{n}: {t}"))
374        .collect::<Vec<_>>()
375        .join(", ");
376    let arg_list = params
377        .iter()
378        .map(|(n, _)| n.to_string())
379        .collect::<Vec<_>>()
380        .join(", ");
381    let selected = &text[site.span.start..site.span.end];
382    let new_fn = format!("fn {fn_name}({param_list}) -> {ret_ty_display} {{\n  {selected}\n}}\n\n");
383
384    // Bynk has no expression-statement form, so a tail-excluded statement
385    // run's call can't stand alone as `fn_name(args)` — it needs `let _ =`
386    // (pure) or `do` (effectful) to be a legal statement. A bare expression
387    // (single-expression selection, or a run that includes the tail) needs
388    // neither: it replaces an expression position, not a statement.
389    let call_expr = format!("{fn_name}({arg_list})");
390    let call_site_text = match call_site_form {
391        CallSiteForm::Bare => call_expr,
392        CallSiteForm::Discard => format!("let _ = {call_expr}"),
393        CallSiteForm::Do => format!("do {call_expr}"),
394    };
395
396    Ok(FunctionPlan {
397        name: fn_name,
398        edits: vec![
399            (Span::new(site.item_start, site.item_start), new_fn),
400            (site.span, call_site_text),
401        ],
402    })
403}
404
405const FUNCTION_DATA_TAG: &str = "bynk.extractFunction";
406
407/// #2100 slice 2: the listed extract-function action for a selection — no
408/// edit yet (`codeAction/resolve` computes and re-checks it), or, when the
409/// selection is refused for a reason worth telling and the client supports
410/// it, a disabled action carrying that reason. `None` otherwise.
411pub fn function_listed(
412    planned: &Result<FunctionPlan, FunctionRefusal>,
413    requested: Span,
414    uri: &Url,
415    version: Option<i32>,
416    disabled_support: bool,
417) -> Option<CodeActionOrCommand> {
418    match planned {
419        Ok(plan) => Some(CodeActionOrCommand::CodeAction(CodeAction {
420            title: format!("Extract function `{}`", plan.name),
421            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
422            data: Some(serde_json::json!({
423                "kind": FUNCTION_DATA_TAG,
424                "uri": uri.as_str(),
425                "version": version,
426                "start": requested.start,
427                "end": requested.end,
428            })),
429            ..Default::default()
430        })),
431        Err(FunctionRefusal {
432            reason: Some(reason),
433        }) if disabled_support => Some(CodeActionOrCommand::CodeAction(CodeAction {
434            title: "Extract function".to_string(),
435            kind: Some(CodeActionKind::REFACTOR_EXTRACT),
436            disabled: Some(CodeActionDisabled {
437                reason: reason.clone(),
438            }),
439            ..Default::default()
440        })),
441        Err(_) => None,
442    }
443}
444
445/// #2100 slice 2: what a listed extract-function action's `data` names.
446pub struct FunctionTarget {
447    pub uri: Url,
448    pub version: Option<i32>,
449    pub requested: Span,
450}
451
452impl FunctionTarget {
453    pub fn of(action: &CodeAction) -> Option<Self> {
454        let data = action.data.as_ref()?;
455        if data.get("kind")?.as_str()? != FUNCTION_DATA_TAG {
456            return None;
457        }
458        Some(FunctionTarget {
459            uri: Url::parse(data.get("uri")?.as_str()?).ok()?,
460            version: data
461                .get("version")
462                .and_then(serde_json::Value::as_i64)
463                .and_then(|v| i32::try_from(v).ok()),
464            requested: Span::new(
465                usize::try_from(data.get("start")?.as_u64()?).ok()?,
466                usize::try_from(data.get("end")?.as_u64()?).ok()?,
467            ),
468        })
469    }
470}
471
472/// #2100 slice 2: `e` contains a `?` that returns from the enclosing body —
473/// one inside a lambda returns from the lambda, so it moves with it.
474fn has_early_return(e: &Expr) -> bool {
475    match &e.kind {
476        ExprKind::Question(_) => true,
477        ExprKind::Lambda(_) => false,
478        _ => expr_children(e).into_iter().any(has_early_return),
479    }
480}
481
482/// The smallest expression node fully containing the selection, and the byte
483/// offset immediately before its enclosing statement (or block tail) — where
484/// the new `let` line is inserted. `expr` is retained (not just its span) so
485/// [`plan_function`] can walk it for free identifiers without a second
486/// descent through the tree.
487struct Site<'a> {
488    insertion_offset: usize,
489    expr_span: Span,
490    expr: &'a Expr,
491    /// #1819: the names bound between `insertion_offset` and the selection,
492    /// by an expression-bodied arm's pattern or a lambda's parameters: in
493    /// scope at the selection, not at the insertion point. A descent into a
494    /// block resets them along with the offset, since the `let` then goes
495    /// inside the scope that binds them.
496    binders: Binders<'a>,
497}
498
499/// #1819: what is bound between a [`Site`]'s insertion point and its
500/// selection, so out of scope where the `let` goes.
501#[derive(Default)]
502struct Binders<'a> {
503    names: Vec<&'a str>,
504    /// Inside an observation's `with` predicate, whose names are the observed
505    /// operation's parameters: declared on the capability, not in the tree,
506    /// so every name read there is treated as bound (#1880 review).
507    opaque: bool,
508}
509
510impl<'a> Binders<'a> {
511    fn extend(&mut self, names: impl IntoIterator<Item = &'a str>) {
512        self.names.extend(names);
513    }
514
515    fn binds(&self, name: &str) -> bool {
516        self.opaque || self.names.contains(&name)
517    }
518}
519
520/// The names an `is` test binds for the operand to its right: `r is Ok(n)`
521/// binds `n` on the right of `&&` / `implies`, through a chain of them and
522/// parentheses, as the checker scopes them (#1880 review).
523fn is_bindings(e: &Expr) -> Vec<&str> {
524    match &e.kind {
525        ExprKind::Is { pattern, .. } => pattern
526            .bound_names()
527            .into_iter()
528            .map(|id| id.name.as_str())
529            .collect(),
530        ExprKind::BinOp(BinOp::And | BinOp::Implies, l, r) => {
531            let mut names = is_bindings(l);
532            names.extend(is_bindings(r));
533            names
534        }
535        ExprKind::Paren(inner) => is_bindings(inner),
536        _ => Vec::new(),
537    }
538}
539
540/// Closed containment over half-open spans: `outer` fully contains `inner`.
541fn contains(outer: Span, inner: Span) -> bool {
542    outer.start <= inner.start && inner.end <= outer.end
543}
544
545/// Finds the body (fn / handler / provider op / test case) whose span
546/// contains `target`, then hands off to [`find_in_block`]. `None` when the
547/// selection sits outside any body (a header, or a file that doesn't parse).
548///
549/// #2010: a selection whose smallest enclosing expression is the `match` a
550/// `let … else` was desugared into — the statement itself, or a span crossing
551/// from it into the rest of its block — has no expression of the author's to
552/// lift, so it is `None` too.
553fn find_site(unit: &SourceUnit, target: Span) -> Option<Site<'_>> {
554    find_site_any(unit, target).filter(|site| !site.expr.is_let_else_match())
555}
556
557fn find_site_any(unit: &SourceUnit, target: Span) -> Option<Site<'_>> {
558    match unit {
559        SourceUnit::Commons(c) => find_in_items(&c.items, target),
560        SourceUnit::Context(c) => find_in_items(&c.items, target),
561        // Adapters carry no Bynk-bodied operations to extract from.
562        SourceUnit::Adapter(_) => None,
563        SourceUnit::Suite(t) => t
564            .cases
565            .iter()
566            .find(|case| contains(case.body.span, target))
567            .and_then(|case| find_in_block(&case.body, target)),
568    }
569}
570
571fn find_in_items(items: &[CommonsItem], target: Span) -> Option<Site<'_>> {
572    for item in items {
573        match item {
574            CommonsItem::Fn(f) if contains(f.body.span, target) => {
575                return find_in_block(&f.body, target);
576            }
577            CommonsItem::Provider(p) => {
578                if let Some(op) = p.ops.iter().find(|op| contains(op.body.span, target)) {
579                    return find_in_block(&op.body, target);
580                }
581            }
582            CommonsItem::Service(s) => {
583                if let Some(h) = s.handlers.iter().find(|h| contains(h.body.span, target)) {
584                    return find_in_block(&h.body, target);
585                }
586            }
587            CommonsItem::Agent(a) => {
588                if let Some(h) = a.handlers.iter().find(|h| contains(h.body.span, target)) {
589                    return find_in_block(&h.body, target);
590                }
591            }
592            _ => {}
593        }
594    }
595    None
596}
597
598/// Finds the statement expression (or the tail) that fully contains `target`,
599/// then narrows within it via [`locate`]. `None` when `target` doesn't sit
600/// fully inside any single statement/tail (e.g. it spans the whole block,
601/// braces included, or crosses a statement boundary).
602fn find_in_block(block: &Block, target: Span) -> Option<Site<'_>> {
603    for stmt in &block.statements {
604        // #1851: whichever of the statement's expressions holds the selection.
605        // Not the first: since #1766 a principal's identity comes before the
606        // value it addresses.
607        let mut exprs = Vec::new();
608        statement_exprs(stmt, &mut exprs);
609        if let Some(e) = exprs.into_iter().find(|e| contains(e.span, target)) {
610            return Some(locate(e, target, stmt.span().start, Binders::default()));
611        }
612    }
613    if contains(block.tail.span, target) {
614        return Some(locate(
615            &block.tail,
616            target,
617            block.tail.span.start,
618            Binders::default(),
619        ));
620    }
621    None
622}
623
624/// Descends into `expr` for the smallest node whose span fully contains
625/// `target`. `Block`/`If`/`Match` carry their own nested statement/tail
626/// slots, so descending into one resets `insertion_offset` to that slot's
627/// start; every other kind bubbles the offset through unchanged, walked via
628/// [`expr_children`] — `bynk_syntax`'s exhaustive-by-construction child
629/// iterator, reused here rather than a second hand-rolled `ExprKind` match.
630/// The remainder is enumerated explicitly rather than wildcarded, so an
631/// `ExprKind` variant this doesn't handle fails to compile here instead of
632/// silently falling through the extraction path — and a variant carrying a
633/// `Block` field needs hand-matching above rather than appending to that
634/// list, for the reason recorded at the arm itself.
635fn locate<'a>(
636    expr: &'a Expr,
637    target: Span,
638    insertion_offset: usize,
639    mut binders: Binders<'a>,
640) -> Site<'a> {
641    match &expr.kind {
642        ExprKind::Block(b) => find_in_block(b, target).unwrap_or(Site {
643            insertion_offset,
644            expr_span: expr.span,
645            expr,
646            binders,
647        }),
648        ExprKind::If {
649            cond,
650            then_block,
651            else_block,
652        } => {
653            if contains(cond.span, target) {
654                return locate(cond, target, insertion_offset, binders);
655            }
656            if contains(then_block.span, target)
657                && let Some(site) = find_in_block(then_block, target)
658            {
659                return site;
660            }
661            if contains(else_block.span, target)
662                && let Some(site) = find_in_block(else_block, target)
663            {
664                return site;
665            }
666            Site {
667                insertion_offset,
668                expr_span: expr.span,
669                expr,
670                binders,
671            }
672        }
673        ExprKind::Match { discriminant, arms } => {
674            if contains(discriminant.span, target) {
675                return locate(discriminant, target, insertion_offset, binders);
676            }
677            // A selection inside a guard deliberately falls through to the
678            // whole `match` below rather than descending (#1800 review): the
679            // extracted `let` goes above the enclosing statement, outside the
680            // arm, and a guard almost always reads the arm's pattern bindings,
681            // which are not in scope there. Extracting the whole `match` is
682            // sound.
683            for arm in arms {
684                if !contains(arm.body.span(), target) {
685                    continue;
686                }
687                return match &arm.body {
688                    MatchBody::Expr(e) => {
689                        // #1819: an expression body has no block to insert
690                        // into, so the arm's bindings stay out of scope at the
691                        // insertion point.
692                        binders.extend(arm.pattern.bound_names().iter().map(|id| id.name.as_str()));
693                        locate(e, target, insertion_offset, binders)
694                    }
695                    MatchBody::Block(b) => find_in_block(b, target).unwrap_or(Site {
696                        insertion_offset,
697                        expr_span: expr.span,
698                        expr,
699                        binders,
700                    }),
701                };
702            }
703            Site {
704                insertion_offset,
705                expr_span: expr.span,
706                expr,
707                binders,
708            }
709        }
710        // No variant below carries a `Block` *field*, so `expr_children`'s
711        // total descent is complete for it and bubbling `insertion_offset`
712        // through unchanged is correct — a block reached through a child (a
713        // braced lambda body, say) comes back as an `Expr` and re-enters this
714        // match at the `Block` arm above, which resets the offset via
715        // `find_in_block`. A *new* variant that holds a `Block` directly must
716        // be hand-matched up there alongside `Block`/`If`/`Match`: appending
717        // it here descends past the block's own statement list *and* bubbles
718        // the outer offset, so the returned `Site` inserts the extracted `fn`
719        // outside the block the selection lives in — a wrong edit, not a
720        // failed one.
721        ExprKind::IntLit { .. }
722        | ExprKind::FloatLit { .. }
723        | ExprKind::DurationLit { .. }
724        | ExprKind::StrLit(_)
725        | ExprKind::InterpStr(_)
726        | ExprKind::BoolLit(_)
727        | ExprKind::Ident(_)
728        | ExprKind::Call { .. }
729        | ExprKind::Lambda(_)
730        | ExprKind::BinOp(..)
731        | ExprKind::UnaryOp(..)
732        | ExprKind::Paren(_)
733        | ExprKind::Ok(_)
734        | ExprKind::Err(_)
735        | ExprKind::Question(_)
736        | ExprKind::RecordConstruction { .. }
737        | ExprKind::FieldAccess { .. }
738        | ExprKind::MethodCall { .. }
739        | ExprKind::Is { .. }
740        | ExprKind::Some(_)
741        | ExprKind::None
742        | ExprKind::UnitLit
743        | ExprKind::RecordSpread { .. }
744        | ExprKind::EffectPure(_)
745        | ExprKind::Expect(_)
746        | ExprKind::Val { .. }
747        | ExprKind::Wire(_)
748        | ExprKind::ListLit(_)
749        | ExprKind::Observation(_)
750        | ExprKind::Faults(_)
751        | ExprKind::Trace { .. } => {
752            let children = expr_children(expr);
753            match children.into_iter().find(|c| contains(c.span, target)) {
754                Some(child) => {
755                    // #1819: a lambda's parameters are in scope in its body
756                    // only; an expression body bubbles the outer offset.
757                    if let ExprKind::Lambda(l) = &expr.kind {
758                        binders.extend(l.params.iter().map(|p| p.name.name.as_str()));
759                    }
760                    // #1880 review: an `is` on the left of `&&` / `implies`
761                    // binds for the right operand only.
762                    if let ExprKind::BinOp(BinOp::And | BinOp::Implies, lhs, rhs) = &expr.kind
763                        && std::ptr::eq(child, rhs.as_ref())
764                    {
765                        binders.extend(is_bindings(lhs));
766                    }
767                    if let ExprKind::Observation(o) = &expr.kind
768                        && let ObservationMatcher::Called {
769                            with_pred: Some(pred),
770                            ..
771                        } = &o.matcher
772                        && std::ptr::eq(child, pred.as_ref())
773                    {
774                        binders.opaque = true;
775                    }
776                    locate(child, target, insertion_offset, binders)
777                }
778                None => Site {
779                    insertion_offset,
780                    expr_span: expr.span,
781                    expr,
782                    binders,
783                },
784            }
785        }
786    }
787}
788
789/// `extracted`, `extracted2`, … — the first candidate that doesn't already
790/// appear as a whole word anywhere in the file. A collision-avoiding
791/// placeholder, not a scope-aware binder: the client's rename-on-extract is
792/// the expected next step for a better name.
793fn fresh_name(text: &str) -> String {
794    fresh_word(text, "extracted")
795}
796
797/// Like [`fresh_name`], generalised to any base word — extract-function uses
798/// `extractedFn`/`extractedFn2`/… so its generated name reads distinctly from
799/// extract-variable's `extracted` when both actions are offered on the same
800/// selection.
801fn fresh_word(text: &str, base: &str) -> String {
802    let is_ident_char = |c: char| c.is_alphanumeric() || c == '_';
803    let occurs_as_word = |candidate: &str| {
804        text.match_indices(candidate).any(|(i, _)| {
805            let before_ok = text[..i]
806                .chars()
807                .next_back()
808                .is_none_or(|c| !is_ident_char(c));
809            let after_ok = text[i + candidate.len()..]
810                .chars()
811                .next()
812                .is_none_or(|c| !is_ident_char(c));
813            before_ok && after_ok
814        })
815    };
816    let mut n = 1;
817    loop {
818        let candidate = if n == 1 {
819            base.to_string()
820        } else {
821            format!("{base}{n}")
822        };
823        if !occurs_as_word(&candidate) {
824            return candidate;
825        }
826        n += 1;
827    }
828}
829
830/// The enclosing top-level item's span — where [`find_function_site`] inserts
831/// the new `fn` (immediately above it). `None` for a `CommonsItem` extraction
832/// can never target (a `type`/`context` declaration carries no body).
833fn item_span(item: &CommonsItem) -> Option<Span> {
834    match item {
835        CommonsItem::Fn(f) => Some(f.span),
836        CommonsItem::Provider(p) => Some(p.span),
837        CommonsItem::Service(s) => Some(s.span),
838        CommonsItem::Agent(a) => Some(a.span),
839        _ => None,
840    }
841}
842
843/// Where a [`FunctionSite`]'s selection resolves to. #813 adds [`Stmts`](Self::Stmts)
844/// alongside the original track #800 single-expression shape.
845enum FunctionSelection<'a> {
846    /// The original shape: one AST expression, exactly as [`Site`] finds.
847    Expr(&'a Expr),
848    /// #813: a contiguous run of one or more full statements from a single
849    /// block, optionally including the block's tail. `remainder` is every
850    /// expression after the run within that same block — the tail plus
851    /// nothing else when `tail` is `Some` (nothing follows it), or the tail
852    /// and every later statement's value when `tail` is `None` — checked by
853    /// [`leaks_a_binding`] so a binding the run introduces can't be lifted
854    /// away out from under a live use.
855    Stmts {
856        stmts: &'a [Statement],
857        tail: Option<&'a Expr>,
858        remainder: Vec<&'a Expr>,
859    },
860}
861
862/// How the call site replaces the selection. A tail-excluded statement run
863/// has no expression position to drop a bare call into — Bynk has no
864/// expression-statement form — so it needs a statement wrapper instead; see
865/// [`plan_function`]'s call-site construction.
866enum CallSiteForm {
867    /// The call stands as a bare expression (a single-expression selection,
868    /// or a statement run that includes the tail).
869    Bare,
870    /// A tail-excluded run whose lifted `fn` is pure (`-> ()`).
871    Discard,
872    /// A tail-excluded run whose lifted `fn` performs an effect
873    /// (`-> Effect[()]`).
874    Do,
875}
876
877/// Like [`find_site`], but only within `Commons`/`Context` files, and
878/// additionally records the start of the enclosing top-level item — where the
879/// new `fn` is inserted, immediately above it.
880struct FunctionSite<'a> {
881    item_start: usize,
882    /// The exact selected span — the replacement range, and (for `Stmts`)
883    /// the bound every containment check is stated against.
884    span: Span,
885    selection: FunctionSelection<'a>,
886}
887
888/// #813 tries a statement-run match first (against a whitespace-trimmed
889/// `target`, since a "select these lines" gesture commonly pads onto
890/// surrounding blank space); falling short of an exact statement-boundary
891/// alignment there, this falls back to the original single-expression
892/// algorithm unchanged.
893fn find_function_site<'a>(
894    unit: &'a SourceUnit,
895    target: Span,
896    text: &str,
897) -> Option<FunctionSite<'a>> {
898    let items = match unit {
899        SourceUnit::Commons(c) => &c.items,
900        SourceUnit::Context(c) => &c.items,
901        SourceUnit::Adapter(_) | SourceUnit::Suite(_) => return None,
902    };
903    let item = items
904        .iter()
905        .find(|it| item_span(it).is_some_and(|s| contains(s, target)))?;
906    let item_start = item_span(item)?.start;
907
908    let trimmed = trim_span(text, target);
909    if trimmed.start < trimmed.end
910        && let Some((stmts, tail, remainder)) = find_multi_stmt_in_item(item, trimmed)
911    {
912        return Some(FunctionSite {
913            item_start,
914            span: trimmed,
915            selection: FunctionSelection::Stmts {
916                stmts,
917                tail,
918                remainder,
919            },
920        });
921    }
922
923    let site = find_in_items(std::slice::from_ref(item), target)?;
924    Some(FunctionSite {
925        item_start,
926        span: site.expr_span,
927        selection: FunctionSelection::Expr(site.expr),
928    })
929}
930
931/// Trims leading/trailing whitespace bytes from `span` against `text`: a
932/// real editor selection of "these lines" commonly pads onto the
933/// surrounding blank space, which isn't a partial-statement selection the
934/// way clipping actual statement content would be.
935fn trim_span(text: &str, span: Span) -> Span {
936    let s = &text[span.start..span.end];
937    let start = span.start + (s.len() - s.trim_start().len());
938    let trimmed_len = s.trim().len();
939    Span::new(start, start + trimmed_len)
940}
941
942/// A found statement run: the contiguous statement slice, the block's tail
943/// when the run includes it, and — only populated by [`align_stmt_run`]'s
944/// tail-excluded branch — the remainder [`leaks_a_binding`] scans.
945type StmtRun<'a> = (&'a [Statement], Option<&'a Expr>, Vec<&'a Expr>);
946
947/// Dispatches to the item's body/op/handler containing `target`, then hands
948/// off to [`find_stmt_run`] — the statement-run counterpart of
949/// [`find_in_items`].
950fn find_multi_stmt_in_item(item: &CommonsItem, target: Span) -> Option<StmtRun<'_>> {
951    match item {
952        CommonsItem::Fn(f) if contains(f.body.span, target) => find_stmt_run(&f.body, target),
953        CommonsItem::Provider(p) => p
954            .ops
955            .iter()
956            .find(|op| contains(op.body.span, target))
957            .and_then(|op| find_stmt_run(&op.body, target)),
958        CommonsItem::Service(s) => s
959            .handlers
960            .iter()
961            .find(|h| contains(h.body.span, target))
962            .and_then(|h| find_stmt_run(&h.body, target)),
963        CommonsItem::Agent(a) => a
964            .handlers
965            .iter()
966            .find(|h| contains(h.body.span, target))
967            .and_then(|h| find_stmt_run(&h.body, target)),
968        _ => None,
969    }
970}
971
972/// Descends to the smallest block whose own statement list aligns exactly
973/// with `target` (#813): a nested block fully containing `target` is tried
974/// first via [`find_stmt_run_in_expr`] (mirroring [`locate`]'s
975/// descend-first policy), so a run inside an `if`/`match` branch resolves
976/// there rather than at the outer level; [`align_stmt_run`] does the actual
977/// boundary check once no deeper block matches. Unlike [`locate`], it also
978/// descends into a `match` arm's guard (#1800): a run is lifted into a `fn`
979/// whose parameters carry the guard's bindings, so nothing is hoisted out of
980/// their scope.
981fn find_stmt_run(block: &Block, target: Span) -> Option<StmtRun<'_>> {
982    for stmt in &block.statements {
983        let mut values = Vec::new();
984        statement_exprs(stmt, &mut values);
985        for v in values {
986            if let Some(found) = find_stmt_run_in_expr(v, target) {
987                return Some(found);
988            }
989        }
990    }
991    if let Some(found) = find_stmt_run_in_expr(&block.tail, target) {
992        return Some(found);
993    }
994    align_stmt_run(block, target)
995}
996
997/// Looks for a nested block inside `e` whose statement list aligns with
998/// `target` — `None` unless `e`'s span actually contains `target`. Falls
999/// through to [`expr_children`] for anything other than `Block`/`If`/`Match`,
1000/// the same exhaustive walk [`locate`] and [`collect_idents`] use.
1001fn find_stmt_run_in_expr(e: &Expr, target: Span) -> Option<StmtRun<'_>> {
1002    if !contains(e.span, target) {
1003        return None;
1004    }
1005    match &e.kind {
1006        ExprKind::Block(b) => find_stmt_run(b, target),
1007        ExprKind::If {
1008            cond,
1009            then_block,
1010            else_block,
1011        } => find_stmt_run_in_expr(cond, target)
1012            .or_else(|| find_stmt_run(then_block, target))
1013            .or_else(|| find_stmt_run(else_block, target)),
1014        // #1800: a guard is searched too, so a run inside one
1015        // (`Some(x) if if x > 0 { … } else { … } =>`) can be extracted.
1016        ExprKind::Match { discriminant, arms } => find_stmt_run_in_expr(discriminant, target)
1017            .or_else(|| {
1018                arms.iter().find_map(|arm| {
1019                    arm.guard
1020                        .as_ref()
1021                        .and_then(|g| find_stmt_run_in_expr(g, target))
1022                        .or_else(|| match &arm.body {
1023                            MatchBody::Expr(e) => find_stmt_run_in_expr(e, target),
1024                            MatchBody::Block(b) => find_stmt_run(b, target),
1025                        })
1026                })
1027            }),
1028        // No variant below carries a `Block` *field*, so `expr_children`'s
1029        // total descent is complete for it — a block reached through a child
1030        // (a braced lambda body, say) comes back as an `Expr` and re-enters
1031        // this match at the `Block` arm above. A *new* variant that holds a
1032        // `Block` directly must be hand-matched up there alongside
1033        // `Block`/`If`/`Match`: appending it here descends past the block's
1034        // own statement list (`expr_children` flattens a block to its
1035        // statements' values), so a run inside it stops being extractable.
1036        ExprKind::IntLit { .. }
1037        | ExprKind::FloatLit { .. }
1038        | ExprKind::DurationLit { .. }
1039        | ExprKind::StrLit(_)
1040        | ExprKind::InterpStr(_)
1041        | ExprKind::BoolLit(_)
1042        | ExprKind::Ident(_)
1043        | ExprKind::Call { .. }
1044        | ExprKind::Lambda(_)
1045        | ExprKind::BinOp(..)
1046        | ExprKind::UnaryOp(..)
1047        | ExprKind::Paren(_)
1048        | ExprKind::Ok(_)
1049        | ExprKind::Err(_)
1050        | ExprKind::Question(_)
1051        | ExprKind::RecordConstruction { .. }
1052        | ExprKind::FieldAccess { .. }
1053        | ExprKind::MethodCall { .. }
1054        | ExprKind::Is { .. }
1055        | ExprKind::Some(_)
1056        | ExprKind::None
1057        | ExprKind::UnitLit
1058        | ExprKind::RecordSpread { .. }
1059        | ExprKind::EffectPure(_)
1060        | ExprKind::Expect(_)
1061        | ExprKind::Val { .. }
1062        | ExprKind::Wire(_)
1063        | ExprKind::ListLit(_)
1064        | ExprKind::Observation(_)
1065        | ExprKind::Faults(_)
1066        | ExprKind::Trace { .. } => expr_children(e)
1067            .into_iter()
1068            .find_map(|c| find_stmt_run_in_expr(c, target)),
1069    }
1070}
1071
1072/// Tries to align `target` exactly against `block`'s own statement list: its
1073/// start must equal some statement's start, and its end must equal either
1074/// another statement's end (the run stops before the tail) or the tail's own
1075/// end (the run includes it). No match — a partial-statement selection, or
1076/// one spanning more than this single block — returns `None`.
1077fn align_stmt_run(block: &Block, target: Span) -> Option<StmtRun<'_>> {
1078    // #2010: a `let … else` is a hidden binding plus the `match` it heads, which
1079    // holds the rest of the block; lifting the binding alone would strand the
1080    // rest of the block, so a run that reaches one is not offered.
1081    if block
1082        .statements
1083        .iter()
1084        .any(|s| matches!(s, Statement::Let(l) | Statement::EffectLet(l) if l.let_else.is_some()))
1085        && block
1086            .statements
1087            .last()
1088            .is_some_and(|s| s.span().end <= target.end)
1089    {
1090        return None;
1091    }
1092    let start_idx = block
1093        .statements
1094        .iter()
1095        .position(|s| s.span().start == target.start)?;
1096    if target.end == block.tail.span.end {
1097        let full = Span::new(
1098            block.statements[start_idx].span().start,
1099            block.tail.span.end,
1100        );
1101        if full == target {
1102            return Some((
1103                &block.statements[start_idx..],
1104                Some(block.tail.as_ref()),
1105                Vec::new(),
1106            ));
1107        }
1108    }
1109    let end_idx = block
1110        .statements
1111        .iter()
1112        .position(|s| s.span().end == target.end)?;
1113    if end_idx < start_idx {
1114        return None;
1115    }
1116    let full = Span::new(
1117        block.statements[start_idx].span().start,
1118        block.statements[end_idx].span().end,
1119    );
1120    if full != target {
1121        return None;
1122    }
1123    let mut remainder = Vec::new();
1124    for s in &block.statements[end_idx + 1..] {
1125        statement_exprs(s, &mut remainder);
1126    }
1127    remainder.push(&block.tail);
1128    Some((&block.statements[start_idx..=end_idx], None, remainder))
1129}
1130
1131/// Whether any `let`/`<-` binding introduced by `stmts` (skipping `_`) is
1132/// still referenced in `remainder` — the expressions after the run within
1133/// the same block. A tail-excluded run whose binding leaks this way can't be
1134/// lifted away: the reference downstream would resolve to nothing once the
1135/// binding moves into the new `fn`.
1136fn leaks_a_binding(
1137    stmts: &[Statement],
1138    remainder: &[&Expr],
1139    locals: &[LocalBinding],
1140    run_span: Span,
1141) -> bool {
1142    let bound_names: Vec<&str> = stmts
1143        .iter()
1144        .filter_map(|s| match s {
1145            Statement::Let(l) | Statement::EffectLet(l) if l.name.name != "_" => {
1146                Some(l.name.name.as_str())
1147            }
1148            _ => None,
1149        })
1150        .collect();
1151    if bound_names.is_empty() {
1152        return false;
1153    }
1154    let mut used = Vec::new();
1155    for e in remainder {
1156        collect_idents(e, &mut used);
1157    }
1158    used.iter().any(|id| {
1159        bound_names.contains(&id.name.as_str())
1160            && locals_at(locals, id.span.start)
1161                .into_iter()
1162                .find(|b| b.name == id.name)
1163                .is_some_and(|b| contains(run_span, b.def_span))
1164    })
1165}
1166
1167/// Whether any of `stmts` matches `pred` — checked on the statement itself
1168/// first, then recursively on every nested statement reachable through its
1169/// value expression's own `if`/`match`/block sub-expressions (arbitrarily
1170/// deep). The shared shape behind [`stmts_contain_effect_stmt`] and
1171/// [`stmts_contain_assign_stmt`]: both need "does this run perform/contain
1172/// X, however deeply nested" and neither can stop at the run's direct
1173/// top-level statements — a `~>`/`do`/`<-` or a `:=` can equally sit inside
1174/// an `if`/`match` branch that one of the run's own `let`s evaluates.
1175fn stmts_match(stmts: &[Statement], pred: &impl Fn(&Statement) -> bool) -> bool {
1176    stmts.iter().any(|s| pred(s) || stmt_value_matches(s, pred))
1177}
1178
1179fn stmt_value_matches(s: &Statement, pred: &impl Fn(&Statement) -> bool) -> bool {
1180    // #1851: every expression of the statement, a principal's identity
1181    // included.
1182    let mut exprs = Vec::new();
1183    statement_exprs(s, &mut exprs);
1184    exprs.into_iter().any(|e| expr_matches(e, pred))
1185}
1186
1187fn block_matches(b: &Block, pred: &impl Fn(&Statement) -> bool) -> bool {
1188    stmts_match(&b.statements, pred) || expr_matches(&b.tail, pred)
1189}
1190
1191fn expr_matches(e: &Expr, pred: &impl Fn(&Statement) -> bool) -> bool {
1192    match &e.kind {
1193        ExprKind::Block(b) => block_matches(b, pred),
1194        ExprKind::If {
1195            cond,
1196            then_block,
1197            else_block,
1198        } => {
1199            expr_matches(cond, pred)
1200                || block_matches(then_block, pred)
1201                || block_matches(else_block, pred)
1202        }
1203        // #1800: a guard's statements count too. Skipping it let a run whose
1204        // guard held a `<-` be lifted to a `()`-returning fn, and one holding
1205        // a `:=` be lifted at all; neither typechecks.
1206        ExprKind::Match { discriminant, arms } => {
1207            expr_matches(discriminant, pred)
1208                || arms.iter().any(|arm| {
1209                    arm.guard.as_ref().is_some_and(|g| expr_matches(g, pred))
1210                        || match &arm.body {
1211                            MatchBody::Expr(e) => expr_matches(e, pred),
1212                            MatchBody::Block(b) => block_matches(b, pred),
1213                        }
1214                })
1215        }
1216        // No variant below carries a `Block` *field*, so `expr_children`'s
1217        // total descent is complete for it — a block reached through a child
1218        // (a braced lambda body, say) comes back as an `Expr` and re-enters
1219        // this match at the `Block` arm above. A *new* variant that holds a
1220        // `Block` directly must be hand-matched up there alongside
1221        // `Block`/`If`/`Match`: appending it here never reaches the block's
1222        // statement list (`expr_children` flattens a block to its statements'
1223        // values), so `pred` is never asked about the statements inside it.
1224        ExprKind::IntLit { .. }
1225        | ExprKind::FloatLit { .. }
1226        | ExprKind::DurationLit { .. }
1227        | ExprKind::StrLit(_)
1228        | ExprKind::InterpStr(_)
1229        | ExprKind::BoolLit(_)
1230        | ExprKind::Ident(_)
1231        | ExprKind::Call { .. }
1232        | ExprKind::Lambda(_)
1233        | ExprKind::BinOp(..)
1234        | ExprKind::UnaryOp(..)
1235        | ExprKind::Paren(_)
1236        | ExprKind::Ok(_)
1237        | ExprKind::Err(_)
1238        | ExprKind::Question(_)
1239        | ExprKind::RecordConstruction { .. }
1240        | ExprKind::FieldAccess { .. }
1241        | ExprKind::MethodCall { .. }
1242        | ExprKind::Is { .. }
1243        | ExprKind::Some(_)
1244        | ExprKind::None
1245        | ExprKind::UnitLit
1246        | ExprKind::RecordSpread { .. }
1247        | ExprKind::EffectPure(_)
1248        | ExprKind::Expect(_)
1249        | ExprKind::Val { .. }
1250        | ExprKind::Wire(_)
1251        | ExprKind::ListLit(_)
1252        | ExprKind::Observation(_)
1253        | ExprKind::Faults(_)
1254        | ExprKind::Trace { .. } => expr_children(e).into_iter().any(|c| expr_matches(c, pred)),
1255    }
1256}
1257
1258/// Whether any of `stmts` is (or contains, arbitrarily nested through
1259/// `if`/`match`/block sub-expressions) a `~>`/`do`/`<-` statement — the three
1260/// forms legal only in an effectful body (`ctx.effectful`, gated on the
1261/// enclosing return type being `Effect[_]`). Decides whether a tail-excluded
1262/// run's synthesised `fn` must return `Effect[()]` — so those forms stay
1263/// legal in the lifted body — rather than plain `()`.
1264fn stmts_contain_effect_stmt(stmts: &[Statement]) -> bool {
1265    stmts_match(stmts, &|s| {
1266        matches!(
1267            s,
1268            Statement::EffectLet(_) | Statement::Send(_) | Statement::Do(_)
1269        )
1270    })
1271}
1272
1273/// Whether any of `stmts` is (or contains, arbitrarily nested through
1274/// `if`/`match`/block sub-expressions) a `:=` (`Cell` store write). A lifted
1275/// top-level `fn` has no `store` fields, so a run containing one anywhere —
1276/// not just as a direct top-level statement — would always fail to
1277/// typecheck once extracted.
1278fn stmts_contain_assign_stmt(stmts: &[Statement]) -> bool {
1279    stmts_match(stmts, &|s| matches!(s, Statement::Assign(_)))
1280}
1281
1282/// `expr` and every `Ident` reference nested inside it — walked via
1283/// [`expr_children`], the same exhaustive-by-construction iterator `locate`
1284/// uses, so a future `ExprKind` variant can't silently drop out of either
1285/// walk without the other noticing.
1286///
1287/// #2100 slice 2: a called name (`f(…)`) counts too. It calls whatever `f`
1288/// resolves to, a local closure included, so extract-function threads a
1289/// called local through as a parameter, and the binder and leak checks see
1290/// it.
1291fn collect_idents<'a>(expr: &'a Expr, out: &mut Vec<&'a Ident>) {
1292    match &expr.kind {
1293        ExprKind::Ident(id) => out.push(id),
1294        ExprKind::Call { name, .. } => out.push(name),
1295        _ => {}
1296    }
1297    for child in expr_children(expr) {
1298        collect_idents(child, out);
1299    }
1300}
1301
1302/// The whitespace-only run from `offset`'s line start up to `offset` — empty
1303/// if that run isn't pure whitespace (e.g. a single-line body has no
1304/// indentation to mirror; `bynk fmt` cleans up the result, the same
1305/// tolerance the quick-fix path takes).
1306fn line_indent(text: &str, offset: usize) -> String {
1307    let line_start = text[..offset].rfind('\n').map_or(0, |i| i + 1);
1308    let prefix = &text[line_start..offset];
1309    if prefix.chars().all(|c| c == ' ' || c == '\t') {
1310        prefix.to_string()
1311    } else {
1312        String::new()
1313    }
1314}
1315
1316#[cfg(test)]
1317mod tests {
1318    use super::*;
1319
1320    /// #2100 slice 2: a `?` returns from the enclosing body unless it sits in
1321    /// a lambda, whose body is its own return scope.
1322    #[test]
1323    fn early_return_detection_stops_at_a_lambda() {
1324        let src = "commons c\n\nfn g(r: Result[Int, String]) -> Result[Int, String] {\n  let a = r? + 1\n  let f = (x: Result[Int, String]) => Ok(x? + 1)\n  f(r)\n}\n";
1325        let tokens = tokenize(src).unwrap();
1326        let (Some(SourceUnit::Commons(c)), _) = parse_unit_with_recovery(&tokens, src) else {
1327            panic!("parses");
1328        };
1329        let CommonsItem::Fn(f) = &c.items[0] else {
1330            panic!("a fn");
1331        };
1332        let value = |i: usize| match &f.body.statements[i] {
1333            Statement::Let(l) => &l.value,
1334            _ => panic!("a let"),
1335        };
1336        assert!(has_early_return(value(0)));
1337        assert!(!has_early_return(value(1)));
1338    }
1339
1340    fn actions_for(text: &str, needle: &str) -> Vec<CodeActionOrCommand> {
1341        let start = text.find(needle).expect("needle present");
1342        let requested = Span::new(start, start + needle.len());
1343        let uri = Url::parse("file:///a.bynk").unwrap();
1344        extract_variable(text, requested, &uri, Some(3))
1345    }
1346
1347    fn sole_edit(action: &CodeActionOrCommand) -> Vec<TextEdit> {
1348        let CodeActionOrCommand::CodeAction(a) = action else {
1349            panic!("expected a CodeAction");
1350        };
1351        assert_eq!(a.kind, Some(CodeActionKind::REFACTOR_EXTRACT));
1352        let Some(DocumentChanges::Edits(doc_edits)) = &a.edit.as_ref().unwrap().document_changes
1353        else {
1354            panic!("expected versioned document edits");
1355        };
1356        assert_eq!(doc_edits[0].text_document.version, Some(3));
1357        doc_edits[0]
1358            .edits
1359            .iter()
1360            .map(|e| {
1361                let OneOf::Left(edit) = e else {
1362                    panic!("expected a plain TextEdit")
1363                };
1364                edit.clone()
1365            })
1366            .collect()
1367    }
1368
1369    #[test]
1370    fn extracts_a_sub_expression_of_a_let_value() {
1371        let src = "context c\n\nfn f() -> Int {\n  let y = 1 + 2\n  y\n}\n";
1372        let actions = actions_for(src, "1 + 2");
1373        assert_eq!(actions.len(), 1);
1374        let edits = sole_edit(&actions[0]);
1375        assert_eq!(edits.len(), 2);
1376        assert!(edits[0].new_text.starts_with("let extracted = 1 + 2\n"));
1377        assert_eq!(edits[1].new_text, "extracted");
1378        // The insertion is a zero-length range right at the start of the
1379        // `let y = …` line — same line the replacement (later on that line)
1380        // sits on, since positions are computed against the original text.
1381        assert_eq!(edits[0].range.start, edits[0].range.end);
1382        assert_eq!(edits[0].range.start.line, edits[1].range.start.line);
1383        assert!(edits[0].range.start.character < edits[1].range.start.character);
1384    }
1385
1386    #[test]
1387    fn extracts_the_whole_tail_expression() {
1388        let src = "context c\n\nfn f() -> Int {\n  let x = 1\n  x + 1\n}\n";
1389        let actions = actions_for(src, "x + 1");
1390        let edits = sole_edit(&actions[0]);
1391        assert!(edits[0].new_text.starts_with("let extracted = x + 1\n"));
1392        assert_eq!(edits[1].new_text, "extracted");
1393    }
1394
1395    #[test]
1396    fn resets_the_insertion_point_inside_a_nested_if_block() {
1397        let src = concat!(
1398            "context c\n\n",
1399            "fn f(n: Int) -> Int {\n",
1400            "  if n > 0 {\n",
1401            "    let z = n * 2\n",
1402            "    z\n",
1403            "  } else {\n",
1404            "    0\n",
1405            "  }\n",
1406            "}\n",
1407        );
1408        let actions = actions_for(src, "n * 2");
1409        let edits = sole_edit(&actions[0]);
1410        // Inserted right above `let z = …` inside the `if` branch, not above
1411        // the whole `if` statement.
1412        let insert_line = edits[0].range.start.line;
1413        let let_z_line = crate::position::offset_to_position(src, src.find("let z").unwrap()).line;
1414        assert_eq!(insert_line, let_z_line);
1415    }
1416
1417    #[test]
1418    fn resets_the_insertion_point_inside_a_nested_match_arm_block() {
1419        let src = concat!(
1420            "context c\n\n",
1421            "fn f(n: Int) -> Int {\n",
1422            "  match n {\n",
1423            "    0 => {\n",
1424            "      let z = n * 2\n",
1425            "      z\n",
1426            "    }\n",
1427            "    _ => 0\n",
1428            "  }\n",
1429            "}\n",
1430        );
1431        let actions = actions_for(src, "n * 2");
1432        let edits = sole_edit(&actions[0]);
1433        // Inserted right above `let z = …` inside the arm's block, not above
1434        // the whole `match`.
1435        let insert_line = edits[0].range.start.line;
1436        let let_z_line = crate::position::offset_to_position(src, src.find("let z").unwrap()).line;
1437        assert_eq!(insert_line, let_z_line);
1438    }
1439
1440    /// #1819: the `let` goes above the enclosing statement, where an
1441    /// expression arm's pattern bindings and a lambda's parameters are not in
1442    /// scope, so a selection reading one is declined, not hoisted unbound.
1443    #[test]
1444    fn declines_a_selection_reading_an_arm_or_lambda_binding() {
1445        let arm = concat!(
1446            "context c\n\n",
1447            "fn f(o: Option[Int]) -> Int {\n",
1448            "  let a = match o {\n",
1449            "    Some(x) => x + 1\n",
1450            "    _ => 0\n",
1451            "  }\n",
1452            "  a\n",
1453            "}\n",
1454        );
1455        assert!(actions_for(arm, "x + 1").is_empty(), "arm binding hoisted");
1456        let lambda = concat!(
1457            "context c\n\n",
1458            "fn f(xs: List[Int]) -> List[Int] {\n",
1459            "  let ys = xs.map((v) => v * 2)\n",
1460            "  ys\n",
1461            "}\n",
1462        );
1463        assert!(
1464            actions_for(lambda, "v * 2").is_empty(),
1465            "lambda parameter hoisted"
1466        );
1467        // A guard extracts the whole `match` (#1800 review), never the guard
1468        // alone, so the `let` never reads the arm's binding out of scope.
1469        let guard = concat!(
1470            "context c\n\n",
1471            "fn f(o: Option[Int]) -> Int {\n",
1472            "  let a = match o {\n",
1473            "    Some(x) if x > 1 => 1\n",
1474            "    _ => 0\n",
1475            "  }\n",
1476            "  a\n",
1477            "}\n",
1478        );
1479        let guard_actions = actions_for(guard, "x > 1");
1480        assert_eq!(
1481            guard_actions.len(),
1482            1,
1483            "a guard selection extracts the match"
1484        );
1485        {
1486            let edits = sole_edit(&guard_actions[0]);
1487            assert!(
1488                edits[0].new_text.starts_with("let extracted = match o {"),
1489                "a guard selection must extract the whole match: {:?}",
1490                edits[0].new_text
1491            );
1492        }
1493    }
1494
1495    /// #1819's boundary: inside an arm or a lambda, a selection that reads no
1496    /// binding of theirs still extracts above the statement.
1497    #[test]
1498    fn still_extracts_inside_an_arm_or_lambda_when_no_binding_is_read() {
1499        let arm = concat!(
1500            "context c\n\n",
1501            "fn f(o: Option[Int], n: Int) -> Int {\n",
1502            "  let a = match o {\n",
1503            "    Some(x) => n + 1\n",
1504            "    _ => 0\n",
1505            "  }\n",
1506            "  a\n",
1507            "}\n",
1508        );
1509        let edits = sole_edit(&actions_for(arm, "n + 1")[0]);
1510        assert!(edits[0].new_text.starts_with("let extracted = n + 1\n"));
1511        let lambda = concat!(
1512            "context c\n\n",
1513            "fn f(xs: List[Int], k: Int) -> List[Int] {\n",
1514            "  let ys = xs.map((v) => k * 2)\n",
1515            "  ys\n",
1516            "}\n",
1517        );
1518        let edits = sole_edit(&actions_for(lambda, "k * 2")[0]);
1519        assert!(edits[0].new_text.starts_with("let extracted = k * 2\n"));
1520    }
1521
1522    /// #1880 review: the same scope rule for the other binders. An `is` on the
1523    /// left of `&&` / `implies` binds for the right operand only, and an
1524    /// observation's `with` predicate reads the observed operation's
1525    /// parameters, which the tree doesn't bind.
1526    #[test]
1527    fn declines_a_selection_reading_an_is_binding_or_an_observed_parameter() {
1528        let and = concat!(
1529            "context c\n\n",
1530            "fn f(r: Result[Int, String]) -> Bool {\n",
1531            "  let ok = r is Ok(n) && n > 0\n",
1532            "  ok\n",
1533            "}\n",
1534        );
1535        assert!(actions_for(and, "n > 0").is_empty(), "`&&` binding hoisted");
1536        let implies = concat!(
1537            "context c\n\n",
1538            "fn f(r: Result[Int, String]) -> Bool {\n",
1539            "  let ok = r is Ok(n) implies n > 0\n",
1540            "  ok\n",
1541            "}\n",
1542        );
1543        assert!(
1544            actions_for(implies, "n > 0").is_empty(),
1545            "`implies` binding hoisted"
1546        );
1547        let condition = concat!(
1548            "context c\n\n",
1549            "fn f(r: Result[Int, String]) -> Int {\n",
1550            "  if r is Ok(n) && n > 0 {\n",
1551            "    1\n",
1552            "  } else {\n",
1553            "    0\n",
1554            "  }\n",
1555            "}\n",
1556        );
1557        assert!(
1558            actions_for(condition, "n > 0").is_empty(),
1559            "`if` condition binding hoisted"
1560        );
1561        let observed = concat!(
1562            "suite c {\n",
1563            "  case \"logs\" {\n",
1564            "    expect Logger.log called with msg == \"start\"\n",
1565            "  }\n",
1566            "}\n",
1567        );
1568        assert!(
1569            actions_for(observed, "msg == \"start\"").is_empty(),
1570            "observed parameter hoisted"
1571        );
1572        // The left operand is outside the `is` scope, so it still extracts.
1573        let lhs = concat!(
1574            "context c\n\n",
1575            "fn f(r: Result[Int, String], k: Int) -> Bool {\n",
1576            "  let ok = k > 1 && r is Ok(n)\n",
1577            "  ok\n",
1578            "}\n",
1579        );
1580        let edits = sole_edit(&actions_for(lhs, "k > 1")[0]);
1581        assert!(edits[0].new_text.starts_with("let extracted = k > 1\n"));
1582    }
1583
1584    /// #1880 review: a block body puts the `let` inside the binding's scope,
1585    /// so a selection reading the binding still extracts there.
1586    #[test]
1587    fn still_extracts_a_binding_read_inside_a_block_body() {
1588        let arm = concat!(
1589            "context c\n\n",
1590            "fn f(o: Option[Int]) -> Int {\n",
1591            "  let a = match o {\n",
1592            "    Some(x) => {\n",
1593            "      let b = x + 1\n",
1594            "      b\n",
1595            "    }\n",
1596            "    _ => 0\n",
1597            "  }\n",
1598            "  a\n",
1599            "}\n",
1600        );
1601        let edits = sole_edit(&actions_for(arm, "x + 1")[0]);
1602        assert!(edits[0].new_text.starts_with("let extracted = x + 1\n"));
1603        let lambda = concat!(
1604            "context c\n\n",
1605            "fn f(xs: List[Int]) -> List[Int] {\n",
1606            "  let ys = xs.map((v) => {\n",
1607            "    let w = v * 2\n",
1608            "    w\n",
1609            "  })\n",
1610            "  ys\n",
1611            "}\n",
1612        );
1613        let edits = sole_edit(&actions_for(lambda, "v * 2")[0]);
1614        assert!(edits[0].new_text.starts_with("let extracted = v * 2\n"));
1615    }
1616
1617    #[test]
1618    fn selection_crossing_a_statement_boundary_offers_nothing() {
1619        let src = "context c\n\nfn f() -> Int {\n  let a = 1\n  let b = 2\n  a\n}\n";
1620        // Spans from inside `let a`'s value into the start of `let b` — no
1621        // single statement/tail value fully contains it.
1622        let actions = actions_for(src, "1\n  let b");
1623        assert!(actions.is_empty());
1624    }
1625
1626    #[test]
1627    fn selection_of_the_whole_block_braces_included_offers_nothing() {
1628        let src = "context c\n\nfn f() -> Int {\n  let a = 1\n  a\n}\n";
1629        // The full body span, braces included — larger than any single
1630        // statement/tail value, so no candidate node covers it.
1631        let actions = actions_for(src, "{\n  let a = 1\n  a\n}");
1632        assert!(actions.is_empty());
1633    }
1634
1635    #[test]
1636    fn extracts_a_call_argument() {
1637        let src = "context c\n\nfn f() -> Int {\n  g(1 + 2, 3)\n}\n";
1638        let actions = actions_for(src, "1 + 2");
1639        let edits = sole_edit(&actions[0]);
1640        assert_eq!(edits[1].new_text, "extracted");
1641        assert!(edits[0].new_text.contains("let extracted = 1 + 2"));
1642    }
1643
1644    #[test]
1645    fn bumps_the_suffix_on_a_name_collision() {
1646        let src = "context c\n\nfn f() -> Int {\n  let extracted = 0\n  1 + 2\n}\n";
1647        let actions = actions_for(src, "1 + 2");
1648        let edits = sole_edit(&actions[0]);
1649        assert!(edits[0].new_text.starts_with("let extracted2 = 1 + 2\n"));
1650        assert_eq!(edits[1].new_text, "extracted2");
1651    }
1652
1653    #[test]
1654    fn empty_selection_offers_nothing() {
1655        let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1656        let off = src.find("1 + 2").unwrap();
1657        let uri = Url::parse("file:///a.bynk").unwrap();
1658        let actions = extract_variable(src, Span::new(off, off), &uri, Some(1));
1659        assert!(actions.is_empty());
1660    }
1661
1662    #[test]
1663    fn selection_outside_any_body_offers_nothing() {
1664        let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1665        let actions = actions_for(src, "context c");
1666        assert!(actions.is_empty());
1667    }
1668
1669    #[test]
1670    fn preserves_indentation_on_a_multi_statement_block() {
1671        let src = "context c\n\nfn f() -> Int {\n    let a = 1\n    let b = a + 2\n    b\n}\n";
1672        let actions = actions_for(src, "a + 2");
1673        let edits = sole_edit(&actions[0]);
1674        assert_eq!(edits[0].new_text, "let extracted = a + 2\n    ");
1675    }
1676
1677    mod extract_function_tests {
1678        use super::*;
1679        use bynk_check::locals::LocalKind;
1680        use bynk_check::requirements::RequirementSource;
1681
1682        fn function_actions_for(
1683            text: &str,
1684            needle: &str,
1685            requirements: &[Requirement],
1686            locals: &[LocalBinding],
1687            expr_types: &[(Span, TyId)],
1688        ) -> Vec<CodeActionOrCommand> {
1689            let start = text.find(needle).expect("needle present");
1690            let requested = Span::new(start, start + needle.len());
1691            let uri = Url::parse("file:///a.bynk").unwrap();
1692            extract_function(
1693                text,
1694                requested,
1695                &uri,
1696                Some(3),
1697                requirements,
1698                locals,
1699                expr_types,
1700                &TYS,
1701            )
1702        }
1703
1704        fn nth_offset(text: &str, needle: &str, n: usize) -> usize {
1705            text.match_indices(needle)
1706                .nth(n)
1707                .expect("occurrence present")
1708                .0
1709        }
1710
1711        // Permissive (whole-file) scope: these tests exercise extract_function's
1712        // own gating/free-variable logic, not locals_at's scope resolution
1713        // (already covered in bynk-check::locals's own tests).
1714        fn param(text: &str, name: &str, ty: &str) -> LocalBinding {
1715            let def_start = nth_offset(text, name, 0);
1716            LocalBinding {
1717                name: name.to_string(),
1718                def_span: Span::new(def_start, def_start + name.len()),
1719                kind: LocalKind::Param,
1720                ty: ty.to_string(),
1721                scope: Span::new(0, text.len()),
1722            }
1723        }
1724
1725        use bynk_check::checker::Ty;
1726
1727        /// One table for every fixture in this module — `Types` is
1728        /// `Send + Sync` (T3.6b), so a `LazyLock` static is the least
1729        /// ceremonious way to give the `TyId`s below something to resolve
1730        /// against.
1731        static TYS: std::sync::LazyLock<Types> = std::sync::LazyLock::new(Types::new);
1732
1733        fn int_type(text: &str, needle: &str) -> (Span, TyId) {
1734            let start = text.find(needle).expect("needle present");
1735            (
1736                Span::new(start, start + needle.len()),
1737                TYS.intern(Ty::Base(BaseType::Int)),
1738            )
1739        }
1740
1741        /// A `let`-bound local — its def site is `name`'s first occurrence,
1742        /// same convention as [`param`].
1743        fn let_binding(text: &str, name: &str, ty: &str) -> LocalBinding {
1744            let def_start = nth_offset(text, name, 0);
1745            LocalBinding {
1746                name: name.to_string(),
1747                def_span: Span::new(def_start, def_start + name.len()),
1748                kind: LocalKind::Let,
1749                ty: ty.to_string(),
1750                scope: Span::new(0, text.len()),
1751            }
1752        }
1753
1754        fn capability_use(site_needle_offset: usize, len: usize) -> Requirement {
1755            Requirement {
1756                capability: "Clock".to_string(),
1757                site: Span::new(site_needle_offset, site_needle_offset + len),
1758                source: RequirementSource::DirectCall {
1759                    op: "now".to_string(),
1760                },
1761                covered: false,
1762                materialize: None,
1763            }
1764        }
1765
1766        #[test]
1767        fn extracts_a_free_variable_as_a_parameter() {
1768            let src = "context c\n\nfn f(num: Int) -> Int {\n  num * 2\n}\n";
1769            let locals = vec![param(src, "num", "Int")];
1770            let types = vec![int_type(src, "num * 2")];
1771            let actions = function_actions_for(src, "num * 2", &[], &locals, &types);
1772            assert_eq!(actions.len(), 1);
1773            let edits = sole_edit(&actions[0]);
1774            assert_eq!(
1775                edits[0].new_text,
1776                "fn extractedFn(num: Int) -> Int {\n  num * 2\n}\n\n"
1777            );
1778            // Inserted right above the enclosing `fn f`, not at the selection.
1779            assert_eq!(edits[0].range.start, Position::new(2, 0));
1780            assert_eq!(edits[1].new_text, "extractedFn(num)");
1781        }
1782
1783        /// #1760: a name read only in a match-arm guard is still free in the
1784        /// selection, so it's threaded as a parameter. `expr_children` didn't
1785        /// visit guards, so `lim` was dropped and the extracted `fn` read an
1786        /// unbound name.
1787        #[test]
1788        fn a_name_read_only_in_a_guard_is_a_parameter() {
1789            let src = "context c\n\nfn f(num: Int, lim: Int) -> Int {\n  match num {\n    n if n > lim => 1\n    _ => 0\n  }\n}\n";
1790            let selection = "match num {\n    n if n > lim => 1\n    _ => 0\n  }";
1791            let locals = vec![param(src, "num", "Int"), param(src, "lim", "Int")];
1792            let types = vec![int_type(src, selection)];
1793            let actions = function_actions_for(src, selection, &[], &locals, &types);
1794            assert_eq!(actions.len(), 1);
1795            let edits = sole_edit(&actions[0]);
1796            assert!(
1797                edits[0]
1798                    .new_text
1799                    .starts_with("fn extractedFn(num: Int, lim: Int) -> Int {"),
1800                "{}",
1801                edits[0].new_text
1802            );
1803            assert_eq!(edits[1].new_text, "extractedFn(num, lim)");
1804        }
1805
1806        #[test]
1807        fn no_free_variables_yields_a_nullary_call() {
1808            let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1809            let types = vec![int_type(src, "1 + 2")];
1810            let actions = function_actions_for(src, "1 + 2", &[], &[], &types);
1811            let edits = sole_edit(&actions[0]);
1812            assert_eq!(
1813                edits[0].new_text,
1814                "fn extractedFn() -> Int {\n  1 + 2\n}\n\n"
1815            );
1816            assert_eq!(edits[1].new_text, "extractedFn()");
1817        }
1818
1819        #[test]
1820        fn capability_using_selection_offers_nothing() {
1821            let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1822            let site = src.find("1 + 2").unwrap();
1823            let reqs = vec![capability_use(site, "1 + 2".len())];
1824            let types = vec![int_type(src, "1 + 2")];
1825            let actions = function_actions_for(src, "1 + 2", &reqs, &[], &types);
1826            assert!(actions.is_empty());
1827        }
1828
1829        #[test]
1830        fn bumps_the_suffix_on_a_name_collision() {
1831            let src =
1832                "context c\n\nfn extractedFn() -> Int {\n  0\n}\n\nfn f() -> Int {\n  1 + 2\n}\n";
1833            let types = vec![int_type(src, "1 + 2")];
1834            let actions = function_actions_for(src, "1 + 2", &[], &[], &types);
1835            let edits = sole_edit(&actions[0]);
1836            assert!(edits[0].new_text.starts_with("fn extractedFn2("));
1837            assert_eq!(edits[1].new_text, "extractedFn2()");
1838        }
1839
1840        #[test]
1841        fn selection_outside_any_body_offers_nothing() {
1842            let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1843            let types = vec![int_type(src, "1 + 2")];
1844            let actions = function_actions_for(src, "context c", &[], &[], &types);
1845            assert!(actions.is_empty());
1846        }
1847
1848        #[test]
1849        fn adapter_files_offer_nothing() {
1850            // An `Adapter` carries no Bynk bodies — `find_function_site` (like
1851            // `find_site`) short-circuits to `None` regardless of the selection.
1852            let src = "adapter tokens {\n  binding \"./b.ts\"\n}\n";
1853            let actions = function_actions_for(src, "binding", &[], &[], &[]);
1854            assert!(actions.is_empty());
1855        }
1856
1857        #[test]
1858        fn suite_test_cases_offer_nothing() {
1859            // Unlike extract-variable (which can extract a `let` inside a test
1860            // case), extract-function needs a top-level item to insert the new
1861            // `fn` above — a `Suite` case has none, so this declines even where
1862            // extract-variable would offer something.
1863            let src = "suite thing\n\ncase \"it works\" {\n  1 + 2\n}\n";
1864            let types = vec![int_type(src, "1 + 2")];
1865            let actions = function_actions_for(src, "1 + 2", &[], &[], &types);
1866            assert!(actions.is_empty());
1867        }
1868
1869        #[test]
1870        fn a_dirty_file_with_no_recorded_type_offers_nothing() {
1871            let src = "context c\n\nfn f() -> Int {\n  1 + 2\n}\n";
1872            // No expr_types entry for the selection — an expression the
1873            // checker never typed.
1874            let actions = function_actions_for(src, "1 + 2", &[], &[], &[]);
1875            assert!(actions.is_empty());
1876        }
1877
1878        #[test]
1879        fn ambiguous_same_name_shadow_from_two_outer_scopes_declines() {
1880            let src = "context c\n\nfn f() -> Int {\n  x + x\n}\n";
1881            let types = vec![int_type(src, "x + x")];
1882            let first_x = nth_offset(src, "x + x", 0); // the first `x`'s offset
1883            let second_x = first_x + "x + ".len(); // the second `x`'s offset
1884            // Two distinct external bindings named `x`, each in scope only at
1885            // one of the two occurrences — an occurrence-position-dependent
1886            // resolution a real program could never actually produce, used
1887            // here purely to force the ambiguous-shadow branch.
1888            let locals = vec![
1889                LocalBinding {
1890                    name: "x".to_string(),
1891                    def_span: Span::new(0, 1),
1892                    kind: LocalKind::Let,
1893                    ty: "Int".to_string(),
1894                    scope: Span::new(first_x, first_x),
1895                },
1896                LocalBinding {
1897                    name: "x".to_string(),
1898                    def_span: Span::new(1, 2),
1899                    kind: LocalKind::Let,
1900                    ty: "Int".to_string(),
1901                    scope: Span::new(second_x, second_x),
1902                },
1903            ];
1904            let actions = function_actions_for(src, "x + x", &[], &locals, &types);
1905            assert!(actions.is_empty());
1906        }
1907
1908        mod multi_statement_tests {
1909            use super::*;
1910
1911            #[test]
1912            fn a_run_including_the_tail_becomes_a_call_expression() {
1913                let src = "context c\n\nfn f(num: Int) -> Int {\n  let a = num + 1\n  let b = a * 2\n  b + num\n}\n";
1914                let locals = vec![
1915                    param(src, "num", "Int"),
1916                    let_binding(src, "a", "Int"),
1917                    let_binding(src, "b", "Int"),
1918                ];
1919                let types = vec![int_type(src, "b + num")];
1920                let needle = "let a = num + 1\n  let b = a * 2\n  b + num";
1921                let actions = function_actions_for(src, needle, &[], &locals, &types);
1922                assert_eq!(actions.len(), 1);
1923                let edits = sole_edit(&actions[0]);
1924                assert_eq!(
1925                    edits[0].new_text,
1926                    format!("fn extractedFn(num: Int) -> Int {{\n  {needle}\n}}\n\n")
1927                );
1928                assert_eq!(edits[1].new_text, "extractedFn(num)");
1929            }
1930
1931            #[test]
1932            fn a_run_excluding_the_tail_yields_a_discard_call() {
1933                let src = "context c\n\nfn f(num: Int) -> Int {\n  let a = num + 1\n  let valC = a * 2\n  num\n}\n";
1934                let locals = vec![
1935                    param(src, "num", "Int"),
1936                    let_binding(src, "a", "Int"),
1937                    let_binding(src, "valC", "Int"),
1938                ];
1939                let needle = "let a = num + 1\n  let valC = a * 2";
1940                let actions = function_actions_for(src, needle, &[], &locals, &[]);
1941                assert_eq!(actions.len(), 1);
1942                let edits = sole_edit(&actions[0]);
1943                assert_eq!(
1944                    edits[0].new_text,
1945                    format!("fn extractedFn(num: Int) -> () {{\n  {needle}\n}}\n\n")
1946                );
1947                assert_eq!(edits[1].new_text, "let _ = extractedFn(num)");
1948            }
1949
1950            #[test]
1951            fn an_effectful_run_excluding_the_tail_yields_an_effect_return_and_a_do_call() {
1952                // `do` is only legal in an effectful body semantically (a
1953                // checker rule), but the parser accepts it regardless of the
1954                // enclosing signature — extract_function does no
1955                // type-checking of its own, so this exercises the
1956                // return-type/call-site choice in isolation.
1957                let src = "context c\n\nfn f() -> Effect[()] {\n  do g()\n  do h()\n  ()\n}\n";
1958                let needle = "do g()\n  do h()";
1959                let actions = function_actions_for(src, needle, &[], &[], &[]);
1960                assert_eq!(actions.len(), 1);
1961                let edits = sole_edit(&actions[0]);
1962                assert_eq!(
1963                    edits[0].new_text,
1964                    format!("fn extractedFn() -> Effect[()] {{\n  {needle}\n}}\n\n")
1965                );
1966                assert_eq!(edits[1].new_text, "do extractedFn()");
1967            }
1968
1969            #[test]
1970            fn a_run_whose_match_guard_binds_an_effect_yields_an_effect_return() {
1971                // #1800 review: the `<-` sits only in an arm's guard, so
1972                // `stmts_contain_effect_stmt` must search guards for the lifted
1973                // `fn` to return `Effect[()]` and the call site to be `do`.
1974                let src = concat!(
1975                    "context c\n\n",
1976                    "fn f(o: Option[Int]) -> Effect[()] {\n",
1977                    "  let a = match o {\n",
1978                    "    Some(n) if if n > 0 {\n",
1979                    "      let _ <- g()\n",
1980                    "      true\n",
1981                    "    } else {\n",
1982                    "      false\n",
1983                    "    } => n\n",
1984                    "    _ => 0\n",
1985                    "  }\n",
1986                    "  ()\n",
1987                    "}\n",
1988                );
1989                let needle = "let a = match o {\n    Some(n) if if n > 0 {\n      let _ <- g()\n      true\n    } else {\n      false\n    } => n\n    _ => 0\n  }";
1990                let actions = function_actions_for(src, needle, &[], &[], &[]);
1991                assert_eq!(actions.len(), 1);
1992                let edits = sole_edit(&actions[0]);
1993                assert!(
1994                    edits[0]
1995                        .new_text
1996                        .starts_with("fn extractedFn() -> Effect[()] {"),
1997                    "{}",
1998                    edits[0].new_text
1999                );
2000                assert_eq!(edits[1].new_text, "do extractedFn()");
2001            }
2002
2003            #[test]
2004            fn a_binding_the_run_introduces_still_used_in_the_tail_declines() {
2005                let src = "context c\n\nfn f(num: Int) -> Int {\n  let a = num + 1\n  let b = a * 2\n  b\n}\n";
2006                let locals = vec![
2007                    param(src, "num", "Int"),
2008                    let_binding(src, "a", "Int"),
2009                    let_binding(src, "b", "Int"),
2010                ];
2011                // Excludes the tail `b`, which reads the run's own `let b` —
2012                // lifting the run away would strand that reference.
2013                let needle = "let a = num + 1\n  let b = a * 2";
2014                let actions = function_actions_for(src, needle, &[], &locals, &[]);
2015                assert!(actions.is_empty());
2016            }
2017
2018            #[test]
2019            fn a_run_containing_a_cell_write_declines() {
2020                // A lifted top-level `fn` has no `store` fields — a `:=`
2021                // inside the run would always fail to resolve its target.
2022                let src = "context c\n\nfn f() -> Int {\n  cell := 1\n  x\n}\n";
2023                let actions = function_actions_for(src, "cell := 1", &[], &[], &[]);
2024                assert!(actions.is_empty());
2025            }
2026
2027            #[test]
2028            fn a_run_containing_a_nested_cell_write_declines() {
2029                // The `:=` is nested inside an `if`-branch within the run's
2030                // own `let`, not a direct top-level statement in the run —
2031                // `stmts_contain_assign_stmt` must recurse to catch it, the
2032                // same way `stmts_contain_effect_stmt` already does for
2033                // `~>`/`do`/`<-`.
2034                let src = concat!(
2035                    "context c\n\n",
2036                    "fn f(cond: Bool) -> Int {\n",
2037                    "  let a = if cond {\n",
2038                    "    cell := 1\n",
2039                    "    0\n",
2040                    "  } else {\n",
2041                    "    1\n",
2042                    "  }\n",
2043                    "  a\n",
2044                    "}\n",
2045                );
2046                let needle = "let a = if cond {\n    cell := 1\n    0\n  } else {\n    1\n  }";
2047                let actions = function_actions_for(src, needle, &[], &[], &[]);
2048                assert!(actions.is_empty());
2049            }
2050
2051            #[test]
2052            fn a_run_containing_a_cell_write_in_a_match_guard_declines() {
2053                // #1800: the `:=` sits in an arm's guard, which
2054                // `stmts_contain_assign_stmt` must search like an arm body.
2055                let src = concat!(
2056                    "context c\n\n",
2057                    "fn f(o: Option[Int]) -> Int {\n",
2058                    "  let a = match o {\n",
2059                    "    Some(x) if if x > 0 {\n",
2060                    "      cell := x\n",
2061                    "      true\n",
2062                    "    } else {\n",
2063                    "      false\n",
2064                    "    } => x\n",
2065                    "    _ => 0\n",
2066                    "  }\n",
2067                    "  a\n",
2068                    "}\n",
2069                );
2070                let needle = "let a = match o {\n    Some(x) if if x > 0 {\n      cell := x\n      true\n    } else {\n      false\n    } => x\n    _ => 0\n  }";
2071                let actions = function_actions_for(src, needle, &[], &[], &[]);
2072                assert!(actions.is_empty());
2073            }
2074
2075            #[test]
2076            fn a_run_found_inside_a_match_guard() {
2077                // #1800: the run sits in the `then` block of a guard's `if`.
2078                // The guard's tail reads only `num`, so lifting the run
2079                // strands nothing.
2080                let src = concat!(
2081                    "context c\n\n",
2082                    "fn f(o: Option[Int]) -> Int {\n",
2083                    "  match o {\n",
2084                    "    Some(num) if if num > 0 {\n",
2085                    "      let valA = num * 2\n",
2086                    "      let valB = valA + 1\n",
2087                    "      num > 3\n",
2088                    "    } else {\n",
2089                    "      false\n",
2090                    "    } => num\n",
2091                    "    _ => 0\n",
2092                    "  }\n",
2093                    "}\n",
2094                );
2095                let locals = vec![
2096                    param(src, "num", "Int"),
2097                    let_binding(src, "valA", "Int"),
2098                    let_binding(src, "valB", "Int"),
2099                ];
2100                let needle = "let valA = num * 2\n      let valB = valA + 1";
2101                let actions = function_actions_for(src, needle, &[], &locals, &[]);
2102                assert_eq!(actions.len(), 1, "a run in a guard is extractable");
2103            }
2104
2105            #[test]
2106            fn a_run_found_inside_a_nested_if_branch() {
2107                let src = concat!(
2108                    "context c\n\n",
2109                    "fn f(num: Int) -> Int {\n",
2110                    "  if num > 0 {\n",
2111                    "    let valA = num * 2\n",
2112                    "    let valB = valA + 1\n",
2113                    "    valB\n",
2114                    "  } else {\n",
2115                    "    0\n",
2116                    "  }\n",
2117                    "}\n",
2118                );
2119                let locals = vec![
2120                    param(src, "num", "Int"),
2121                    let_binding(src, "valA", "Int"),
2122                    let_binding(src, "valB", "Int"),
2123                ];
2124                let tail_val_b = nth_offset(src, "valB", 1); // skip `let valB`'s own def
2125                let types = vec![(
2126                    Span::new(tail_val_b, tail_val_b + "valB".len()),
2127                    TYS.intern(Ty::Base(BaseType::Int)),
2128                )];
2129                let needle = "let valA = num * 2\n    let valB = valA + 1\n    valB";
2130                let actions = function_actions_for(src, needle, &[], &locals, &types);
2131                assert_eq!(actions.len(), 1);
2132                let edits = sole_edit(&actions[0]);
2133                assert_eq!(
2134                    edits[0].new_text,
2135                    format!("fn extractedFn(num: Int) -> Int {{\n  {needle}\n}}\n\n")
2136                );
2137                // Inserted above the enclosing `fn f`, not inside the `if`.
2138                assert_eq!(edits[0].range.start, Position::new(2, 0));
2139                assert_eq!(edits[1].new_text, "extractedFn(num)");
2140            }
2141
2142            /// #2010: a `let … else` line is the hidden binding that heads the
2143            /// rest of its block, so neither refactoring lifts it — alone or
2144            /// with what follows — while its value is still extractable.
2145            #[test]
2146            fn a_let_else_statement_is_not_extracted() {
2147                let src = "context c\n\nfn f(r: Result[Int, String]) -> Int {\n  let Ok(n) = load(r) else { 0 }\n  let m = n + 1\n  m\n}\n";
2148                let uri = Url::parse("file:///a.bynk").unwrap();
2149                let locals = vec![param(src, "r", "Result[Int, String]")];
2150                for sel in [
2151                    "let Ok(n) = load(r) else { 0 }",
2152                    "let Ok(n) = load(r) else { 0 }\n  let m = n + 1",
2153                    "let Ok(n) = load(r) else { 0 }\n  let m = n + 1\n  m",
2154                ] {
2155                    let start = src.find(sel).unwrap();
2156                    let requested = Span::new(start, start + sel.len());
2157                    assert!(
2158                        extract_function(src, requested, &uri, Some(3), &[], &locals, &[], &TYS)
2159                            .is_empty(),
2160                        "extract function must decline {sel:?}"
2161                    );
2162                    assert!(
2163                        extract_variable(src, requested, &uri, Some(3)).is_empty(),
2164                        "extract variable must decline {sel:?}"
2165                    );
2166                }
2167                let start = src.find("load(r)").unwrap();
2168                let requested = Span::new(start, start + "load(r)".len());
2169                let actions = extract_variable(src, requested, &uri, Some(3));
2170                assert_eq!(actions.len(), 1, "the value is the author's expression");
2171                let edits = sole_edit(&actions[0]);
2172                assert!(
2173                    edits
2174                        .iter()
2175                        .any(|e| e.new_text.starts_with("let extracted = load(r)")),
2176                    "{edits:?}"
2177                );
2178            }
2179
2180            /// #2010: statements after a `let … else` live in its pattern's arm
2181            /// and are an ordinary run there.
2182            #[test]
2183            fn statements_after_a_let_else_still_extract() {
2184                let src = "context c\n\nfn f(r: Result[Int, String]) -> Int {\n  let Ok(n) = load(r) else { 0 }\n  let m = n + 1\n  let k = m * 2\n  k\n}\n";
2185                let uri = Url::parse("file:///a.bynk").unwrap();
2186                let sel = "let m = n + 1\n  let k = m * 2\n  k";
2187                let start = src.find(sel).unwrap();
2188                let requested = Span::new(start, start + sel.len());
2189                let locals = vec![
2190                    param(src, "r", "Result[Int, String]"),
2191                    let_binding(src, "m", "Int"),
2192                    let_binding(src, "k", "Int"),
2193                    LocalBinding {
2194                        name: "n".to_string(),
2195                        def_span: Span::new(nth_offset(src, "n)", 0), nth_offset(src, "n)", 0) + 1),
2196                        kind: LocalKind::Let,
2197                        ty: "Int".to_string(),
2198                        scope: Span::new(0, src.len()),
2199                    },
2200                ];
2201                // The run includes the tail, whose type becomes the lifted
2202                // `fn`'s return type.
2203                let tail = src.find("  k\n}").unwrap() + 2;
2204                let types = vec![(
2205                    Span::new(tail, tail + 1),
2206                    TYS.intern(Ty::Base(BaseType::Int)),
2207                )];
2208                let actions =
2209                    extract_function(src, requested, &uri, Some(3), &[], &locals, &types, &TYS);
2210                assert_eq!(actions.len(), 1, "{actions:#?}");
2211            }
2212
2213            #[test]
2214            fn a_selection_extending_past_a_statement_boundary_declines() {
2215                let src = "context c\n\nfn f(num: Int) -> Int {\n  let a = num + 1\n  let valC = a * 2\n  num\n}\n";
2216                let start = src.find("let a = num + 1").unwrap();
2217                // One byte short of a full second statement — doesn't align
2218                // with any statement/tail boundary in either direction, so
2219                // neither the multi-statement nor the single-expression
2220                // algorithm matches.
2221                let end = start + "let a = num + 1\n  let valC = a * 2".len() - 1;
2222                let requested = Span::new(start, end);
2223                let uri = Url::parse("file:///a.bynk").unwrap();
2224                let actions = extract_function(src, requested, &uri, Some(3), &[], &[], &[], &TYS);
2225                assert!(actions.is_empty());
2226            }
2227
2228            #[test]
2229            fn a_selection_padded_with_surrounding_whitespace_still_aligns() {
2230                let src = "context c\n\nfn f(num: Int) -> Int {\n  let a = num + 1\n  let valC = a * 2\n  num\n}\n";
2231                let inner = "let a = num + 1\n  let valC = a * 2";
2232                let start = src.find(inner).unwrap();
2233                // Pads onto the leading two-space indent and the trailing
2234                // newline — a common "select whole lines" editor gesture.
2235                let requested = Span::new(start - 2, start + inner.len() + 1);
2236                let locals = vec![
2237                    param(src, "num", "Int"),
2238                    let_binding(src, "a", "Int"),
2239                    let_binding(src, "valC", "Int"),
2240                ];
2241                let uri = Url::parse("file:///a.bynk").unwrap();
2242                let actions =
2243                    extract_function(src, requested, &uri, Some(3), &[], &locals, &[], &TYS);
2244                assert_eq!(actions.len(), 1);
2245                let edits = sole_edit(&actions[0]);
2246                assert_eq!(edits[1].new_text, "let _ = extractedFn(num)");
2247            }
2248        }
2249
2250        /// Fixtures run through the real analysis rather than hand-built
2251        /// tables, so `expr_types`/`locals` carry exactly what the
2252        /// `code_action` handler sees — real `FileId`s on every span, and
2253        /// (ADR 0094) best-effort partial types for a file with errors, where
2254        /// an expression whose typing failed is recorded as `Ty::Error`.
2255        mod analysed_fixtures {
2256            use super::*;
2257            use std::path::Path;
2258            use std::sync::Arc;
2259
2260            /// `src`'s `(expr_types, locals, tys)` as the single-file context
2261            /// `c.bynk` of a fresh on-disk project, asserting the file's
2262            /// diagnostic categories are exactly `expected`.
2263            fn analysed(
2264                test_name: &str,
2265                src: &str,
2266                expected: &[&str],
2267            ) -> (Vec<(Span, TyId)>, Vec<LocalBinding>, Arc<Types>) {
2268                let root = std::env::temp_dir().join(format!(
2269                    "bynk-lsp-extract-{test_name}-{}",
2270                    std::process::id()
2271                ));
2272                let _ = std::fs::remove_dir_all(&root);
2273                std::fs::create_dir_all(&root).expect("create test root");
2274                std::fs::write(root.join("c.bynk"), src).expect("write file");
2275                let root = root.canonicalize().expect("canonical root");
2276                let overlay = HashMap::from([(root.join("c.bynk"), src.to_string())]);
2277                let result = bynk_ide::diagnose_project(&root, &overlay);
2278                let _ = std::fs::remove_dir_all(&root);
2279                let rel = Path::new("c.bynk");
2280                let categories: Vec<&str> = result
2281                    .files
2282                    .iter()
2283                    .filter(|f| f.source_path == rel)
2284                    .flat_map(|f| f.diagnostics.iter().map(|d| d.error.category))
2285                    .collect();
2286                assert_eq!(categories, expected);
2287                (
2288                    result.expr_types.get(rel).cloned().unwrap_or_default(),
2289                    result.locals.get(rel).cloned().unwrap_or_default(),
2290                    Arc::clone(&result.ty_intern),
2291                )
2292            }
2293
2294            /// The new `fn` each offered action would insert.
2295            fn offered(test_name: &str, src: &str, needle: &str, expected: &[&str]) -> Vec<String> {
2296                let (types, locals, tys) = analysed(test_name, src, expected);
2297                let start = src.find(needle).expect("needle present");
2298                let uri = Url::parse("file:///c.bynk").unwrap();
2299                extract_function(
2300                    src,
2301                    Span::new(start, start + needle.len()),
2302                    &uri,
2303                    Some(3),
2304                    &[],
2305                    &locals,
2306                    &types,
2307                    &tys,
2308                )
2309                .iter()
2310                .map(|a| sole_edit(a)[0].new_text.clone())
2311                .collect()
2312            }
2313
2314            /// The handler reparses the buffer (spans with no file identity)
2315            /// but the analysis's spans carry the file's real `FileId` (T3.5),
2316            /// so matching a recorded type must compare positions only — an
2317            /// exact `Span` match never found one, and the action was never
2318            /// offered against real analysis output.
2319            #[test]
2320            fn a_clean_file_offers_the_action() {
2321                let src = "context c\n\nfn f(num: Int) -> Int {\n  num * 2\n}\n";
2322                assert_eq!(
2323                    offered("clean", src, "num * 2", &[]),
2324                    ["fn extractedFn(num: Int) -> Int {\n  num * 2\n}\n\n"]
2325                );
2326            }
2327
2328            /// The selection itself failed to type: its recorded type is
2329            /// `Ty::Error`, which must decline, not render `-> <type error>`.
2330            #[test]
2331            fn an_error_typed_selection_declines() {
2332                let src = "context c\n\nfn f(num: Int) -> Int {\n  num + \"s\"\n}\n";
2333                let offered = offered(
2334                    "error-typed-selection",
2335                    src,
2336                    "num + \"s\"",
2337                    &["bynk.types.type_mismatch"],
2338                );
2339                assert!(offered.is_empty(), "expected no action; got {offered:?}");
2340            }
2341
2342            /// A well-typed selection that reads an error-typed binding: the
2343            /// checker records no local for a `let` whose value failed to
2344            /// type, so `z` would not be threaded as a parameter and the
2345            /// lifted `fn` would read an unbound name.
2346            #[test]
2347            fn a_selection_reading_an_error_typed_binding_declines() {
2348                let src = "context c\n\nfn f(num: Int) -> Int {\n  let z = num + \"s\"\n  let w = if num > 0 {\n    let q = z\n    1\n  } else {\n    2\n  }\n  w\n}\n";
2349                let offered = offered(
2350                    "error-typed-binding",
2351                    src,
2352                    "if num > 0 {\n    let q = z\n    1\n  } else {\n    2\n  }",
2353                    &["bynk.types.type_mismatch"],
2354                );
2355                assert!(offered.is_empty(), "expected no action; got {offered:?}");
2356            }
2357
2358            /// A tail-less statement run synthesises its `()` return type and
2359            /// never looks one up, so the `Ty::Error` gate is its only guard:
2360            /// `z`'s `let` is unrecorded in `locals`, so without the gate the
2361            /// run would lift with `z` unbound.
2362            #[test]
2363            fn a_statement_run_reading_an_error_typed_binding_declines() {
2364                let src = "context c\n\nfn f(num: Int) -> Int {\n  let z = num + \"s\"\n  let q = z\n  num\n}\n";
2365                let offered = offered(
2366                    "error-typed-stmts",
2367                    src,
2368                    "let q = z",
2369                    &["bynk.types.type_mismatch"],
2370                );
2371                assert!(offered.is_empty(), "expected no action; got {offered:?}");
2372            }
2373
2374            /// An error elsewhere in the file doesn't block a selection that
2375            /// typed cleanly (ADR 0094's partial map).
2376            #[test]
2377            fn an_error_elsewhere_still_offers_the_action() {
2378                let src = "context c\n\nfn f(num: Int) -> Int {\n  num * 2\n}\n\nfn g(num: Int) -> Int {\n  num + \"s\"\n}\n";
2379                assert_eq!(
2380                    offered(
2381                        "error-elsewhere",
2382                        src,
2383                        "num * 2",
2384                        &["bynk.types.type_mismatch"]
2385                    ),
2386                    ["fn extractedFn(num: Int) -> Int {\n  num * 2\n}\n\n"]
2387                );
2388            }
2389        }
2390    }
2391
2392    #[test]
2393    fn extracts_inside_the_value_of_a_let_with_a_call_site_principal() {
2394        // #1766 put a principal's identity first in `statement_exprs`; the
2395        // value is the statement's last expression, not its first.
2396        let src = "suite demo.s {\n  case \"c\" {\n    let who = \"alice\"\n    let item <- api.get(1 + 2) by User(who)\n    expect item\n  }\n}\n";
2397        assert_eq!(actions_for(src, "1 + 2").len(), 1);
2398    }
2399}