Skip to main content

bynk_lsp/
capability_fixes.rs

1//! #852 (capability-aware quick-fixes): the `codeAction` producers that repair
2//! *resolution/boundary* diagnostics by editing a unit's header — `add
3//! consumes` for an unconsumed cross-context call, and the Bynk analogue of
4//! auto-import (`add uses`/`add consumes`) for an unresolved name that the
5//! binding index places in a mixable commons or a consumable context.
6//!
7//! Unlike the [`crate::code_actions`] quick-fixes — which render structured
8//! [`bynk_syntax::error::Suggestion`]s authored at the diagnosis site — these
9//! are computed **here**: the fix's location is a unit-header edit that only
10//! exists once the buffer is reparsed, and (for auto-import) the resolution is
11//! a whole-project query over the committed [`ProjectIndex`], neither of which
12//! is available at the per-unit checker diagnosis site. Like the `extract`
13//! module, the current buffer is reparsed fresh each call (no cached AST);
14//! edits are **versioned** against the analysed document version, so a drifted
15//! buffer rejects them (§3.10's rule).
16//!
17//! Keying, as in `code_actions`, is on the **diagnostic's** span: the
18//! unresolved name / unconsumed chain is read straight from the source at that
19//! span (never re-derived from the message text), so the fix stays anchored to
20//! exactly what the compiler flagged.
21
22use bynk_check::firstparty::BYNK_SURFACE_CAPABILITIES;
23use bynk_check::index::{ProjectIndex, SymbolKind};
24use bynk_syntax::ast::{ConsumesDecl, SourceUnit, UsesDecl};
25use bynk_syntax::lexer::tokenize;
26use bynk_syntax::parser::parse_unit_with_recovery;
27use bynk_syntax::span::Span;
28use tower_lsp::lsp_types::*;
29
30/// Quick-fixes that add a `uses`/`consumes` clause to the current unit's
31/// header, for every resolution diagnostic whose span intersects `requested`.
32/// `index` is the committed round's binding index; the current unit's own
33/// declarations are excluded as candidates (a name that already resolves
34/// locally is not "unresolved").
35pub fn header_quick_fixes(
36    text: &str,
37    diagnostics: &[bynk_ide::Diagnostic],
38    requested: Span,
39    uri: &Url,
40    version: Option<i32>,
41    index: &ProjectIndex,
42) -> Vec<CodeActionOrCommand> {
43    // Reparse the buffer to read the current unit's header (kind + existing
44    // clauses). A file that no longer parses to a single header unit offers
45    // nothing — the same posture as `extract`.
46    let Ok(tokens) = tokenize(text) else {
47        return Vec::new();
48    };
49    let (Some(unit), _errs) = parse_unit_with_recovery(&tokens, text) else {
50        return Vec::new();
51    };
52    let Some(header) = Header::of(&unit) else {
53        return Vec::new();
54    };
55
56    let mut out = Vec::new();
57    for d in diagnostics {
58        if !intersects(d.error.span, requested) {
59            continue;
60        }
61        match d.error.category {
62            // A dotted chain that looks like a cross-context call but is not
63            // consumed → `consumes <chain>`. The chain is the diagnostic's own
64            // span (`app.other`, not the trailing `.service`).
65            "bynk.resolve.unconsumed_context" => {
66                let chain = span_ident(text, d.error.span);
67                if let Some(action) = header.add_consumes_unit(&chain, text, uri, version) {
68                    out.push(action);
69                }
70            }
71            // An unresolved name/type → one action per unit that declares it,
72            // as `uses <commons>` or `consumes <context> { name }` (DECISION A:
73            // per candidate, never a guess). `unknown_type` is restricted to
74            // type-shaped candidates.
75            "bynk.resolve.unknown_name" | "bynk.resolve.unknown_type" => {
76                let type_only = d.error.category == "bynk.resolve.unknown_type";
77                let name = span_ident(text, d.error.span);
78                out.extend(header.import_actions(&name, type_only, text, uri, version, index));
79            }
80            _ => {}
81        }
82    }
83    out
84}
85
86/// Closed intersection over half-open spans (mirrors `code_actions`).
87fn intersects(a: Span, b: Span) -> bool {
88    a.start <= b.end && b.start <= a.end
89}
90
91/// The identifier (or dotted chain) at `span`, whitespace-collapsed so a chain
92/// written with spacing (`app . other`) still reads as `app.other`.
93fn span_ident(text: &str, span: Span) -> String {
94    text.get(span.start..span.end)
95        .unwrap_or_default()
96        .split_whitespace()
97        .collect()
98}
99
100/// The current unit's header, extracted from the reparsed AST: what it can
101/// import (`can_consume` is false for a commons — a commons has no `consumes`),
102/// its name-clause anchor, and its existing `uses`/`consumes` clauses.
103struct Header<'a> {
104    unit_name: String,
105    can_consume: bool,
106    name_span: Span,
107    uses: &'a [UsesDecl],
108    consumes: &'a [ConsumesDecl],
109}
110
111const NO_CONSUMES: &[ConsumesDecl] = &[];
112
113impl<'a> Header<'a> {
114    fn of(unit: &'a SourceUnit) -> Option<Self> {
115        match unit {
116            SourceUnit::Commons(c) => Some(Header {
117                unit_name: c.name.joined(),
118                can_consume: false,
119                name_span: c.name.span,
120                uses: &c.uses,
121                consumes: NO_CONSUMES,
122            }),
123            SourceUnit::Context(c) => Some(Header {
124                unit_name: c.name.joined(),
125                can_consume: true,
126                name_span: c.name.span,
127                uses: &c.uses,
128                consumes: &c.consumes,
129            }),
130            SourceUnit::Adapter(a) => Some(Header {
131                unit_name: a.name.joined(),
132                can_consume: true,
133                name_span: a.name.span,
134                uses: &a.uses,
135                consumes: &a.consumes,
136            }),
137            // A suite has no importing header of its own.
138            SourceUnit::Suite(_) => None,
139        }
140    }
141
142    /// One code action per unit that declares `name`, as an auto-import. A
143    /// commons declaration (value vocabulary) → `uses`; a capability exported
144    /// by a context/adapter → `consumes … { name }`. Candidates already in
145    /// scope, or that the current unit cannot host, are dropped (never a no-op
146    /// or an unsound offer); everything genuinely importable is offered
147    /// (DECISION A).
148    fn import_actions(
149        &self,
150        name: &str,
151        type_only: bool,
152        text: &str,
153        uri: &Url,
154        version: Option<i32>,
155        index: &ProjectIndex,
156    ) -> Vec<CodeActionOrCommand> {
157        let mut targets: Vec<Candidate> = Vec::new();
158
159        // The env-free `bynk` surface capabilities are first-party synthetic
160        // symbols, excluded from the index — offered from the known list.
161        if !type_only && BYNK_SURFACE_CAPABILITIES.contains(&name) {
162            targets.push(Candidate::ConsumesCapability {
163                unit: "bynk".to_string(),
164            });
165        }
166
167        for (key, entry) in &index.symbols {
168            if key.name != name || entry.def.is_none() || key.unit == self.unit_name {
169                continue;
170            }
171            match key.kind {
172                SymbolKind::Type if unit_is_commons(index, &key.unit) => {
173                    targets.push(Candidate::Uses {
174                        unit: key.unit.clone(),
175                    });
176                }
177                // #1883: an asset (and #2387 an embed) is a value from a commons,
178                // imported like a fn.
179                SymbolKind::Fn | SymbolKind::Asset | SymbolKind::Embed
180                    if !type_only && unit_is_commons(index, &key.unit) =>
181                {
182                    targets.push(Candidate::Uses {
183                        unit: key.unit.clone(),
184                    });
185                }
186                SymbolKind::Capability if !type_only => {
187                    targets.push(Candidate::ConsumesCapability {
188                        unit: key.unit.clone(),
189                    });
190                }
191                _ => {}
192            }
193        }
194
195        targets.sort();
196        targets.dedup();
197
198        let mut out = Vec::new();
199        for cand in targets {
200            let action = match cand {
201                Candidate::Uses { unit } => self.add_uses(&unit, text, uri, version),
202                Candidate::ConsumesCapability { unit } => {
203                    self.add_consumes_capability(&unit, name, text, uri, version)
204                }
205            };
206            if let Some(action) = action {
207                out.push(action);
208            }
209        }
210        out
211    }
212
213    /// A whole-unit `consumes <target>` clause (the cross-context call fix).
214    /// `None` when the current unit cannot consume or already consumes it.
215    fn add_consumes_unit(
216        &self,
217        target: &str,
218        text: &str,
219        uri: &Url,
220        version: Option<i32>,
221    ) -> Option<CodeActionOrCommand> {
222        if !self.can_consume || self.consumes_target(target).is_some() {
223            return None;
224        }
225        let (at, insert) = self.new_consumes_edit(&format!("consumes {target}"));
226        Some(action(
227            format!("add `consumes {target}`"),
228            at,
229            insert,
230            text,
231            uri,
232            version,
233        ))
234    }
235
236    /// A `consumes <unit> { <cap> }` clause, extending an existing braced
237    /// clause for the same unit in place (DECISION C) when one exists. `None`
238    /// when the current unit cannot consume or already lists the capability.
239    fn add_consumes_capability(
240        &self,
241        unit: &str,
242        cap: &str,
243        text: &str,
244        uri: &Url,
245        version: Option<i32>,
246    ) -> Option<CodeActionOrCommand> {
247        if !self.can_consume {
248            return None;
249        }
250        // Extend a matching braced clause, if any.
251        if let Some(dec) = self
252            .consumes
253            .iter()
254            .find(|c| c.target.joined() == unit && c.selected.is_some())
255        {
256            let selected = dec.selected.as_ref().unwrap();
257            if selected.iter().any(|c| c.name == cap) {
258                return None; // already listed
259            }
260            let (at, insert) = match selected.last() {
261                // Non-empty list: append `, cap` after the last selected name.
262                Some(last) => (Span::new(last.span.end, last.span.end), format!(", {cap}")),
263                // `consumes unit { }` — the interior spacing is unknown, so
264                // **replace** the whole clause with a canonical one (its only
265                // content is the target and the new capability), rather than
266                // inserting into braces of unknown width.
267                None => (dec.span, format!("consumes {unit} {{ {cap} }}")),
268            };
269            return Some(action(
270                format!("add `{cap}` to `consumes {unit}`"),
271                at,
272                insert,
273                text,
274                uri,
275                version,
276            ));
277        }
278        // A whole-unit consume of the same target already brings everything.
279        if self.consumes_target(unit).is_some() {
280            return None;
281        }
282        let (at, insert) = self.new_consumes_edit(&format!("consumes {unit} {{ {cap} }}"));
283        Some(action(
284            format!("add `consumes {unit} {{ {cap} }}`"),
285            at,
286            insert,
287            text,
288            uri,
289            version,
290        ))
291    }
292
293    /// A `uses <target>` clause. `None` when it is already used.
294    fn add_uses(
295        &self,
296        target: &str,
297        text: &str,
298        uri: &Url,
299        version: Option<i32>,
300    ) -> Option<CodeActionOrCommand> {
301        if self.uses.iter().any(|u| u.target.joined() == target) {
302            return None;
303        }
304        // Append after the last `uses`, else after the last `consumes`, else on
305        // a fresh line under the unit name.
306        let (at, insert) = if let Some(last) = self.uses.last() {
307            (last.span.end, format!("\nuses {target}"))
308        } else if let Some(last) = self.consumes.last() {
309            (last.span.end, format!("\nuses {target}"))
310        } else {
311            (self.name_span.end, format!("\n\nuses {target}"))
312        };
313        Some(action(
314            format!("add `uses {target}`"),
315            Span::new(at, at),
316            insert,
317            text,
318            uri,
319            version,
320        ))
321    }
322
323    /// The `(anchor, text)` for a brand-new consumes clause `clause` (e.g.
324    /// `consumes a.b` or `consumes bynk { Fetch }`): appended after the last
325    /// existing `consumes`, else on a fresh line under the unit name — before
326    /// any `uses` (the conventional consumes-first header order).
327    fn new_consumes_edit(&self, clause: &str) -> (Span, String) {
328        if let Some(last) = self.consumes.last() {
329            let at = last.span.end;
330            (Span::new(at, at), format!("\n{clause}"))
331        } else {
332            let at = self.name_span.end;
333            (Span::new(at, at), format!("\n\n{clause}"))
334        }
335    }
336
337    fn consumes_target(&self, target: &str) -> Option<&ConsumesDecl> {
338        self.consumes
339            .iter()
340            .find(|c| c.target.joined() == target && c.selected.is_none())
341    }
342}
343
344/// A resolved import target for an unresolved name.
345#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
346enum Candidate {
347    Uses { unit: String },
348    ConsumesCapability { unit: String },
349}
350
351/// True when `unit` declares only value vocabulary (types/fns/methods) — i.e.
352/// it is a commons, not a context/adapter. A context/adapter additionally
353/// declares services, agents, actors, capabilities, or providers; the presence
354/// of any such symbol is the discriminator (no `UnitKind` is threaded into the
355/// index). Conservative: an unclassifiable unit reads as not-a-commons and its
356/// types are simply not offered for `uses`.
357fn unit_is_commons(index: &ProjectIndex, unit: &str) -> bool {
358    !index.symbols.keys().any(|k| {
359        k.unit == unit
360            && matches!(
361                k.kind,
362                SymbolKind::Service
363                    | SymbolKind::Agent
364                    | SymbolKind::Actor
365                    | SymbolKind::Capability
366                    | SymbolKind::Provider
367                    | SymbolKind::Handler
368                    | SymbolKind::CapabilityOp
369            )
370    })
371}
372
373/// Build a single-edit versioned quick-fix code action (the §3.10 shape).
374fn action(
375    title: String,
376    at: Span,
377    new_text: String,
378    text: &str,
379    uri: &Url,
380    version: Option<i32>,
381) -> CodeActionOrCommand {
382    let edit = OneOf::Left(TextEdit {
383        range: crate::position::span_to_range(text, at),
384        new_text,
385    });
386    CodeActionOrCommand::CodeAction(CodeAction {
387        title,
388        kind: Some(CodeActionKind::QUICKFIX),
389        edit: Some(WorkspaceEdit {
390            changes: None,
391            document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
392                text_document: OptionalVersionedTextDocumentIdentifier {
393                    uri: uri.clone(),
394                    version,
395                },
396                edits: vec![edit],
397            }])),
398            change_annotations: None,
399        }),
400        ..Default::default()
401    })
402}