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                SymbolKind::Fn if !type_only && unit_is_commons(index, &key.unit) => {
178                    targets.push(Candidate::Uses {
179                        unit: key.unit.clone(),
180                    });
181                }
182                SymbolKind::Capability if !type_only => {
183                    targets.push(Candidate::ConsumesCapability {
184                        unit: key.unit.clone(),
185                    });
186                }
187                _ => {}
188            }
189        }
190
191        targets.sort();
192        targets.dedup();
193
194        let mut out = Vec::new();
195        for cand in targets {
196            let action = match cand {
197                Candidate::Uses { unit } => self.add_uses(&unit, text, uri, version),
198                Candidate::ConsumesCapability { unit } => {
199                    self.add_consumes_capability(&unit, name, text, uri, version)
200                }
201            };
202            if let Some(action) = action {
203                out.push(action);
204            }
205        }
206        out
207    }
208
209    /// A whole-unit `consumes <target>` clause (the cross-context call fix).
210    /// `None` when the current unit cannot consume or already consumes it.
211    fn add_consumes_unit(
212        &self,
213        target: &str,
214        text: &str,
215        uri: &Url,
216        version: Option<i32>,
217    ) -> Option<CodeActionOrCommand> {
218        if !self.can_consume || self.consumes_target(target).is_some() {
219            return None;
220        }
221        let (at, insert) = self.new_consumes_edit(&format!("consumes {target}"));
222        Some(action(
223            format!("add `consumes {target}`"),
224            at,
225            insert,
226            text,
227            uri,
228            version,
229        ))
230    }
231
232    /// A `consumes <unit> { <cap> }` clause, extending an existing braced
233    /// clause for the same unit in place (DECISION C) when one exists. `None`
234    /// when the current unit cannot consume or already lists the capability.
235    fn add_consumes_capability(
236        &self,
237        unit: &str,
238        cap: &str,
239        text: &str,
240        uri: &Url,
241        version: Option<i32>,
242    ) -> Option<CodeActionOrCommand> {
243        if !self.can_consume {
244            return None;
245        }
246        // Extend a matching braced clause, if any.
247        if let Some(dec) = self
248            .consumes
249            .iter()
250            .find(|c| c.target.joined() == unit && c.selected.is_some())
251        {
252            let selected = dec.selected.as_ref().unwrap();
253            if selected.iter().any(|c| c.name == cap) {
254                return None; // already listed
255            }
256            let (at, insert) = match selected.last() {
257                // Non-empty list: append `, cap` after the last selected name.
258                Some(last) => (Span::new(last.span.end, last.span.end), format!(", {cap}")),
259                // `consumes unit { }` — the interior spacing is unknown, so
260                // **replace** the whole clause with a canonical one (its only
261                // content is the target and the new capability), rather than
262                // inserting into braces of unknown width.
263                None => (dec.span, format!("consumes {unit} {{ {cap} }}")),
264            };
265            return Some(action(
266                format!("add `{cap}` to `consumes {unit}`"),
267                at,
268                insert,
269                text,
270                uri,
271                version,
272            ));
273        }
274        // A whole-unit consume of the same target already brings everything.
275        if self.consumes_target(unit).is_some() {
276            return None;
277        }
278        let (at, insert) = self.new_consumes_edit(&format!("consumes {unit} {{ {cap} }}"));
279        Some(action(
280            format!("add `consumes {unit} {{ {cap} }}`"),
281            at,
282            insert,
283            text,
284            uri,
285            version,
286        ))
287    }
288
289    /// A `uses <target>` clause. `None` when it is already used.
290    fn add_uses(
291        &self,
292        target: &str,
293        text: &str,
294        uri: &Url,
295        version: Option<i32>,
296    ) -> Option<CodeActionOrCommand> {
297        if self.uses.iter().any(|u| u.target.joined() == target) {
298            return None;
299        }
300        // Append after the last `uses`, else after the last `consumes`, else on
301        // a fresh line under the unit name.
302        let (at, insert) = if let Some(last) = self.uses.last() {
303            (last.span.end, format!("\nuses {target}"))
304        } else if let Some(last) = self.consumes.last() {
305            (last.span.end, format!("\nuses {target}"))
306        } else {
307            (self.name_span.end, format!("\n\nuses {target}"))
308        };
309        Some(action(
310            format!("add `uses {target}`"),
311            Span::new(at, at),
312            insert,
313            text,
314            uri,
315            version,
316        ))
317    }
318
319    /// The `(anchor, text)` for a brand-new consumes clause `clause` (e.g.
320    /// `consumes a.b` or `consumes bynk { Fetch }`): appended after the last
321    /// existing `consumes`, else on a fresh line under the unit name — before
322    /// any `uses` (the conventional consumes-first header order).
323    fn new_consumes_edit(&self, clause: &str) -> (Span, String) {
324        if let Some(last) = self.consumes.last() {
325            let at = last.span.end;
326            (Span::new(at, at), format!("\n{clause}"))
327        } else {
328            let at = self.name_span.end;
329            (Span::new(at, at), format!("\n\n{clause}"))
330        }
331    }
332
333    fn consumes_target(&self, target: &str) -> Option<&ConsumesDecl> {
334        self.consumes
335            .iter()
336            .find(|c| c.target.joined() == target && c.selected.is_none())
337    }
338}
339
340/// A resolved import target for an unresolved name.
341#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
342enum Candidate {
343    Uses { unit: String },
344    ConsumesCapability { unit: String },
345}
346
347/// True when `unit` declares only value vocabulary (types/fns/methods) — i.e.
348/// it is a commons, not a context/adapter. A context/adapter additionally
349/// declares services, agents, actors, capabilities, or providers; the presence
350/// of any such symbol is the discriminator (no `UnitKind` is threaded into the
351/// index). Conservative: an unclassifiable unit reads as not-a-commons and its
352/// types are simply not offered for `uses`.
353fn unit_is_commons(index: &ProjectIndex, unit: &str) -> bool {
354    !index.symbols.keys().any(|k| {
355        k.unit == unit
356            && matches!(
357                k.kind,
358                SymbolKind::Service
359                    | SymbolKind::Agent
360                    | SymbolKind::Actor
361                    | SymbolKind::Capability
362                    | SymbolKind::Provider
363                    | SymbolKind::Handler
364                    | SymbolKind::CapabilityOp
365            )
366    })
367}
368
369/// Build a single-edit versioned quick-fix code action (the §3.10 shape).
370fn action(
371    title: String,
372    at: Span,
373    new_text: String,
374    text: &str,
375    uri: &Url,
376    version: Option<i32>,
377) -> CodeActionOrCommand {
378    let edit = OneOf::Left(TextEdit {
379        range: crate::position::span_to_range(text, at),
380        new_text,
381    });
382    CodeActionOrCommand::CodeAction(CodeAction {
383        title,
384        kind: Some(CodeActionKind::QUICKFIX),
385        edit: Some(WorkspaceEdit {
386            changes: None,
387            document_changes: Some(DocumentChanges::Edits(vec![TextDocumentEdit {
388                text_document: OptionalVersionedTextDocumentIdentifier {
389                    uri: uri.clone(),
390                    version,
391                },
392                edits: vec![edit],
393            }])),
394            change_annotations: None,
395        }),
396        ..Default::default()
397    })
398}