bynk_lsp/hover.rs
1//! #611 (ADR 0190): hover's resolution ladder, as one pure function.
2//!
3//! `Backend::hover` is transport — it resolves the cursor position, gathers the
4//! round's tables and the live buffer, and packages the result as an LSP
5//! `Hover`. The *order* the rungs are tried in is the behaviour: #611's gap B was
6//! a fall-through bug, where a rung that resolved the offset correctly but
7//! rendered nothing let a later, name-matching rung answer instead — a
8//! confidently wrong hover. That order lives here, once, so a test can **pin** it
9//! rather than replicate it (a replica agrees with the original only until
10//! someone reorders one of them).
11//!
12//! Two text sources, deliberately: the index rungs read the round's **analysed
13//! snapshot** (the tables' spans index into it), while the lexical rungs read the
14//! **live buffer**, which is what makes hover work mid-edit. They diverge while
15//! the user types, so the ladder keeps them distinct rather than assuming one.
16//!
17//! The rungs, in trial order (see [`hover_content`]'s inline numbering):
18//! 1. the binding index — a resolved symbol reference, described from its
19//! defining file (a resolved `Service` gets a #855 wire-contract appendix);
20//! 2. a `store` field operation;
21//! 3. a local/param/`self`;
22//! 4. #855: a handler's HEADER (`on`, the method/kind token, the route
23//! literal) — guarded to that byte range so it cannot shadow rung 1;
24//! 5. a top-level declaration by name (lexical, first live-buffer rung);
25//! 6. the `key`/`store` contextual keywords and agent-state references;
26//! 7. a handler-position `@cache` annotation;
27//! 8. a `Recv.member` name-receiver access;
28//! 9. a project-wide name scan;
29//! 10. the embedded first-party sources.
30
31use std::collections::HashMap;
32use std::path::{Path, PathBuf};
33
34use bynk_check::checker::Types;
35use bynk_check::index::SymbolKind;
36use bynk_syntax::span::Span;
37use tower_lsp::lsp_types::Url;
38
39/// The analysed round's tables, positioned at the cursor.
40pub struct HoverAnalysis<'a> {
41 pub index: &'a bynk_check::index::ProjectIndex,
42 /// Project-relative path → the analysed text.
43 pub snapshots: &'a HashMap<PathBuf, String>,
44 pub locals: &'a bynk_check::locals::FileLocals,
45 pub expr_types: &'a bynk_check::expr_types::FileExprTypes,
46 /// T3.6b (R4.1): the table `expr_types`' `TyId`s resolve against.
47 pub tys: &'a Types,
48 /// The cursor's file (project-relative) and its offset **into the snapshot**.
49 pub rel: &'a Path,
50 pub offset: usize,
51 /// #848: the round's project root — resolves a doc-link's `SiteRef` into
52 /// a `file://` hover-Markdown target.
53 pub project_root: &'a Path,
54 /// #848: qualified unit name → its doc-comment intra-doc-link search
55 /// order, for rung 1's doc-link rewrite.
56 pub doc_scope: &'a HashMap<String, Vec<String>>,
57 /// #855: qualified context/adapter unit name → the combined type table
58 /// and service/agent tables the wire-contract peek needs.
59 pub boundary_info: &'a HashMap<String, bynk_ide::ContextBoundaryInfo>,
60 /// #855: how many **real** contexts/adapters this project has — computed
61 /// once per round by the transport with
62 /// `bynk_ide::wire_contract::real_context_count`, never re-derived here
63 /// as a bare `boundary_info.len()` (see that function's doc: the
64 /// synthetic toolchain `bynk` capability unit would otherwise count as a
65 /// second "context" and `NoCrossContextReason::SingleContext` would
66 /// almost never fire).
67 pub context_count: usize,
68}
69
70/// Everything the ladder reads.
71pub struct HoverInput<'a> {
72 /// The analysed round; `None` when the file is outside it — the lexical
73 /// rungs still answer from the live buffer.
74 pub analysis: Option<HoverAnalysis<'a>>,
75 /// The **live** buffer and the cursor's offset into it; `None` when the
76 /// document is not open — the index rungs still answer from the snapshot.
77 pub doc: Option<(&'a str, usize)>,
78 pub uri: &'a Url,
79 /// Content-ownership track (#1086): a pre-read `(path, content)` map for
80 /// the project's `.bynk` files (the caller's overlay-then-disk sweep) —
81 /// was a bare path list the resolution rungs below read from disk
82 /// themselves. Every rung that reads project files (8's `resolve_label`,
83 /// 9's `describe_symbol_cross_file`) takes this map directly now.
84 pub files: Option<&'a HashMap<PathBuf, String>>,
85}
86
87/// The hover Markdown for the cursor, or `None` when no rung resolves it.
88///
89/// The rung order is the contract; see the module doc. Each rung is tried in
90/// turn and the first `Some` wins.
91pub fn hover_content(input: &HoverInput<'_>) -> Option<String> {
92 if let Some(a) = &input.analysis {
93 let tys = a.tys;
94 // 1. v0.25: binding-index path — a resolved symbol reference, described
95 // from its *defining* file (names are unique per file, so the per-file
96 // lookup is exact). Binding-correct: a duplicate name in another unit
97 // describes the bound declaration, not the first name match.
98 //
99 // v0.166 (ADR 0191): every `SymbolKind` now has a renderer arm, so a
100 // resolved key is answered here rather than falling through to a name
101 // match below. The `Some` guard remains — it is what a new kind added
102 // without an arm would fall through, silently, which is how
103 // `Method`/`CapabilityOp` came to render the wrong declaration.
104 if let Some((key, def)) = crate::index_queries::definition_at(a.index, a.rel, a.offset)
105 && let Some(def_text) = a.snapshots.get(&def.path)
106 && let Some(content) = crate::symbols::describe_symbol(def_text, &key.name)
107 {
108 // #848: rewrite any resolvable intra-doc link in the rendered
109 // doc comment into a Markdown link, scoped to `key.unit` (the
110 // declaring unit doc-link resolution searches from).
111 let content = crate::symbols::linkify_doc_links(
112 &content,
113 a.index,
114 a.doc_scope,
115 a.project_root,
116 &key.unit,
117 );
118 // #855: a resolved `Service` symbol (the declaration's own name,
119 // or a cross-context/test-service call site — both record this
120 // kind, see `bynk-emit/src/project/symbols.rs`) gets its wire
121 // contract appended after everything `describe_symbol` already
122 // rendered. This changes only the *tail* of what rung 1 already
123 // returns `Some` for — never whether it answers.
124 let content = if key.kind == SymbolKind::Service {
125 match wire_contract_for_service_key(a, key, &def.path, def_text) {
126 Some(model) => format!("{content}{}", wire_contract_appendix(&model)),
127 None => content,
128 }
129 } else {
130 content
131 };
132 return Some(content);
133 }
134 // 2. #611 (gap C): a `store` field's operation (`items.put(…)`) — a
135 // structural match on the enclosing agent's declared field, so it
136 // outranks the locals rung below, which guesses by name in scope.
137 // Takes `locals` to honour the checker's by-provenance dispatch: a
138 // local shadowing the field makes this an ordinary value method.
139 if let Some(text) = a.snapshots.get(a.rel)
140 && let Some(content) = crate::symbols::describe_store_op_at(
141 text,
142 a.offset,
143 a.locals.get(a.rel).map_or(&[], |l| l.as_slice()),
144 )
145 {
146 return Some(content);
147 }
148 // 3. v0.122 (slice 1): a local / parameter → its inferred type, and
149 // `self` → its receiver/agent type. Both read the retained analysis
150 // tables and run before the lexical fallback, which knows only
151 // declarations by name.
152 if let Some(text) = a.snapshots.get(a.rel) {
153 let local = a
154 .locals
155 .get(a.rel)
156 .and_then(|locals| crate::locals_nav::describe_local_at(locals, text, a.offset))
157 .or_else(|| {
158 let entries = a.expr_types.get(a.rel)?;
159 crate::symbols::describe_self_at(text, a.offset, entries, tys)
160 });
161 if let Some(content) = local {
162 return Some(content);
163 }
164 }
165 // 4. #855: the handler HEADER — the `on` keyword, the HTTP method /
166 // handler-kind token, and any route string literal. No earlier rung
167 // (nor any later lexical one) ever answers here today: `GET` lexes
168 // as a bare `Ident` with no index symbol, and no top-level
169 // declaration is named after it. Guarded to the header's own byte
170 // range — from the handler's span start up to (but excluding) its
171 // first param's span start, or its body's span start with no
172 // params — which contains only tokens that are never an index
173 // symbol, so this rung is provably unable to shadow rung 1's
174 // resolution of, say, a param's *type* name (`client: ClientId`),
175 // which always sits past that boundary. See `header_handler_at`.
176 if let Some(text) = a.snapshots.get(a.rel)
177 && let Some((service_name, handler)) = header_handler_at(text, a.offset)
178 && let Some((unit, _)) = bynk_ide::symbols::own_declaration_name(text)
179 && let Some(info) = a.boundary_info.get(&unit)
180 {
181 let expr_types = a.expr_types.get(a.rel).map(|v| v.as_slice()).unwrap_or(&[]);
182 if let Some(model) = bynk_ide::wire_contract::wire_contract_for_service(
183 &unit,
184 text,
185 &service_name,
186 &handler,
187 info,
188 expr_types,
189 tys,
190 a.context_count,
191 ) {
192 return Some(wire_contract_standalone(&model));
193 }
194 }
195 }
196
197 // The remaining rungs are lexical, over the **live** buffer.
198 let (text, offset) = input.doc?;
199 let Some((name, span)) = identifier_at(text, offset) else {
200 // v0.121 (ADR 0156): the mechanical coverage test requires every
201 // lowercase-initial keyword to have *a* hover path. A bare keyword token
202 // (`requires`, `suite`, …) never resolves as an identifier above, so it
203 // falls here — its one-line `keywords` registry doc, the same text
204 // completion shows. Richer per-declaration hover is `describe_symbol`'s
205 // job, not this fallback's.
206 return crate::symbols::describe_keyword_at(text, offset).map(str::to_string);
207 };
208 // 5. A top-level declaration in this file (fast path).
209 if let Some(content) = crate::symbols::describe_symbol(text, &name) {
210 return Some(content);
211 }
212 // 6. v0.137.0 (ADR 0161) + #611 (gap A): the `key`/`store` contextual
213 // keywords, the agent state fields they declare, and — since #611 — a
214 // *reference* to one from the agent's body. Single-file-local, so it
215 // resolves before any project-wide scan.
216 if let Some(content) = crate::symbols::describe_agent_state_at(text, span.start) {
217 return Some(content);
218 }
219 // 7. v0.140 (ADR 0163): a handler-position `@cache` annotation — not a symbol
220 // and no local, so it resolves here beside the agent state.
221 if let Some(content) = crate::symbols::describe_handler_annotation_at(text, span.start) {
222 return Some(content);
223 }
224 // 8. v0.123 (slice 2, DECISION B): a `Recv.member` name-receiver access — a
225 // capability op (`Clock.now`), a refined/opaque `of`/`unsafe`, or a type
226 // static — via the same path signature help uses, over the project and the
227 // embedded surface. Before the cross-file / first-party name scans.
228 crate::symbols::qualified_callee_at(text, span)
229 .and_then(|callee| crate::signature_help::resolve_label(&callee, text, input.files))
230 .map(|sig| format!("```bynk\n{sig}\n```"))
231 // 9. A project-wide scan (v1.1), then 10. the embedded first-party
232 // sources (slice 9) — so `uses`/`consumes` names resolve across
233 // file boundaries (§3.4) and stdlib/surface symbols surface too.
234 // Content-ownership track (#1086) slice 1: `describe_symbol_cross_file`
235 // now takes `input.files`'s content map directly — no more deriving
236 // a path list to hand it.
237 .or_else(|| {
238 input
239 .files
240 .and_then(|content| {
241 crate::symbols::describe_symbol_cross_file(content, input.uri, &name)
242 })
243 .map(|(_other_uri, desc)| desc)
244 })
245 .or_else(|| crate::symbols::describe_firstparty_symbol(&name))
246}
247
248/// The identifier-ish token covering `offset` — its text and span.
249///
250/// Hole-aware (issue #473): interpolation holes are expanded, so a cursor inside
251/// `"… \(name) …"` lands on the hole's identifier token rather than the opaque
252/// `InterpStr` token. The token-kind filter is wider than `Ident` because the
253/// literal kinds carry hover-worthy names too (`Result`, `Option`, `Effect`).
254fn identifier_at(text: &str, offset: usize) -> Option<(String, Span)> {
255 use bynk_syntax::lexer::TokenKind;
256 let tokens = bynk_syntax::lexer::tokenize_expanding_holes(text).ok()?;
257 tokens
258 .iter()
259 .find(|t| {
260 t.span.start <= offset
261 && offset < t.span.end
262 && matches!(
263 t.kind,
264 TokenKind::Ident
265 | TokenKind::Int
266 | TokenKind::String
267 | TokenKind::Bool
268 | TokenKind::Float
269 | TokenKind::Result
270 | TokenKind::Option
271 | TokenKind::Effect
272 )
273 })
274 .map(|t| (text[t.span.start..t.span.end].to_string(), t.span))
275}
276
277// ---------------------------------------------------------------------
278// #855: wire-contract rendering — rung 1's appendix and rung 4's standalone
279// content share the same body renderer ([`wire_contract_body`]); they differ
280// only in what comes before it (an existing `describe_symbol` block, versus
281// nothing).
282// ---------------------------------------------------------------------
283
284/// Resolve a resolved-`Service`-symbol's wire contract, for rung 1's
285/// appendix. A service may declare more than one handler (several HTTP
286/// routes on one service, say); this module has no way to tell *which* one
287/// the author meant by hovering the service's bare name (a declaration site,
288/// or a cross-context/test-service call site — neither carries a handler
289/// selector). Rather than guess, this answers only the unambiguous case: a
290/// service with **exactly one** handler. That covers every worked example in
291/// the issue (a single-route HTTP service, a single-handler `on call`
292/// service) and every #855 fixture; a multi-handler service simply gets no
293/// appendix, which is rung 1's pre-#855 behaviour unchanged — never a wrong
294/// answer, only sometimes an absent one.
295fn wire_contract_for_service_key(
296 a: &HoverAnalysis<'_>,
297 key: &bynk_check::index::SymbolKey,
298 def_path: &Path,
299 def_text: &str,
300) -> Option<bynk_ide::wire_contract::WireContractModel> {
301 let tys = a.tys;
302 let info = a.boundary_info.get(&key.unit)?;
303 let svc = info.services.get(&key.name)?;
304 let [handler] = svc.handlers.as_slice() else {
305 return None;
306 };
307 let expr_types = a
308 .expr_types
309 .get(def_path)
310 .map(|v| v.as_slice())
311 .unwrap_or(&[]);
312 bynk_ide::wire_contract::wire_contract_for_service(
313 &key.unit,
314 def_text,
315 &key.name,
316 handler,
317 info,
318 expr_types,
319 tys,
320 a.context_count,
321 )
322}
323
324/// Locate the `Handler` (and its owning service's name) whose **header**
325/// contains `offset` — the `on` keyword, the handler-kind/HTTP-method token,
326/// and any route string literal, but *none* of its params or body. Mirrors
327/// `bynk_ide::wire_contract::wire_contract_at`'s re-parse-and-find-the-
328/// enclosing-handler convention; duplicated (rather than reused) because the
329/// guard below needs the raw `Handler`'s param/body spans, which
330/// `wire_contract_at`'s return value (a `WireContractModel`, not an AST node)
331/// does not carry.
332fn header_handler_at(text: &str, offset: usize) -> Option<(String, bynk_syntax::ast::Handler)> {
333 use bynk_syntax::ast::{CommonsItem, SourceUnit};
334 let tokens = bynk_syntax::lexer::tokenize(text).ok()?;
335 let (parsed, _errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, text);
336 let items: &[CommonsItem] = match parsed.as_ref()? {
337 SourceUnit::Context(c) => &c.items,
338 SourceUnit::Adapter(a) => &a.items,
339 SourceUnit::Commons(_) | SourceUnit::Suite(_) => return None,
340 };
341 for item in items {
342 let CommonsItem::Service(s) = item else {
343 continue;
344 };
345 for h in &s.handlers {
346 // The safety property: this range holds only `on`, the
347 // kind/method token, and the route literal — never a param name,
348 // a type name, or anything else an earlier rung (or a later
349 // lexical one) could already resolve. See the module doc and
350 // rung 4's own comment.
351 let prefix_end = h
352 .params
353 .first()
354 .map(|p| p.span.start)
355 .unwrap_or(h.body.span.start);
356 if h.span.start <= offset && offset < prefix_end {
357 return Some((s.name.name.clone(), h.clone()));
358 }
359 }
360 }
361 None
362}
363
364/// The Markdown facts shared by rung 1's appendix and rung 4's standalone
365/// render: the request envelope (all three cases), the cross-context
366/// contract form + hash (`on call` only), and — HTTP handlers only — the
367/// reachable response status set.
368fn wire_contract_body(model: &bynk_ide::wire_contract::WireContractModel) -> String {
369 use bynk_ide::wire_contract::{BoundaryKind, Envelope};
370
371 let mut out = String::new();
372 if let BoundaryKind::Http { method, path } = &model.kind {
373 out.push_str(&format!("`{} {}`\n\n", method.as_str(), path));
374 }
375
376 out.push_str("**Request:** ");
377 match &model.envelope {
378 Envelope::Empty => out.push_str("no body — the handler never reads it.\n\n"),
379 Envelope::Bare { param, .. } => out.push_str(&format!(
380 "the request body **is** the value of `{param}` — not wrapped in an object.\n\n"
381 )),
382 Envelope::Keyed { params } => {
383 let names: Vec<&str> = params.iter().map(|(n, _)| n.as_str()).collect();
384 out.push_str(&format!(
385 "an object keyed by `{}`, in declaration order.\n\n",
386 names.join("`, `")
387 ));
388 }
389 }
390
391 if let Some(contract) = &model.contract {
392 out.push_str(&format!(
393 "**Cross-context contract** (hash `{}`) — change any of this and \
394 already-deployed callers get a 409:\n\n```\n{}\n```\n\n",
395 contract.hash, contract.normal_form
396 ));
397 }
398
399 if !model.responses.is_empty() {
400 out.push_str("**Responses:**\n\n");
401 for r in &model.responses {
402 out.push_str(&format!("- `{} {}`\n", r.status, r.variant));
403 }
404 out.push('\n');
405 }
406
407 out
408}
409
410/// Rung 1's wire-contract appendix — separated from whatever
411/// `describe_symbol` already rendered by a rule, so it reads as a distinct
412/// section rather than a continuation of the declaration's own doc comment.
413fn wire_contract_appendix(model: &bynk_ide::wire_contract::WireContractModel) -> String {
414 format!(
415 "\n\n---\n\n**Wire contract**\n\n{}",
416 wire_contract_body(model)
417 )
418}
419
420/// Rung 4's standalone render — hovering the header *is* the question, so
421/// this is the whole answer, headed by the service/handler name rather than
422/// appended after one.
423fn wire_contract_standalone(model: &bynk_ide::wire_contract::WireContractModel) -> String {
424 format!(
425 "**Wire contract** — `{}`\n\n{}",
426 model.service,
427 wire_contract_body(model)
428 )
429}