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}