Skip to main content

bynk_lsp/
inline.rs

1//! #2100 slice 1: the inline-variable refactor (`refactor.inline`). It
2//! replaces every use of a `let` binding with the binding's initialiser and
3//! deletes the `let`.
4//!
5//! Only a pure `let name = value` is inlined. A `let name <- value` performs an
6//! effect where it stands, so moving or repeating it would change what the
7//! program does: it is never offered. A `let =` initialiser has no effects (in
8//! Bynk an effect needs `<-`), so repeating it at several uses keeps the
9//! program's meaning (DECISION B's re-check still runs).
10//!
11//! The action is offered with the cursor on the binding's name or on any use.
12//! It declines when:
13//! - the binding is `_`, a `let … else` desugaring, or has no uses;
14//! - a name the initialiser reads would resolve to a different binding at a
15//!   use (an inner `let` or parameter shadowing it). The re-check can't see
16//!   that, because the program would still type-check;
17//! - the `let` has a type annotation (Bynk has no ascription form to carry
18//!   it, and dropping it could change the inferred type silently);
19//! - a use sits somewhere the walk doesn't recognise (a record field label
20//!   spelled the same is not a use);
21//! - the `let` shares its lines with other code, or carries a comment
22//!   (deleting it would drop or orphan the comment).
23//!
24//! A use as a record shorthand (`T { name }`) becomes `T { name: value }`. A
25//! non-atomic initialiser is parenthesised at each use. Like fill-arms the
26//! action rides the unfiltered lightbulb request: it is listed with no edit,
27//! and `codeAction/resolve` computes the edit and re-checks it.
28
29use bynk_check::locals::{LocalBinding, locals_at};
30use bynk_syntax::ast::{
31    Block, CommonsItem, Expr, ExprKind, Ident, LetStmt, MatchBody, SourceUnit, Statement,
32    expr_children, statement_exprs,
33};
34use bynk_syntax::lexer::tokenize;
35use bynk_syntax::parser::parse_unit_with_recovery;
36use bynk_syntax::span::Span;
37use tower_lsp::lsp_types::*;
38
39const DATA_TAG: &str = "bynk.inlineVariable";
40
41/// A planned inline: the binding's name, its definition span, and the edits.
42pub struct Plan {
43    pub name: String,
44    pub def: Span,
45    pub edits: Vec<(Span, String)>,
46}
47
48/// What listing needs: the binding an inline is offered for.
49pub struct Offer {
50    pub name: String,
51    pub def: Span,
52}
53
54/// Whether an inline is offered for the local binding at `offset` (its name
55/// or a use) — every decline check, but none of the formatting [`plan`] does,
56/// so the unfiltered lightbulb request stays cheap.
57pub fn offerable(text: &str, offset: usize, locals: &[LocalBinding]) -> Option<Offer> {
58    analyse(text, offset, locals).map(|p| Offer {
59        name: p.name,
60        def: p.def,
61    })
62}
63
64/// The inline of the local binding at `offset` (its name or a use) in `text`,
65/// given the round's local bindings for this file, as the edits to apply.
66/// `None` when there is none to offer (see the module docs).
67pub fn plan(text: &str, offset: usize, locals: &[LocalBinding]) -> Option<Plan> {
68    let mut plan = analyse(text, offset, locals)?;
69    // A canonical file stays canonical: the inlined text is re-formatted
70    // (deleting a `let` can leave a body `bynk-fmt` prints on one line) and
71    // the edit becomes the one region where the two differ.
72    let opts = bynk_fmt::FormatOptions::default();
73    if bynk_fmt::format_source(text, &opts).is_ok_and(|f| f == text) {
74        let inlined = crate::fix_all::apply(text, &plan.edits);
75        let formatted = bynk_fmt::format_source(&inlined, &opts).ok()?;
76        plan.edits = vec![changed_region(text, &formatted)];
77    }
78    Some(plan)
79}
80
81/// Every decline check, and the raw (unformatted) edits.
82fn analyse(text: &str, offset: usize, locals: &[LocalBinding]) -> Option<Plan> {
83    let sites = crate::locals_nav::local_sites_at(locals, text, offset)?;
84    let (def, uses) = sites.split_first()?;
85    let tokens = tokenize(text).ok()?;
86    let (Some(unit), errs) = parse_unit_with_recovery(&tokens, text) else {
87        return None;
88    };
89    if !errs.is_empty() {
90        return None;
91    }
92    let mut found = Found::default();
93    walk_unit(&unit, def.start, &mut found);
94    let let_stmt = found.let_stmt?;
95    if let_stmt.name.name == "_" || let_stmt.let_else.is_some() || found.effectful {
96        return None;
97    }
98    // An annotation can pin a type inference wouldn't pick at the use (e.g.
99    // `let init: Option[A] = None`), and Bynk has no ascription form to carry
100    // it into an expression: inlining could change the program's types
101    // without any diagnostic, so an annotated `let` is never inlined.
102    if let_stmt.type_annot.is_some() {
103        return None;
104    }
105    if !let_stmt.trivia.leading.is_empty() || let_stmt.trivia.trailing.is_some() {
106        return None;
107    }
108    let value = &let_stmt.value;
109    let value_text = text.get(value.span.start..value.span.end)?;
110    let replacement = if atomic(value) {
111        value_text.to_string()
112    } else {
113        format!("({value_text})")
114    };
115
116    // Free names of the initialiser must resolve the same way at every use.
117    let mut idents = Vec::new();
118    collect_idents(value, &mut idents);
119    let resolve = |name: &str, at: usize| -> Option<Span> {
120        locals_at(locals, at)
121            .into_iter()
122            .find(|b| b.name == name)
123            .map(|b| b.def_span)
124    };
125    let same = |a: Option<Span>, b: Option<Span>| {
126        a.map(|s| (s.start, s.end)) == b.map(|s| (s.start, s.end))
127    };
128
129    let mut edits = Vec::new();
130    for u in uses {
131        let shorthand = found
132            .shorthands
133            .iter()
134            .any(|s| (s.start, s.end) == (u.start, u.end));
135        let expr_use = found
136            .idents
137            .iter()
138            .any(|s| (s.start, s.end) == (u.start, u.end));
139        if !shorthand && !expr_use {
140            // A record field label spelled the same (`T { name: … }`) is not
141            // a use; any other site the walk doesn't recognise declines.
142            if found
143                .labels
144                .iter()
145                .any(|s| (s.start, s.end) == (u.start, u.end))
146            {
147                continue;
148            }
149            return None;
150        }
151        for id in &idents {
152            if !same(
153                resolve(&id.name, value.span.start),
154                resolve(&id.name, u.start),
155            ) {
156                return None;
157            }
158        }
159        let new_text = if shorthand {
160            format!("{}: {value_text}", let_stmt.name.name)
161        } else {
162            replacement.clone()
163        };
164        edits.push((Span::new(u.start, u.end), new_text));
165    }
166    if edits.is_empty() {
167        return None;
168    }
169
170    // Delete the `let`'s whole line(s).
171    let s = let_stmt.span;
172    let line_start = text[..s.start].rfind('\n').map_or(0, |i| i + 1);
173    if !text[line_start..s.start].trim().is_empty() {
174        return None;
175    }
176    let line_end = text[s.end..].find('\n').map_or(text.len(), |i| s.end + i);
177    if !text[s.end..line_end].trim().is_empty() {
178        return None;
179    }
180    let delete_end = (line_end + 1).min(text.len());
181    edits.push((Span::new(line_start, delete_end), String::new()));
182    edits.sort_by_key(|(span, _)| (span.start, span.end));
183    Some(Plan {
184        name: let_stmt.name.name.clone(),
185        def: *def,
186        edits,
187    })
188}
189
190/// The one edit turning `old` into `new`: the span between their common
191/// prefix and common suffix, on char boundaries.
192pub(crate) fn changed_region(old: &str, new: &str) -> (Span, String) {
193    let prefix: usize = old
194        .chars()
195        .zip(new.chars())
196        .take_while(|(a, b)| a == b)
197        .map(|(a, _)| a.len_utf8())
198        .sum();
199    let suffix: usize = old[prefix..]
200        .chars()
201        .rev()
202        .zip(new[prefix..].chars().rev())
203        .take_while(|(a, b)| a == b)
204        .map(|(a, _)| a.len_utf8())
205        .sum();
206    (
207        Span::new(prefix, old.len() - suffix),
208        new[prefix..new.len() - suffix].to_string(),
209    )
210}
211
212/// An initialiser that needs no parentheses wherever an expression goes.
213fn atomic(e: &Expr) -> bool {
214    matches!(
215        e.kind,
216        ExprKind::IntLit { .. }
217            | ExprKind::FloatLit { .. }
218            | ExprKind::DurationLit { .. }
219            | ExprKind::StrLit(_)
220            | ExprKind::InterpStr(_)
221            | ExprKind::BoolLit(_)
222            | ExprKind::Ident(_)
223            | ExprKind::Call { .. }
224            | ExprKind::Paren(_)
225            | ExprKind::Ok(_)
226            | ExprKind::Err(_)
227            | ExprKind::RecordConstruction { .. }
228            | ExprKind::FieldAccess { .. }
229            | ExprKind::MethodCall { .. }
230            | ExprKind::Some(_)
231            | ExprKind::None
232            | ExprKind::UnitLit
233            | ExprKind::ListLit(_)
234    )
235}
236
237/// Every name `e` reads: identifier expressions and called names (`f(…)`
238/// calls whatever `f` resolves to, a local closure included).
239fn collect_idents<'a>(e: &'a Expr, out: &mut Vec<&'a Ident>) {
240    match &e.kind {
241        ExprKind::Ident(id) => out.push(id),
242        ExprKind::Call { name, .. } => out.push(name),
243        _ => {}
244    }
245    for c in expr_children(e) {
246        collect_idents(c, out);
247    }
248}
249
250/// What the walk over the unit found: the `let` whose name starts at the
251/// definition offset, and the spans of every identifier expression and
252/// record shorthand field.
253#[derive(Default)]
254struct Found<'a> {
255    let_stmt: Option<&'a LetStmt>,
256    effectful: bool,
257    idents: Vec<Span>,
258    shorthands: Vec<Span>,
259    /// Record field labels (`name` in `T { name: value }`).
260    labels: Vec<Span>,
261}
262
263fn walk_unit<'a>(unit: &'a SourceUnit, def: usize, found: &mut Found<'a>) {
264    let items: &[CommonsItem] = match unit {
265        SourceUnit::Commons(c) => &c.items,
266        SourceUnit::Context(c) => &c.items,
267        SourceUnit::Adapter(_) => &[],
268        SourceUnit::Suite(t) => {
269            for case in &t.cases {
270                walk_block(&case.body, def, found);
271            }
272            &[]
273        }
274    };
275    for item in items {
276        match item {
277            CommonsItem::Fn(f) => walk_block(&f.body, def, found),
278            CommonsItem::Provider(p) => {
279                p.ops.iter().for_each(|op| walk_block(&op.body, def, found))
280            }
281            CommonsItem::Service(s) => s
282                .handlers
283                .iter()
284                .for_each(|h| walk_block(&h.body, def, found)),
285            CommonsItem::Agent(a) => a
286                .handlers
287                .iter()
288                .for_each(|h| walk_block(&h.body, def, found)),
289            _ => {}
290        }
291    }
292}
293
294fn walk_block<'a>(b: &'a Block, def: usize, found: &mut Found<'a>) {
295    for s in &b.statements {
296        if let Statement::Let(l) | Statement::EffectLet(l) = s
297            && l.name.span.start == def
298        {
299            found.let_stmt = Some(l);
300            found.effectful = matches!(s, Statement::EffectLet(_));
301        }
302        let mut exprs = Vec::new();
303        statement_exprs(s, &mut exprs);
304        for e in exprs {
305            walk_expr(e, def, found);
306        }
307    }
308    walk_expr(&b.tail, def, found);
309}
310
311fn walk_expr<'a>(e: &'a Expr, def: usize, found: &mut Found<'a>) {
312    match &e.kind {
313        ExprKind::Ident(id) => found.idents.push(id.span),
314        ExprKind::RecordConstruction { fields, .. } => {
315            for f in fields {
316                match f.value {
317                    None => found.shorthands.push(f.name.span),
318                    Some(_) => found.labels.push(f.name.span),
319                }
320            }
321        }
322        _ => {}
323    }
324    match &e.kind {
325        ExprKind::Block(b) => walk_block(b, def, found),
326        ExprKind::If {
327            cond,
328            then_block,
329            else_block,
330        } => {
331            walk_expr(cond, def, found);
332            walk_block(then_block, def, found);
333            walk_block(else_block, def, found);
334        }
335        ExprKind::Match { discriminant, arms } => {
336            walk_expr(discriminant, def, found);
337            for arm in arms {
338                if let Some(g) = &arm.guard {
339                    walk_expr(g, def, found);
340                }
341                match &arm.body {
342                    MatchBody::Expr(e) => walk_expr(e, def, found),
343                    MatchBody::Block(b) => walk_block(b, def, found),
344                }
345            }
346        }
347        _ => {
348            for c in expr_children(e) {
349                walk_expr(c, def, found);
350            }
351        }
352    }
353}
354
355/// The listed (unresolved) `refactor.inline` action for `offer`.
356pub fn listed(plan: &Offer, uri: &Url, version: Option<i32>) -> CodeActionOrCommand {
357    CodeActionOrCommand::CodeAction(CodeAction {
358        title: format!("Inline `{}`", plan.name),
359        kind: Some(CodeActionKind::REFACTOR_INLINE),
360        data: Some(serde_json::json!({
361            "kind": DATA_TAG,
362            "uri": uri.as_str(),
363            "version": version,
364            "def": plan.def.start,
365        })),
366        ..Default::default()
367    })
368}
369
370/// What a listed inline action's `data` names.
371pub struct Target {
372    pub uri: Url,
373    pub version: Option<i32>,
374    pub def: usize,
375}
376
377impl Target {
378    pub fn of(action: &CodeAction) -> Option<Self> {
379        let data = action.data.as_ref()?;
380        if data.get("kind")?.as_str()? != DATA_TAG {
381            return None;
382        }
383        Some(Target {
384            uri: Url::parse(data.get("uri")?.as_str()?).ok()?,
385            version: data
386                .get("version")
387                .and_then(serde_json::Value::as_i64)
388                .and_then(|v| i32::try_from(v).ok()),
389            def: usize::try_from(data.get("def")?.as_u64()?).ok()?,
390        })
391    }
392}