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}