Skip to main content

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}