Skip to main content

bynk_ide/
symbols.rs

1//! Symbol lookups for hover and go-to-definition.
2//!
3//! Single-file lookups walk the parsed AST. Cross-file lookups (v1.1; LSP
4//! spec §3.4 cross-file requirement) iterate the project's `.bynk` sources
5//! to find a declaration in any unit the user might be referencing — used
6//! when the open file lacks the symbol the user clicked on (typically
7//! because the name was imported via `uses` or made available via
8//! `consumes`).
9
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12
13use bynk_syntax::ast::*;
14use bynk_syntax::lexer::tokenize;
15use bynk_syntax::parser::parse_unit_with_recovery;
16use bynk_syntax::span::Span;
17
18/// Return the source span of the declaration named `name` in the given
19/// source text. Returns `None` if no declaration matches.
20///
21/// The name of each item comes from [`CommonsItem::name`] rather than a list of
22/// arms here: that `match` is exhaustive, so a variant this lookup does not
23/// handle cannot compile, and a new one is answered the day it is added. The
24/// arms it replaces omitted `Actor`, and a `_ => {}` catch-all swallowed it —
25/// so go-to-definition on the `User` in `by u: User` found nothing whenever the
26/// index rung above it had not resolved the offset (an unanalysed or mid-edit
27/// buffer, a file outside the analysed project).
28///
29/// Note this deliberately matches a **method** by its bare name — `fn
30/// Stored.retitle` answers to `retitle` — because go-to-definition from a bare
31/// identifier depends on it. `describe_item` guards on `FnName::Free` instead
32/// and the two therefore disagree on what a bare name means, which ADR 0191 D2
33/// records as intended rather than as drift.
34pub fn find_declaration_span(source: &str, name: &str) -> Option<Span> {
35    let tokens = tokenize(source).ok()?;
36    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
37    let unit = unit?;
38    let items: &[CommonsItem] = match &unit {
39        SourceUnit::Commons(c) => &c.items,
40        SourceUnit::Context(c) => &c.items,
41        SourceUnit::Adapter(a) => &a.items,
42        SourceUnit::Suite(_) => &[],
43    };
44    items
45        .iter()
46        .filter_map(CommonsItem::name)
47        .find(|ident| ident.name == name)
48        .map(|ident| ident.span)
49}
50
51/// Build a Markdown summary of a named declaration suitable for an LSP
52/// hover response. Returns `None` if no declaration matches.
53pub fn describe_symbol(source: &str, name: &str) -> Option<String> {
54    let tokens = tokenize(source).ok()?;
55    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
56    let unit = unit?;
57    let items: &[CommonsItem] = match &unit {
58        SourceUnit::Commons(c) => &c.items,
59        SourceUnit::Context(c) => &c.items,
60        SourceUnit::Adapter(a) => &a.items,
61        SourceUnit::Suite(_) => &[],
62    };
63    for item in items {
64        if let Some(summary) = describe_item(item, name) {
65            return Some(summary);
66        }
67    }
68    None
69}
70
71/// v0.121 (ADR 0156): the reserved-keyword doc for the token at `offset` in
72/// `source`, if the cursor sits on one — matched by source text against
73/// `bynk_syntax::keywords::KEYWORDS`, independent of the token's `TokenKind`
74/// (unlike the identifier-only lexical hover fallback above). This is hover's
75/// floor for the mechanical coverage test: every lowercase-initial keyword
76/// gets at least this, even where `describe_symbol` has no richer path for it
77/// (e.g. the testing-track clause keywords — `requires`/`ensures`/`suite`/…).
78pub fn describe_keyword_at(source: &str, offset: usize) -> Option<&'static str> {
79    let tokens = tokenize(source).ok()?;
80    let word = tokens
81        .iter()
82        .find(|t| t.span.start <= offset && offset < t.span.end)
83        .map(|t| &source[t.span.start..t.span.end])?;
84    bynk_syntax::keywords::KEYWORDS
85        .iter()
86        .find(|k| k.word == word)
87        .map(|k| k.meaning)
88}
89
90/// v0.137.0 (ADR 0161): hover for the `key`/`store` *contextual* keywords and
91/// the agent state fields they introduce. Both are lexed as `Ident`s (not
92/// reserved `KEYWORDS`), and the fields they declare are neither `let`/param
93/// locals nor top-level declarations — so neither the keyword fallback nor the
94/// `describe_symbol`/locals paths in the hover handler reach them. This closes
95/// that gap: for the cursor on the `key`/`store` keyword *or* on the field name
96/// it declares, render the field's signature (type, and a `store` field's
97/// `@indexed`/`@bounded`/… annotations) followed by the contextual-keyword doc.
98///
99/// #611 (gap A): a *reference* to a state field inside the agent's body — a
100/// bare read (`lastSeq + 1`), a `:=` write target, an invariant subject, a store
101/// op's receiver (`items.put(…)`) — renders the same hover as its declaration.
102/// State fields are absent from the project index and are not `let`/param
103/// locals, so a reference resolved nowhere before this. The hover handler tries
104/// the locals path first, so a local shadowing a field name still hovers as the
105/// local — matching the checker, which dispatches a store op only on a bare
106/// ident that is *not* in the value scope.
107///
108/// `None` when the cursor is not on an agent's `key`/`store` keyword, its
109/// state-field name, or a reference to one within the agent.
110pub fn describe_agent_state_at(source: &str, offset: usize) -> Option<String> {
111    let tokens = tokenize(source).ok()?;
112    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
113    let unit = unit?;
114    let items: &[CommonsItem] = match &unit {
115        SourceUnit::Commons(c) => &c.items,
116        SourceUnit::Context(c) => &c.items,
117        SourceUnit::Adapter(a) => &a.items,
118        SourceUnit::Suite(_) => &[],
119    };
120    for item in items {
121        let CommonsItem::Agent(a) = item else {
122            continue;
123        };
124        // `key <name>: <type>` — the cursor on the field name, or on the `key`
125        // keyword token immediately preceding it.
126        let on_key_kw = preceding_ident_span(&tokens, source, a.key_name.span, "key")
127            .is_some_and(|s| span_covers(s, offset));
128        if on_key_kw || span_covers(a.key_name.span, offset) {
129            return Some(key_hover(a));
130        }
131        // `store <name>: <kind> <annotations>` — the parser sets each field's
132        // span to start at its `store` keyword, so the keyword span is derivable
133        // without re-scanning.
134        for f in &a.store_fields {
135            let store_kw = Span {
136                file: f.span.file,
137                start: f.span.start,
138                end: f.span.start + "store".len(),
139            };
140            let on_store_kw = source.get(store_kw.start..store_kw.end) == Some("store")
141                && span_covers(store_kw, offset);
142            if on_store_kw || span_covers(f.name.span, offset) {
143                return Some(store_field_hover(f));
144            }
145        }
146        // #611: a reference to `key`/`store` state from within this agent.
147        if !in_state_scope(a, offset) {
148            continue;
149        }
150        let Some((name, name_span)) = ident_at(&tokens, source, offset) else {
151            continue;
152        };
153        // State is referenced by **bare** name, so a member of another value
154        // (`p.items`) is not a state reference even when the names coincide.
155        if is_dot_preceded(source, name_span.start) {
156            continue;
157        }
158        if name == a.key_name.name {
159            return Some(key_hover(a));
160        }
161        if let Some(f) = a.store_fields.iter().find(|f| f.name.name == name) {
162            return Some(store_field_hover(f));
163        }
164    }
165    None
166}
167
168/// #611: true when `offset` sits where an agent's `key`/`store` state is
169/// referenceable by bare name — a handler body, or an invariant/transition
170/// predicate. Deliberately narrower than the agent's own span: the declaration
171/// region names things that are *not* state references, and a store annotation
172/// argument (`@indexed(by: id)`) names a field of the **stored value**, which
173/// must not masquerade as a same-named `key`/`store` field.
174fn in_state_scope(a: &AgentDecl, offset: usize) -> bool {
175    a.handlers.iter().any(|h| span_covers(h.body.span, offset))
176        || a.invariants
177            .iter()
178            .any(|i| span_covers(i.predicate.span, offset))
179        || a.transitions
180            .iter()
181            .any(|t| span_covers(t.predicate.span, offset))
182}
183
184/// The hover for an agent's `key` field — its declaration and every reference.
185fn key_hover(a: &AgentDecl) -> String {
186    let sig = format!("key {}: {}", a.key_name.name, type_ref_str(&a.key_type));
187    render_state_hover(&sig, "key")
188}
189
190/// The hover for a `store` field — its declaration and every reference.
191fn store_field_hover(f: &StoreField) -> String {
192    let mut sig = format!("store {}: {}", f.name.name, store_kind_str(&f.kind));
193    for ann in &f.annotations {
194        sig.push(' ');
195        sig.push_str(&bynk_fmt::annotation_to_string(ann));
196    }
197    render_state_hover(&sig, "store")
198}
199
200/// #611: hover for a `store` field's operation — the `<op>` of a
201/// `<field>.<op>(…)` call on an agent's `store` field (`items.put(id, item)`).
202/// Store operations are checked but never indexed and are not value-receiver
203/// methods, so `qualified_callee_at` (name-receivers only) never reaches them
204/// and they resolved nowhere. Renders the operation's signature from the
205/// enumerable [`bynk_check::store_ops`] registry — generic in the kind's
206/// key/value/element type — over the field's declared kind, which grounds it.
207///
208/// `locals` guards the receiver the way the checker's dispatch does: a store op
209/// is a bare ident receiver that is *not* in the value scope, so a local
210/// shadowing the field name makes this an ordinary value method, not a store op.
211/// `None` when the cursor is not on a store operation of the enclosing agent.
212pub fn describe_store_op_at(
213    source: &str,
214    offset: usize,
215    locals: &[bynk_check::locals::LocalBinding],
216) -> Option<String> {
217    let tokens = tokenize(source).ok()?;
218    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
219    let unit = unit?;
220    let items: &[CommonsItem] = match &unit {
221        SourceUnit::Commons(c) => &c.items,
222        SourceUnit::Context(c) => &c.items,
223        SourceUnit::Adapter(a) => &a.items,
224        SourceUnit::Suite(_) => &[],
225    };
226    // The cursor must sit on the `<op>` of a `<recv>.<op>` access.
227    let (op, op_span) = ident_at(&tokens, source, offset)?;
228    let (recv, recv_start) = receiver_segment_at(source, op_span)?;
229    // The checker dispatches a store op on a **bare** ident receiver only, so a
230    // qualified one is not one: `p.items.contains(…)` is an ordinary value method
231    // on a record field that merely shares a store field's name.
232    if is_dot_preceded(source, recv_start) {
233        return None;
234    }
235    // A local of the receiver's name shadows the store field (the same
236    // by-provenance dispatch) — then this is a value method, not a store op.
237    if bynk_check::locals::locals_at(locals, recv_start)
238        .iter()
239        .any(|b| b.name == recv)
240    {
241        return None;
242    }
243    for item in items {
244        let CommonsItem::Agent(a) = item else {
245            continue;
246        };
247        if !in_state_scope(a, offset) {
248            continue;
249        }
250        let f = a.store_fields.iter().find(|f| f.name.name == recv)?;
251        let sig = bynk_check::store_ops::ops_for(&f.kind.head.name)
252            .iter()
253            .find(|o| o.name == op)?
254            .signature;
255        return Some(format!(
256            "```bynk\n{sig}\n```\n\nA `{}` store operation on `store {}: {}` — the field's \
257             declared kind grounds the operation's type parameters.",
258            f.kind.head.name,
259            f.name.name,
260            store_kind_str(&f.kind),
261        ));
262    }
263    None
264}
265
266/// The identifier token covering `offset` — its text and span — if the cursor is
267/// on one.
268fn ident_at<'a>(
269    tokens: &[bynk_syntax::lexer::Token],
270    source: &'a str,
271    offset: usize,
272) -> Option<(&'a str, Span)> {
273    tokens
274        .iter()
275        .find(|t| t.kind == bynk_syntax::lexer::TokenKind::Ident && span_covers(t.span, offset))
276        .and_then(|t| Some((source.get(t.span.start..t.span.end)?, t.span)))
277}
278
279/// The receiver segment of a `<recv>.<member>` access whose member sits at
280/// `member_span`: the identifier run immediately before the dot, and the offset
281/// it starts at. `None` when the member is not dot-preceded. Shared by every
282/// caller that reads a receiver off the line prefix, so the extraction has one
283/// definition rather than a copy per call site — delegates the actual boundary
284/// scan to `completion::ident_ending_at`.
285fn receiver_segment_at(text: &str, member_span: Span) -> Option<(&str, usize)> {
286    let before = text.get(..member_span.start)?.strip_suffix('.')?;
287    crate::completion::ident_ending_at(before, before.len())
288}
289
290/// True when the identifier starting at `start` is itself the member of a
291/// further access (the `items` of `p.items`) rather than a bare name.
292pub(crate) fn is_dot_preceded(text: &str, start: usize) -> bool {
293    text[..start].ends_with('.')
294}
295
296/// #596: the storage-kind vocabulary a bare receiver is eligible for at
297/// completion's `<recv>.` position — `recv_end` is the offset just past the
298/// receiver identifier (where its dot sat before
299/// `completion::value_receiver_rewrite` dropped it, so `source` here is that
300/// rewritten buffer). Mirrors `describe_store_op_at`'s by-provenance receiver
301/// check (not shadowed by a local, inside the declaring agent's state scope),
302/// but starts from the receiver's own end offset rather than walking back from
303/// an operation token, since completion fires before any member name is
304/// typed — and the rewrite's postcondition already guarantees a bare,
305/// non-dot-qualified name reaches here, so unlike `describe_store_op_at` there
306/// is no further `is_dot_preceded` check to make.
307///
308/// Returns the field's storage-kind head (`"Map"`, `"Cache"`, `"Set"`,
309/// `"Cell"`, `"Log"`) and, for a `Map`, whether its value type is a held
310/// `Connection` (v0.158, ADR 0184: `.entries`/`.keys`/`.values` are refused on
311/// one) — enough for the completion layer to look up each kind's registry
312/// without re-parsing.
313pub fn store_field_kind_at(
314    source: &str,
315    recv_end: usize,
316    locals: &[bynk_check::locals::LocalBinding],
317) -> Option<(String, bool)> {
318    let (recv, recv_start) = crate::completion::ident_ending_at(source, recv_end)?;
319    if bynk_check::locals::locals_at(locals, recv_start)
320        .iter()
321        .any(|b| b.name == recv)
322    {
323        return None;
324    }
325    let tokens = tokenize(source).ok()?;
326    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
327    let unit = unit?;
328    let items: &[CommonsItem] = match &unit {
329        SourceUnit::Commons(c) => &c.items,
330        SourceUnit::Context(c) => &c.items,
331        SourceUnit::Adapter(a) => &a.items,
332        SourceUnit::Suite(_) => &[],
333    };
334    for item in items {
335        let CommonsItem::Agent(a) = item else {
336            continue;
337        };
338        if !in_state_scope(a, recv_end) {
339            continue;
340        }
341        if let Some(f) = a.store_fields.iter().find(|f| f.name.name == recv) {
342            let held = f.kind.head.name == "Map"
343                && f.kind.args.len() == 2
344                && matches!(f.kind.args[1], TypeRef::Connection(..));
345            return Some((f.kind.head.name.clone(), held));
346        }
347    }
348    None
349}
350
351/// v0.140 (ADR 0163): hover for a handler-position annotation (`@cache`). Handler
352/// annotations are not symbols and declare no local, so they miss both the
353/// `describe_symbol` and locals paths — this closes the gap. For the cursor
354/// anywhere within a handler's `@cache( … )` annotation, render the formatted
355/// annotation followed by a prose description of `@cache` and its fields. `None`
356/// when the cursor is not inside a handler annotation.
357pub fn describe_handler_annotation_at(source: &str, offset: usize) -> Option<String> {
358    let tokens = tokenize(source).ok()?;
359    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
360    let unit = unit?;
361    let items: &[CommonsItem] = match &unit {
362        SourceUnit::Commons(c) => &c.items,
363        SourceUnit::Context(c) => &c.items,
364        SourceUnit::Adapter(a) => &a.items,
365        SourceUnit::Suite(_) => &[],
366    };
367    for item in items {
368        let handlers: &[Handler] = match item {
369            CommonsItem::Service(s) => &s.handlers,
370            CommonsItem::Agent(a) => &a.handlers,
371            _ => continue,
372        };
373        for h in handlers {
374            for ann in &h.annotations {
375                if span_covers(ann.span, offset) {
376                    return Some(render_handler_annotation_hover(ann));
377                }
378            }
379        }
380    }
381    None
382}
383
384/// v0.140 (ADR 0163): the spans to classify as `decorator` semantic tokens — each
385/// handler annotation's `@name` (the `@` through the name) and its argument labels
386/// (`maxAge:`, `scope:`). Parsed from `source`; empty when it carries no handler
387/// annotations. Feeds the semantic-tokens producer, which is otherwise a
388/// parse-free index read, so the parse lives here beside the hover parse.
389pub fn handler_annotation_token_spans(source: &str) -> Vec<Span> {
390    let Ok(tokens) = tokenize(source) else {
391        return Vec::new();
392    };
393    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
394    let Some(unit) = unit else {
395        return Vec::new();
396    };
397    let items: &[CommonsItem] = match &unit {
398        SourceUnit::Commons(c) => &c.items,
399        SourceUnit::Context(c) => &c.items,
400        SourceUnit::Adapter(a) => &a.items,
401        SourceUnit::Suite(_) => &[],
402    };
403    let mut spans = Vec::new();
404    for item in items {
405        let handlers: &[Handler] = match item {
406            CommonsItem::Service(s) => &s.handlers,
407            CommonsItem::Agent(a) => &a.handlers,
408            _ => continue,
409        };
410        for h in handlers {
411            for ann in &h.annotations {
412                // The `@name` — from the annotation's leading `@` through its name.
413                spans.push(Span {
414                    file: ann.span.file,
415                    start: ann.span.start,
416                    end: ann.name.span.end,
417                });
418                // Each argument label (`maxAge:`, `scope:`).
419                for arg in &ann.args {
420                    if let Some(label) = &arg.label {
421                        spans.push(label.span);
422                    }
423                }
424            }
425        }
426    }
427    spans
428}
429
430/// The formatted annotation in a code block, plus a prose description for the
431/// closed handler-annotation set. `@cache` and `@limit` (v0.142) carry prose; any
432/// other name (a typo the checker will flag) still hovers as its formatted form so
433/// the surface is never silent.
434fn render_handler_annotation_hover(ann: &Annotation) -> String {
435    let sig = bynk_fmt::annotation_to_string(ann);
436    if ann.name.name == "cache" {
437        return format!(
438            "```bynk\n{sig}\n```\n\n\
439             **`@cache`** — cache this `GET` read. Every eligible `GET` already carries a \
440             synthesised weak `ETag` and is answered `304 Not Modified` on a matching \
441             `If-None-Match`; `@cache` adds a `Cache-Control` freshness window on top.\n\n\
442             - **`maxAge`** — the freshness window, a `Duration` (e.g. `5.minutes`), lowered to \
443             `Cache-Control: max-age`.\n\
444             - **`scope`** — `public` or `private` (default `private`; a shared cache stores the \
445             response only when `public`)."
446        );
447    }
448    // v0.142 (ADR 0165): `@limit` caps the request body size on a write route.
449    if ann.name.name == "limit" {
450        return format!(
451            "```bynk\n{sig}\n```\n\n\
452             **`@limit`** — cap the request body size on this `POST`/`PUT`/`PATCH` route. A \
453             request whose body exceeds the `maxBody` byte ceiling is answered `413 Payload Too \
454             Large`, synthesised before the body is read.\n\n\
455             - **`maxBody`** — the maximum request body size in bytes, a positive `Int`."
456        );
457    }
458    format!("```bynk\n{sig}\n```")
459}
460
461/// True when `offset` falls within `span` (half-open, as hover offsets are).
462fn span_covers(span: Span, offset: usize) -> bool {
463    span.start <= offset && offset < span.end
464}
465
466/// The span of the token immediately preceding the token that begins at
467/// `name_span`, if that preceding token's source text is exactly `kw` — used to
468/// locate a contextual keyword (`key`) that the AST records only by its effect,
469/// not with a span of its own.
470fn preceding_ident_span(
471    tokens: &[bynk_syntax::lexer::Token],
472    source: &str,
473    name_span: Span,
474    kw: &str,
475) -> Option<Span> {
476    let idx = tokens
477        .iter()
478        .position(|t| t.span.start == name_span.start)?;
479    let prev = tokens.get(idx.checked_sub(1)?)?;
480    (source.get(prev.span.start..prev.span.end) == Some(kw)).then_some(prev.span)
481}
482
483/// A code-block field signature followed by the contextual keyword's one-line
484/// doc from [`bynk_syntax::keywords::CONTEXTUAL_KEYWORDS`].
485fn render_state_hover(sig: &str, contextual_kw: &str) -> String {
486    let doc = bynk_syntax::keywords::CONTEXTUAL_KEYWORDS
487        .iter()
488        .find(|k| k.word == contextual_kw)
489        .map(|k| k.meaning)
490        .unwrap_or_default();
491    format!("```bynk\n{sig}\n```\n\n{doc}")
492}
493
494/// v0.122 (editor-currency slice 1): a hover summary for `self` under the
495/// cursor — `self: <Type>`. `self` is a reserved keyword (never an `Ident`, so
496/// it does not flow through `locals_nav`), but a `self` *use* is a typed
497/// expression, so its type is in `expr_types` at the token's span. For a method
498/// the type is the receiver's name; for an agent handler the checker gives
499/// `self` a synthetic record type `__<Agent>Self` (to resolve `self.<key>`),
500/// which is un-synthesised here to `<Agent>`. `None` when the cursor is not on
501/// the `self` keyword or its type is unknown (a broken buffer — `expr_types` is
502/// clean-file-only, so this degrades to the keyword doc, never a wrong type).
503pub fn describe_self_at(
504    text: &str,
505    offset: usize,
506    expr_types: &[(Span, bynk_check::checker::TyId)],
507    tys: &bynk_check::checker::Types,
508) -> Option<String> {
509    let tokens = tokenize(text).ok()?;
510    let on_self = tokens.iter().any(|t| {
511        t.span.start <= offset && offset < t.span.end && &text[t.span.start..t.span.end] == "self"
512    });
513    if !on_self {
514        return None;
515    }
516    let ty = bynk_check::expr_types::type_at_offset(expr_types, offset)?;
517    let display = ty.display(tys);
518    let name = display
519        .strip_prefix("__")
520        .and_then(|s| s.strip_suffix("Self"))
521        .unwrap_or(&display);
522    Some(format!("```bynk\nself: {name}\n```"))
523}
524
525/// v0.123 (editor-currency slice 2, DECISION B): if the identifier at
526/// `ident_span` is the member of an `Upper.member` name-receiver access
527/// (`Clock.now`, `Email.of`), return the full `Recv.member` callee for
528/// [`crate::signature_help::resolve_label`] to resolve to its signature — the
529/// same resolution completion and signature help perform, no new index.
530/// `None` for a bare identifier or a lowercase (value-receiver) method, which
531/// `resolve_label` does not handle.
532pub fn qualified_callee_at(text: &str, ident_span: Span) -> Option<String> {
533    let (recv, _) = receiver_segment_at(text, ident_span)?;
534    if !recv.chars().next()?.is_uppercase() {
535        return None;
536    }
537    let member = text.get(ident_span.start..ident_span.end)?;
538    Some(format!("{recv}.{member}"))
539}
540
541/// Describe a symbol declared in the embedded first-party sources — the `bynk`
542/// and `bynk.cloudflare` adapters and the `bynk.list`/`bynk.map`/`bynk.string`
543/// stdlib. Hover and completion-doc resolution otherwise walk only the project's
544/// files (`walk_bynk_files`), so stdlib/surface symbols had no surfaced signature
545/// or doc; this is the fallback after the project scan. Any `---` doc block on a
546/// first-party declaration rides along (via `describe_fn`/`describe_type`/…),
547/// once the sources carry one.
548pub fn describe_firstparty_symbol(name: &str) -> Option<String> {
549    // The single first-party source list (`bynk-check::firstparty`), so a new
550    // first-party commons is hoverable without a second edit here (#901).
551    bynk_check::firstparty::FIRSTPARTY_SOURCES
552        .iter()
553        .find_map(|(_, src)| describe_symbol(src, name))
554}
555
556/// Slice 6b: the `(unit name, name span)` of every `uses`/`consumes` target in
557/// the source — the clickable ranges for document links. The link's target file
558/// is resolved by the handler through the unit→source map (ADR 0095); this only
559/// finds the spans, so it works on the live buffer regardless of the map.
560pub fn unit_reference_spans(source: &str) -> Vec<(String, Span)> {
561    let Ok(tokens) = tokenize(source) else {
562        return Vec::new();
563    };
564    let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
565        return Vec::new();
566    };
567    let mut out: Vec<(String, Span)> = Vec::new();
568    // A suite links its target (the unit under test) plus any `uses` clauses,
569    // mirroring the `uses`/`consumes` links on the other unit kinds (#609).
570    let (uses, consumes): (&[UsesDecl], &[ConsumesDecl]) = match &unit {
571        SourceUnit::Commons(c) => (&c.uses, &[]),
572        SourceUnit::Context(c) => (&c.uses, &c.consumes),
573        SourceUnit::Adapter(a) => (&a.uses, &a.consumes),
574        SourceUnit::Suite(s) => {
575            out.push((s.target.joined(), s.target.span));
576            (&s.uses, &[])
577        }
578    };
579    for u in uses {
580        out.push((u.target.joined(), u.target.span));
581    }
582    for c in consumes {
583        out.push((c.target.joined(), c.target.span));
584    }
585    out
586}
587
588/// #848: one intra-doc-link candidate scanned from doc-comment text — a
589/// `[Name]` shortcut, a `` [`Name`] `` code-span-wrapped shortcut, or a
590/// `[text][Name]` full reference. `span` is the byte range (into the text the
591/// scanner was given) of the whole construct to rewrite when `name` resolves;
592/// `display` is the link text to keep (`Name` for a shortcut, the code span
593/// including backticks for the code-span form, `text` for a full reference).
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct DocLinkCandidate {
596    pub name: String,
597    pub span: std::ops::Range<usize>,
598    pub display: String,
599}
600
601/// #848: is `s` shaped like a doc-link candidate name — a bare identifier, or
602/// two identifiers joined by `.` (an `Owner.member` reference)? Ordinary
603/// bracketed prose (`[note]`, `[sic]`) is never a candidate.
604fn is_doc_link_name(s: &str) -> bool {
605    fn is_ident(part: &str) -> bool {
606        let mut chars = part.chars();
607        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
608            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
609    }
610    match s.split_once('.') {
611        Some((owner, member)) => is_ident(owner) && is_ident(member),
612        None => is_ident(s),
613    }
614}
615
616/// #848: fenced-code-block byte ranges (paired ` ``` ` lines only — doc
617/// comments are free text, not guaranteed well-formed Markdown, so a `~~~`
618/// fence or an unterminated one just gets a best-effort range to end of text)
619/// and the set of `[label]: destination`-shaped reference-definition labels,
620/// both found in `text` line by line. [`scan_doc_link_candidates`] excludes
621/// both: fenced content is never scanned for candidates, and a label with an
622/// author-defined reference keeps its ordinary Markdown meaning instead of
623/// becoming an intra-doc link.
624fn doc_link_prepass(text: &str) -> (Vec<(usize, usize)>, std::collections::HashSet<String>) {
625    let mut fenced = Vec::new();
626    let mut defined = std::collections::HashSet::new();
627    let mut in_fence = false;
628    let mut fence_start = 0usize;
629    let mut pos = 0usize;
630    loop {
631        let line_end = text[pos..]
632            .find('\n')
633            .map(|i| pos + i + 1)
634            .unwrap_or(text.len());
635        let line = &text[pos..line_end];
636        let trimmed = line.trim_start();
637        if trimmed.starts_with("```") {
638            if in_fence {
639                fenced.push((fence_start, line_end));
640                in_fence = false;
641            } else {
642                fence_start = pos;
643                in_fence = true;
644            }
645        } else if !in_fence {
646            // A reference definition (`[label]: url`) — CommonMark allows up
647            // to 3 leading spaces.
648            let indent = line.len() - trimmed.len();
649            if indent <= 3
650                && let Some(rest) = trimmed.strip_prefix('[')
651                && let Some(close) = rest.find(']')
652            {
653                let label = &rest[..close];
654                if let Some(after_colon) = rest[close + 1..].strip_prefix(':')
655                    && !label.is_empty()
656                    && !after_colon.trim().is_empty()
657                {
658                    defined.insert(label.to_string());
659                }
660            }
661        }
662        if line_end >= text.len() {
663            break;
664        }
665        pos = line_end;
666    }
667    if in_fence {
668        fenced.push((fence_start, text.len()));
669    }
670    (fenced, defined)
671}
672
673/// #848: scans the bracket construct starting at `text[start]` (which must be
674/// `[`) for one of the recognised intra-doc-link forms. `None` when the
675/// bracket isn't well-formed, is an explicit-URL link (`[text](url)`), is a
676/// collapsed reference (`[text][]` — out of scope), names something that
677/// isn't identifier-shaped, or names an author-defined reference label.
678fn scan_bracket_candidate(
679    text: &str,
680    start: usize,
681    defined: &std::collections::HashSet<String>,
682) -> Option<DocLinkCandidate> {
683    let bytes = text.as_bytes();
684    debug_assert_eq!(bytes[start], b'[');
685    // `` [`Name`] `` — a code-span-wrapped shortcut.
686    if bytes.get(start + 1) == Some(&b'`') {
687        let inner_start = start + 2;
688        let close_tick = text[inner_start..].find('`')? + inner_start;
689        if bytes.get(close_tick + 1) != Some(&b']') {
690            return None;
691        }
692        let name = &text[inner_start..close_tick];
693        if !is_doc_link_name(name) || defined.contains(name) {
694            return None;
695        }
696        return Some(DocLinkCandidate {
697            name: name.to_string(),
698            span: start..close_tick + 2,
699            display: text[start + 1..close_tick + 1].to_string(),
700        });
701    }
702    let inner_start = start + 1;
703    let close = text[inner_start..].find(']')? + inner_start;
704    let inner = &text[inner_start..close];
705    let after = close + 1;
706    match bytes.get(after) {
707        // `[text](url)` — an explicit URL, unchanged Markdown.
708        Some(b'(') => None,
709        // A reference-definition line reached mid-scan (pass 1 already
710        // excludes true line-start ones) — never a candidate.
711        Some(b':') => None,
712        // `[text][label]` — a full reference.
713        Some(b'[') => {
714            let label_start = after + 1;
715            let label_close = text[label_start..].find(']')? + label_start;
716            let label = &text[label_start..label_close];
717            // `[text][]` (collapsed reference) is out of scope this increment.
718            if label.is_empty() || !is_doc_link_name(label) || defined.contains(label) {
719                return None;
720            }
721            Some(DocLinkCandidate {
722                name: label.to_string(),
723                span: start..label_close + 1,
724                display: inner.to_string(),
725            })
726        }
727        // `[Name]` — a shortcut.
728        _ => {
729            if !is_doc_link_name(inner) || defined.contains(inner) {
730                return None;
731            }
732            Some(DocLinkCandidate {
733                name: inner.to_string(),
734                span: start..after,
735                display: inner.to_string(),
736            })
737        }
738    }
739}
740
741/// #848: every intra-doc-link candidate in `text`, in order. `[text](url)`,
742/// an author-defined `[label]: url` reference, fenced-code-block content, and
743/// non-identifier bracket content (`[note]`) are excluded — see
744/// `scan_bracket_candidate` and `doc_link_prepass` (private below).
745pub fn scan_doc_link_candidates(text: &str) -> Vec<DocLinkCandidate> {
746    let (fenced, defined) = doc_link_prepass(text);
747    let in_fenced = |i: usize| fenced.iter().any(|&(s, e)| i >= s && i < e);
748    let bytes = text.as_bytes();
749    let mut out = Vec::new();
750    let mut i = 0usize;
751    while i < bytes.len() {
752        if bytes[i] != b'[' || in_fenced(i) {
753            i += 1;
754            continue;
755        }
756        match scan_bracket_candidate(text, i, &defined) {
757            Some(cand) => {
758                i = cand.span.end;
759                out.push(cand);
760            }
761            None => i += 1,
762        }
763    }
764    out
765}
766
767/// #848: the `(candidate name, absolute source span)` of every intra-doc-link
768/// candidate inside every `DocBlock` token in `source` — the clickable
769/// ranges for doc-comment document links, mirroring [`unit_reference_spans`]'s
770/// role for `uses`/`consumes` targets. Spans are computed against the raw,
771/// unstripped doc-block body (`bynk_syntax::lexer::doc_block_body_range`),
772/// not the common-indent-stripped `doc_block_content` text — that stripping
773/// is not offset-preserving, so a span-based caller must avoid it. Only
774/// tokenizes (not a full parse), so links still surface even when the rest of
775/// the file has a parse error elsewhere, as long as tokenization succeeds.
776pub fn doc_link_spans(source: &str) -> Vec<(String, Span)> {
777    let Ok(tokens) = tokenize(source) else {
778        return Vec::new();
779    };
780    let mut out = Vec::new();
781    for t in tokens {
782        if t.kind != bynk_syntax::lexer::TokenKind::DocBlock {
783            continue;
784        }
785        let Some(range) = bynk_syntax::lexer::doc_block_body_range(source, t.span) else {
786            continue;
787        };
788        for cand in scan_doc_link_candidates(&source[range.clone()]) {
789            out.push((
790                cand.name,
791                Span::new(range.start + cand.span.start, range.start + cand.span.end),
792            ));
793        }
794    }
795    out
796}
797
798/// #302: the source's own declared qualified name and its span — the
799/// rewrite target when the file backing this unit is renamed. `None` for a
800/// `suite` (its `SourceUnit::name()` is its *target*'s name, not one of its
801/// own; nothing else addresses a suite by name) or on a parse bail.
802pub fn own_declaration_name(source: &str) -> Option<(String, Span)> {
803    let tokens = tokenize(source).ok()?;
804    let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
805        return None;
806    };
807    if matches!(unit, SourceUnit::Suite(_)) {
808        return None;
809    }
810    let name = unit.name();
811    Some((name.joined(), name.span))
812}
813
814fn describe_item(item: &CommonsItem, name: &str) -> Option<String> {
815    match item {
816        CommonsItem::Type(t) if t.name.name == name => Some(describe_type(t)),
817        // v0.166 (#616): a bare key names a *free* function. A method's identity
818        // is its compound `"Type.method"` key (below); matching one by its bare
819        // method name answered with whichever type declared it first, so
820        // `g.bump()` and even `fn Gauge.bump`'s own declaration rendered
821        // `Counter.bump`. `signature_help::resolve_label` guards its free-fn path
822        // the same way.
823        CommonsItem::Fn(f) if matches!(f.name, FnName::Free(_)) && f.name.ident().name == name => {
824            Some(describe_fn(f))
825        }
826        CommonsItem::Capability(c) if c.name.name == name => Some(describe_capability(c)),
827        CommonsItem::Service(s) if s.name.name == name => Some(describe_service(s)),
828        CommonsItem::Agent(a) if a.name.name == name => Some(describe_agent(a)),
829        CommonsItem::Provider(p) if p.provider_name.name == name => Some(describe_provider(p)),
830        // v0.166 (#616): an actor, keyed by its plain name — the `Actor` index
831        // kind ADR 0190 filed as the clearest evidence that the renderer, not the
832        // ladder, is where these were missing. `by u: User` resolved here and
833        // rendered nothing.
834        CommonsItem::Actor(a) if a.name.name == name => Some(describe_actor(a)),
835        // message-bundles slice 1 (#859): a messages block, keyed by its tag —
836        // the same top-level, plain-name convention as Capability/Service/
837        // Agent above.
838        CommonsItem::Messages(m) if m.tag == name => Some(describe_messages(m)),
839        // #972 (Events slice 3a): an event, keyed by its plain name — the same
840        // top-level convention as the arms above. Reuses `describe_type` via
841        // the synthetic `TypeDecl` `as_type_decl` already builds for every
842        // other event-as-type consumer (exports/consumes/construction).
843        CommonsItem::Event(e) if e.name.name == name => Some(describe_event(e)),
844        // #611 (gap B): a record field, keyed `"Type.field"` by the index — the
845        // checker records construction labels and field accesses as `Field` refs,
846        // so hover resolves the key but had no arm to render it and fell through
847        // to the locals path, which name-matches in scope (a `title:` label bound
848        // to a same-named handler param). Top-level names carry no `.`, so the
849        // compound key can only match here.
850        CommonsItem::Type(t) => {
851            let (owner, field) = name.rsplit_once('.')?;
852            if t.name.name != owner {
853                return None;
854            }
855            let TypeBody::Record(r) = &t.body else {
856                return None;
857            };
858            r.fields
859                .iter()
860                .find(|f| f.name.name == field)
861                .map(|f| describe_record_field(t, f))
862        }
863        // #972 (Events slice 3a): an event field, keyed `"Event.field"` —
864        // mirrors the `CommonsItem::Type` arm above via the same synthetic
865        // `TypeDecl`, so a defaulted event field's hover also renders its
866        // `= <expr>`.
867        CommonsItem::Event(e) => {
868            let (owner, field) = name.rsplit_once('.')?;
869            if e.name.name != owner {
870                return None;
871            }
872            let synthetic = e.as_type_decl();
873            e.body
874                .fields
875                .iter()
876                .find(|f| f.name.name == field)
877                .map(|f| describe_record_field(&synthetic, f))
878        }
879        // v0.166 (#616): a method, keyed `"Type.method"` (ADR 0069). `display()`
880        // renders exactly that key, so the compound name matches the one method
881        // it names — the type prefix is what disambiguates `Counter.bump` from
882        // `Gauge.bump`.
883        CommonsItem::Fn(f) => (f.name.display() == name).then(|| describe_fn(f)),
884        // v0.166 (#616): a capability operation, keyed `"Cap.op"` (ADR 0069).
885        CommonsItem::Capability(c) => {
886            let (owner, op) = name.rsplit_once('.')?;
887            if c.name.name != owner {
888                return None;
889            }
890            c.ops
891                .iter()
892                .find(|o| o.name.name == op)
893                .map(|o| describe_capability_op(c, o))
894        }
895        // #304: an agent handler, keyed `"Agent.handler"` — the checker
896        // records a dispatch call (`agentInstance.handler(...)`) as this
897        // compound key, mirroring the method/field/op convention above.
898        CommonsItem::Agent(a) => {
899            let (owner, handler) = name.rsplit_once('.')?;
900            if a.name.name != owner {
901                return None;
902            }
903            a.handlers
904                .iter()
905                .find(|h| h.method_name.as_ref().is_some_and(|n| n.name == handler))
906                .map(|h| describe_agent_handler(a, h, handler))
907        }
908        _ => None,
909    }
910}
911
912/// v0.166 (#616): an actor as declared — the `auth` scheme with its config, the
913/// `identity` type, or the refinement form's base and claim predicate.
914/// Mirrors `bynk-fmt`'s `format_actor`, as [`describe_agent`] mirrors an agent.
915///
916/// #847: `pub(crate)` so the documentation-view aggregator ([`crate::documentation`])
917/// renders each declaration through the *same* signature+doc assembly hover uses,
918/// rather than a parallel renderer that could drift from it.
919pub(crate) fn describe_actor(a: &ActorDecl) -> String {
920    let mut out = String::from("```bynk\n");
921    match &a.refinement {
922        // `actor Admin = User where hasClaim("admin")` (ADR 0091).
923        Some(r) => out.push_str(&format!(
924            "actor {} = {} where {}",
925            a.name.name,
926            r.base.name,
927            bynk_fmt::expr_to_string(&r.predicate)
928        )),
929        None => {
930            // An absent `auth` is the `None` scheme, which is how it parses.
931            let auth = a.auth.as_ref().map_or("None", |i| i.name.as_str());
932            out.push_str(&format!("actor {} {{ auth = {auth}", a.name.name));
933            if !a.auth_config.is_empty() {
934                let args: Vec<String> = a
935                    .auth_config
936                    .iter()
937                    .map(|arg| match &arg.value {
938                        // The parser resolves escapes at lex time, so the stored
939                        // value is *unescaped* — re-escape it through the
940                        // formatter's own escaper, or a `"` in the config renders
941                        // as invalid Bynk inside the fence below.
942                        SchemeArgValue::Str(s) => {
943                            format!("{} = \"{}\"", arg.key.name, bynk_fmt::escape_string(s))
944                        }
945                        SchemeArgValue::Int(n) => format!("{} = {n}", arg.key.name),
946                    })
947                    .collect();
948                out.push_str(&format!("({})", args.join(", ")));
949            }
950            if let Some(id) = &a.identity {
951                out.push_str(&format!(", identity = {}", type_ref_str(id)));
952            }
953            out.push_str(" }");
954        }
955    }
956    out.push_str("\n```\n");
957    if let Some(doc) = &a.documentation {
958        out.push('\n');
959        out.push_str(doc);
960        out.push('\n');
961    }
962    out
963}
964
965/// #926: an op's own `[T, …]` type parameters, rendered for hover — `""` when
966/// the op is non-generic. Shared by [`describe_capability_op`] and
967/// [`describe_capability`] so the two signature renderings can't drift.
968fn capability_op_type_params_str(op: &CapabilityOp) -> String {
969    if op.type_params.is_empty() {
970        return String::new();
971    }
972    let names: Vec<&str> = op
973        .type_params
974        .iter()
975        .map(|tp| tp.name.name.as_str())
976        .collect();
977    format!("[{}]", names.join(", "))
978}
979
980/// v0.166 (#616): a capability operation as declared, attributed to the
981/// capability that owns it. Mirrors how [`describe_capability`] renders the same
982/// op within the capability body, as [`describe_record_field`] does for a field.
983pub(crate) fn describe_capability_op(c: &CapabilityDecl, op: &CapabilityOp) -> String {
984    let params: Vec<String> = op
985        .params
986        .iter()
987        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
988        .collect();
989    let mut out = format!(
990        "```bynk\nfn {}{}({}) -> {}\n```\n\nAn operation of capability `{}`.\n",
991        op.name.name,
992        capability_op_type_params_str(op),
993        params.join(", "),
994        type_ref_str(&op.return_type),
995        c.name.name
996    );
997    if let Some(doc) = &op.documentation {
998        out.push('\n');
999        out.push_str(doc);
1000        out.push('\n');
1001    }
1002    out
1003}
1004
1005/// #304: an agent handler as declared, attributed to the agent that owns it —
1006/// its dispatch name, params, and return type. Mirrors [`describe_capability_op`].
1007pub(crate) fn describe_agent_handler(a: &AgentDecl, h: &Handler, handler_name: &str) -> String {
1008    let params: Vec<String> = h
1009        .params
1010        .iter()
1011        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1012        .collect();
1013    let mut out = format!(
1014        "```bynk\nfn {}({}) -> {}\n```\n\nA handler of agent `{}`.\n",
1015        handler_name,
1016        params.join(", "),
1017        type_ref_str(&h.return_type),
1018        a.name.name
1019    );
1020    if let Some(doc) = &h.documentation {
1021        out.push('\n');
1022        out.push_str(doc);
1023        out.push('\n');
1024    }
1025    out
1026}
1027
1028/// #847: a service handler as declared, attributed to the service that owns it —
1029/// its route/protocol (`on GET("/x")`, `on call`, …) with the typed params and
1030/// return type. The documentation-view counterpart to [`describe_agent_handler`]:
1031/// a service handler has no compound index key (its route, not a dispatch name,
1032/// identifies it — see `bynk-lsp/src/sequence_request.rs`), so hover never
1033/// describes one on its own; the doc page is the first surface that renders each
1034/// individually, and it does so through the same fenced-signature + doc-prose
1035/// shape every other `describe_*` uses.
1036pub(crate) fn describe_service_handler(s: &ServiceDecl, h: &Handler) -> String {
1037    let params: Vec<String> = h
1038        .params
1039        .iter()
1040        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1041        .collect();
1042    let mut out = format!(
1043        "```bynk\n{}({}) -> {}\n```\n\nA handler of service `{}`.\n",
1044        handler_line(h),
1045        params.join(", "),
1046        type_ref_str(&h.return_type),
1047        s.name.name
1048    );
1049    if let Some(doc) = &h.documentation {
1050        out.push('\n');
1051        out.push_str(doc);
1052        out.push('\n');
1053    }
1054    out
1055}
1056
1057/// #611: a record field as declared — its type and any `where` refinement —
1058/// attributed to the record that owns it. Mirrors how [`describe_type`] renders
1059/// the same field within the record body.
1060pub(crate) fn describe_record_field(t: &TypeDecl, f: &RecordField) -> String {
1061    let mut sig = format!("{}: {}", f.name.name, type_ref_str(&f.type_ref));
1062    if let Some(r) = &f.refinement {
1063        sig.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1064    }
1065    // #972 (Events slice 3a): render a field default the same way the
1066    // formatter does — `= <expr>` — so a subscriber-visible default is not
1067    // silently dropped from hover.
1068    if let Some(init) = &f.init {
1069        sig.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1070    }
1071    format!("```bynk\n{sig}\n```\n\nA field of `{}`.", t.name.name)
1072}
1073
1074pub(crate) fn describe_type(t: &TypeDecl) -> String {
1075    let mut out = String::new();
1076    out.push_str("```bynk\n");
1077    // v0.157 (ADR 0183): render `[A, B]` type parameters on a generic type.
1078    let params = if t.type_params.is_empty() {
1079        String::new()
1080    } else {
1081        let names: Vec<&str> = t
1082            .type_params
1083            .iter()
1084            .map(|tp| tp.name.name.as_str())
1085            .collect();
1086        format!("[{}]", names.join(", "))
1087    };
1088    out.push_str(&format!("type {}{} = ", t.name.name, params));
1089    match &t.body {
1090        // v0.123 (slice 2): render the refined/opaque `where` predicate (was
1091        // collapsed to the bare base) via the formatter's own renderer.
1092        TypeBody::Refined {
1093            base, refinement, ..
1094        } => {
1095            out.push_str(base.name());
1096            if let Some(r) = refinement {
1097                out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1098            }
1099        }
1100        TypeBody::Opaque {
1101            base, refinement, ..
1102        } => {
1103            out.push_str(&format!("opaque {}", base.name()));
1104            if let Some(r) = refinement {
1105                out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1106            }
1107        }
1108        // Record fields, one per line (was collapsed to `record`).
1109        TypeBody::Record(r) => {
1110            if r.fields.is_empty() {
1111                out.push_str("{}");
1112            } else {
1113                out.push_str("{\n");
1114                for f in &r.fields {
1115                    out.push_str(&format!("\t{}: {}", f.name.name, type_ref_str(&f.type_ref)));
1116                    if let Some(r) = &f.refinement {
1117                        out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1118                    }
1119                    // #972 (Events slice 3a): an event field's default,
1120                    // reachable here via `EventDecl::as_type_decl`'s
1121                    // synthetic `TypeDecl`.
1122                    if let Some(init) = &f.init {
1123                        out.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1124                    }
1125                    out.push_str(",\n");
1126                }
1127                out.push('}');
1128            }
1129        }
1130        // Sum variants, with payloads (was collapsed to `sum`).
1131        TypeBody::Sum(s) => {
1132            out.push_str("enum {\n");
1133            for v in &s.variants {
1134                out.push_str(&format!("\t{}", v.name.name));
1135                if !v.payload.is_empty() {
1136                    let parts: Vec<String> = v
1137                        .payload
1138                        .iter()
1139                        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1140                        .collect();
1141                    out.push_str(&format!("({})", parts.join(", ")));
1142                }
1143                out.push_str(",\n");
1144            }
1145            out.push('}');
1146        }
1147    }
1148    out.push_str("\n```\n");
1149    if let Some(doc) = &t.documentation {
1150        out.push('\n');
1151        out.push_str(doc);
1152        out.push('\n');
1153    }
1154    out
1155}
1156
1157/// Events slice 3b (#978): additively render an event's `@schema(N)` (or any
1158/// future event annotation) into its hover, the same rendering
1159/// `store_field_hover` already uses. `describe_type`'s signature is shared
1160/// with every ordinary type's hover, so this post-processes its output for
1161/// the one synthetic-`TypeDecl` caller that has annotations to show, rather
1162/// than widening that signature. A no-op when the event declares none —
1163/// absent-annotation hover stays byte-identical to before this slice.
1164fn describe_event(e: &EventDecl) -> String {
1165    let base = describe_type(&e.as_type_decl());
1166    if e.annotations.is_empty() {
1167        return base;
1168    }
1169    let annotations: String = e
1170        .annotations
1171        .iter()
1172        .map(|a| format!(" {}", bynk_fmt::annotation_to_string(a)))
1173        .collect();
1174    let from = format!("type {} = ", e.name.name);
1175    let to = format!("type {}{annotations} = ", e.name.name);
1176    base.replacen(&from, &to, 1)
1177}
1178
1179pub(crate) fn describe_fn(f: &FnDecl) -> String {
1180    let mut out = String::new();
1181    out.push_str("```bynk\n");
1182    out.push_str("fn ");
1183    out.push_str(&f.name.display());
1184    out.push('(');
1185    let mut parts: Vec<String> = Vec::new();
1186    if f.has_self {
1187        parts.push("self".into());
1188    }
1189    for p in &f.params {
1190        parts.push(format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)));
1191    }
1192    out.push_str(&parts.join(", "));
1193    out.push_str(") -> ");
1194    out.push_str(&type_ref_str(&f.return_type));
1195    // v0.123 (slice 2): the contract clauses (v0.115), beneath the signature —
1196    // rendered through the formatter's own predicate renderer.
1197    for c in &f.requires {
1198        out.push_str(&format!(
1199            "\n\trequires {}: {}",
1200            c.name.name,
1201            bynk_fmt::expr_to_string(&c.predicate)
1202        ));
1203    }
1204    for c in &f.ensures {
1205        out.push_str(&format!(
1206            "\n\tensures {}: {}",
1207            c.name.name,
1208            bynk_fmt::expr_to_string(&c.predicate)
1209        ));
1210    }
1211    out.push_str("\n```\n");
1212    if let Some(doc) = &f.documentation {
1213        out.push('\n');
1214        out.push_str(doc);
1215        out.push('\n');
1216    }
1217    out
1218}
1219
1220pub(crate) fn describe_capability(c: &CapabilityDecl) -> String {
1221    let mut out = String::new();
1222    out.push_str("```bynk\ncapability ");
1223    out.push_str(&c.name.name);
1224    out.push_str(" {\n");
1225    for op in &c.ops {
1226        out.push_str("\tfn ");
1227        out.push_str(&op.name.name);
1228        out.push_str(&capability_op_type_params_str(op));
1229        out.push('(');
1230        let parts: Vec<String> = op
1231            .params
1232            .iter()
1233            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1234            .collect();
1235        out.push_str(&parts.join(", "));
1236        out.push_str(") -> ");
1237        out.push_str(&type_ref_str(&op.return_type));
1238        out.push('\n');
1239    }
1240    out.push_str("}\n```\n");
1241    if let Some(doc) = &c.documentation {
1242        out.push('\n');
1243        out.push_str(doc);
1244        out.push('\n');
1245    }
1246    out
1247}
1248
1249/// message-bundles slice 1 (#859): a message bundle's tag, its annotations
1250/// (`@reference`, unresolved cardinality shown as-is — that's a checker
1251/// concern, not hover's), and its declared codes.
1252pub(crate) fn describe_messages(m: &MessagesDecl) -> String {
1253    let mut out = String::new();
1254    out.push_str("```bynk\nmessages ");
1255    out.push('"');
1256    out.push_str(&m.tag);
1257    out.push('"');
1258    for ann in &m.annotations {
1259        out.push(' ');
1260        out.push_str(&bynk_fmt::annotation_to_string(ann));
1261    }
1262    out.push_str(" {\n");
1263    for entry in &m.entries {
1264        // message-bundles slice 3 (#878): name any non-plain (plural/
1265        // select/number/date) placeholders inline, so an author can tell
1266        // at a glance which codes carry ICU dispatch without expanding the
1267        // (elided) template text itself.
1268        let icu_kinds: Vec<String> = bynk_check::icu::template_format_kinds(&entry.template)
1269            .into_iter()
1270            .map(|(name, kind)| (name.to_string(), kind.as_str()))
1271            .filter(|(_, kind)| *kind != "plain")
1272            .map(|(name, kind)| format!("{name}: {kind}"))
1273            .collect();
1274        if icu_kinds.is_empty() {
1275            out.push_str(&format!("\t\"{}\" => …\n", entry.code));
1276        } else {
1277            out.push_str(&format!(
1278                "\t\"{}\" => …  // {}\n",
1279                entry.code,
1280                icu_kinds.join(", ")
1281            ));
1282        }
1283    }
1284    out.push_str("}\n```\n");
1285    if let Some(doc) = &m.documentation {
1286        out.push('\n');
1287        out.push_str(doc);
1288        out.push('\n');
1289    }
1290    out
1291}
1292
1293/// v0.123 (slice 2): the `from <protocol>` header suffix for a service, or the
1294/// empty string for a plain `on call` service.
1295fn service_protocol_suffix(p: &ServiceProtocol) -> String {
1296    match p {
1297        ServiceProtocol::Call => String::new(),
1298        ServiceProtocol::Http => " from http".to_string(),
1299        ServiceProtocol::Cron => " from cron".to_string(),
1300        ServiceProtocol::Queue { name } => format!(" from queue(\"{name}\")"),
1301        ServiceProtocol::WebSocket { .. } => " from websocket".to_string(),
1302        // Events track slice 1 (spine #936): a subscription pattern is not
1303        // rendered in this short suffix, matching how WebSocket's in/out
1304        // types are also omitted here — this is a one-line summary label,
1305        // not a full re-render of the header.
1306        ServiceProtocol::Events { event_type, .. } => {
1307            format!(" from Events({})", type_ref_str(event_type))
1308        }
1309    }
1310}
1311
1312/// v0.131: a one-line summary of a `cors { }` policy for hover — the origins
1313/// (always present), then `credentials`/`maxAge` when set.
1314fn cors_summary(cors: &CorsPolicy) -> String {
1315    let mut parts = vec![format!("origins: {:?}", cors.origins())];
1316    if cors.credentials() {
1317        parts.push("credentials: true".to_string());
1318    }
1319    if let Some(secs) = cors.max_age_secs() {
1320        parts.push(format!("maxAge: {secs}s"));
1321    }
1322    parts.join(", ")
1323}
1324
1325/// v0.141: a one-line summary of a `security { }` policy for hover — `nosniff`
1326/// (default on, shown when off) and `hsts` (when opted in).
1327fn security_summary(security: &SecurityPolicy) -> String {
1328    let mut parts = Vec::new();
1329    if !security.nosniff() {
1330        parts.push("nosniff: false".to_string());
1331    }
1332    if let Some(secs) = security.hsts_max_age_secs() {
1333        parts.push(format!("hsts: {secs}s"));
1334    }
1335    if parts.is_empty() {
1336        // The default posture (nosniff on, no HSTS) with an empty block.
1337        "nosniff".to_string()
1338    } else {
1339        parts.join(", ")
1340    }
1341}
1342
1343/// v0.142 (ADR 0165): a one-line summary of a `limits { }` policy for hover — the
1344/// `maxBody` byte ceiling when set.
1345fn limits_summary(limits: &LimitsPolicy) -> String {
1346    match limits.max_body() {
1347        Some(bytes) => format!("maxBody: {bytes} bytes"),
1348        None => "maxBody".to_string(),
1349    }
1350}
1351
1352/// v0.123 (slice 2): the `on …` line for a handler — its route/protocol shape.
1353pub(crate) fn handler_line(h: &Handler) -> String {
1354    match &h.kind {
1355        HandlerKind::Call => "on call".to_string(),
1356        HandlerKind::Http { method, path } => format!("on {}(\"{}\")", method.as_str(), path),
1357        HandlerKind::Cron { expr } => format!("on schedule(\"{expr}\")"),
1358        HandlerKind::Message => "on message".to_string(),
1359        HandlerKind::Open => "on open".to_string(),
1360        HandlerKind::Close => "on close".to_string(),
1361        HandlerKind::Event => "on event".to_string(),
1362    }
1363}
1364
1365pub(crate) fn describe_service(s: &ServiceDecl) -> String {
1366    // v0.123 (slice 2): the protocol header and a line per route (was a bare
1367    // handler count).
1368    let mut out = format!(
1369        "```bynk\nservice {}{} {{\n",
1370        s.name.name,
1371        service_protocol_suffix(&s.protocol)
1372    );
1373    // v0.131: the CORS policy, if any, renders as a `cors { … }` header line
1374    // summarising the origins (the load-bearing field).
1375    if let Some(cors) = &s.cors {
1376        out.push_str(&format!("\tcors {{ {} }}\n", cors_summary(cors)));
1377    }
1378    // v0.141: the security-headers policy, if declared, renders similarly.
1379    if let Some(security) = &s.security {
1380        out.push_str(&format!(
1381            "\tsecurity {{ {} }}\n",
1382            security_summary(security)
1383        ));
1384    }
1385    // v0.142 (ADR 0165): the request-limits policy, if declared, renders similarly.
1386    if let Some(limits) = &s.limits {
1387        out.push_str(&format!("\tlimits {{ {} }}\n", limits_summary(limits)));
1388    }
1389    for h in &s.handlers {
1390        out.push_str(&format!("\t{}\n", handler_line(h)));
1391    }
1392    out.push_str("}\n```\n");
1393    if let Some(doc) = &s.documentation {
1394        out.push('\n');
1395        out.push_str(doc);
1396        out.push('\n');
1397    }
1398    out
1399}
1400
1401/// v0.123 (slice 2): a store field's kind — `Cell[Int]`, `Map[K, V]`, or a bare
1402/// head with no type args.
1403fn store_kind_str(k: &StoreKind) -> String {
1404    if k.args.is_empty() {
1405        k.head.name.clone()
1406    } else {
1407        let args: Vec<String> = k.args.iter().map(type_ref_str).collect();
1408        format!("{}[{}]", k.head.name, args.join(", "))
1409    }
1410}
1411
1412pub(crate) fn describe_agent(a: &AgentDecl) -> String {
1413    // v0.123 (slice 2): the store fields plus the `invariant`/`transition` step
1414    // invariants (v0.116), was a bare store-field count.
1415    let mut out = format!(
1416        "```bynk\nagent {} {{\n\tkey {}: {}\n",
1417        a.name.name,
1418        a.key_name.name,
1419        type_ref_str(&a.key_type),
1420    );
1421    for f in &a.store_fields {
1422        out.push_str(&format!(
1423            "\tstore {}: {}\n",
1424            f.name.name,
1425            store_kind_str(&f.kind)
1426        ));
1427    }
1428    for inv in &a.invariants {
1429        out.push_str(&format!(
1430            "\tinvariant {}: {}\n",
1431            inv.name.name,
1432            bynk_fmt::expr_to_string(&inv.predicate)
1433        ));
1434    }
1435    for tr in &a.transitions {
1436        out.push_str(&format!(
1437            "\ttransition {}: {}\n",
1438            tr.name.name,
1439            bynk_fmt::expr_to_string(&tr.predicate)
1440        ));
1441    }
1442    out.push_str("}\n```\n");
1443    if let Some(doc) = &a.documentation {
1444        out.push('\n');
1445        out.push_str(doc);
1446        out.push('\n');
1447    }
1448    out
1449}
1450
1451pub(crate) fn describe_provider(p: &ProviderDecl) -> String {
1452    let mut out = format!(
1453        "```bynk\nprovides {} = {}\n```\n",
1454        p.capability.name, p.provider_name.name
1455    );
1456    if let Some(doc) = &p.documentation {
1457        out.push('\n');
1458        out.push_str(doc);
1459        out.push('\n');
1460    }
1461    out
1462}
1463
1464/// A cross-file declaration lookup result: the path of the file containing
1465/// the declaration, the declaration's source span, and the full source
1466/// text of that file (returned because callers need it to convert the
1467/// span to an LSP range and to build hover content).
1468pub struct CrossFileSymbol {
1469    pub path: PathBuf,
1470    pub span: Span,
1471    pub source: String,
1472}
1473
1474/// The project's files, in the same deterministic order
1475/// `bynk_project::discover_bynk_files` produces (sorted by path) —
1476/// `HashMap` iteration order is unspecified, and "the first hit wins" only
1477/// means something if that order is stable and reproducible.
1478fn sorted_paths(files: &HashMap<PathBuf, String>) -> Vec<&PathBuf> {
1479    let mut paths: Vec<&PathBuf> = files.keys().collect();
1480    paths.sort();
1481    paths
1482}
1483
1484/// Find `name`'s declaration in any project file other than `current_path`.
1485/// Content-ownership track (#1086) slice 1: `files` is a pre-read
1486/// `(path, content)` map (the caller's overlay-then-disk sweep) rather than
1487/// bare paths this function used to read from disk itself. Returns the
1488/// first hit, in path-sorted order (see `sorted_paths`); `None` if the name
1489/// is not found anywhere in the project.
1490///
1491/// Caller is responsible for trying the open file's local symbol table
1492/// first; this function intentionally skips `current_path` so the local
1493/// path remains the fast path.
1494pub fn find_declaration_cross_file(
1495    files: &HashMap<PathBuf, String>,
1496    current_path: &Path,
1497    name: &str,
1498) -> Option<CrossFileSymbol> {
1499    for path in sorted_paths(files) {
1500        if path.as_path() == current_path {
1501            continue;
1502        }
1503        let source = &files[path];
1504        if let Some(span) = find_declaration_span(source, name) {
1505            return Some(CrossFileSymbol {
1506                path: path.clone(),
1507                span,
1508                source: source.clone(),
1509            });
1510        }
1511    }
1512    None
1513}
1514
1515/// Markdown hover content for `name` from any project file other than
1516/// `current_path`, plus the path of the file that contributed it. See
1517/// `find_declaration_cross_file`'s doc for the content-ownership-track
1518/// shape of `files`. Returns `None` if the name is not declared anywhere in
1519/// the project.
1520pub fn describe_symbol_cross_file(
1521    files: &HashMap<PathBuf, String>,
1522    current_path: &Path,
1523    name: &str,
1524) -> Option<(PathBuf, String)> {
1525    for path in sorted_paths(files) {
1526        if path.as_path() == current_path {
1527            continue;
1528        }
1529        let source = &files[path];
1530        if let Some(desc) = describe_symbol(source, name) {
1531            return Some((path.clone(), desc));
1532        }
1533    }
1534    None
1535}
1536
1537pub fn type_ref_str(t: &TypeRef) -> String {
1538    match t {
1539        // v0.20a: function types render in Bynk surface syntax.
1540        TypeRef::Fn(params, ret, _) => {
1541            let lhs = match params.len() {
1542                0 => "()".to_string(),
1543                1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_str(&params[0]),
1544                _ => format!(
1545                    "({})",
1546                    params
1547                        .iter()
1548                        .map(type_ref_str)
1549                        .collect::<Vec<_>>()
1550                        .join(", ")
1551                ),
1552            };
1553            format!("{lhs} -> {}", type_ref_str(ret))
1554        }
1555        TypeRef::Base(b, _) => b.name().to_string(),
1556        TypeRef::Named(id) => id.name.clone(),
1557        TypeRef::Result(a, b, _) => format!("Result[{}, {}]", type_ref_str(a), type_ref_str(b)),
1558        TypeRef::Option(t, _) => format!("Option[{}]", type_ref_str(t)),
1559        TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_str(t)),
1560        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_str(t)),
1561        TypeRef::QueueResult(_) => "QueueResult".to_string(),
1562        // v0.20b: the built-in collection types.
1563        TypeRef::List(t, _) => format!("List[{}]", type_ref_str(t)),
1564        TypeRef::Query(t, _) => format!("Query[{}]", type_ref_str(t)),
1565        TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_str(t)),
1566        TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_str(t)),
1567        TypeRef::History(t, _) => format!("History[{}]", type_ref_str(t)),
1568        TypeRef::Map(k, v, _) => format!("Map[{}, {}]", type_ref_str(k), type_ref_str(v)),
1569        TypeRef::ValidationError(_) => "ValidationError".to_string(),
1570        TypeRef::JsonError(_) => "JsonError".to_string(),
1571        TypeRef::Unit(_) => "()".to_string(),
1572        // v0.157 (ADR 0183): a user generic-type application, as written.
1573        TypeRef::App { name, args, .. } => format!(
1574            "{}[{}]",
1575            name.name,
1576            args.iter().map(type_ref_str).collect::<Vec<_>>().join(", ")
1577        ),
1578    }
1579}
1580
1581#[cfg(test)]
1582mod tests {
1583    use super::*;
1584
1585    /// Content-ownership track (#1086) slice 1: `find_declaration_cross_file`/
1586    /// `describe_symbol_cross_file` take a pre-read `(path, content)` map
1587    /// rather than bare paths they used to read from disk themselves, so the
1588    /// tests below build that map directly — a synthetic root plus a
1589    /// `(relative_path, contents)` list, no real filesystem I/O needed at all.
1590    fn project_files(root: &Path, files: &[(&str, &str)]) -> HashMap<PathBuf, String> {
1591        files
1592            .iter()
1593            .map(|(rel, contents)| (root.join(rel), (*contents).to_string()))
1594            .collect()
1595    }
1596
1597    #[test]
1598    fn receiver_segment_survives_a_multibyte_char_before_the_receiver() {
1599        // A multi-byte non-identifier char before the receiver used to make the
1600        // `i + 1` byte offset land mid-codepoint → panic on slice (#715),
1601        // reached from hover on a `recv.member` access inside a string literal.
1602        let text = "\"€p.items"; // member `items` sits after the dot
1603        let member_start = text.rfind("items").unwrap();
1604        let (recv, start) =
1605            receiver_segment_at(text, Span::new(member_start, text.len())).expect("receiver");
1606        assert_eq!(recv, "p");
1607        assert_eq!(&text[start..start + recv.len()], "p");
1608    }
1609
1610    #[test]
1611    fn cross_file_definition_resolves_into_sibling_file() {
1612        let root = PathBuf::from("/synthetic/cross_file_definition");
1613        let files = project_files(
1614            &root,
1615            &[
1616                (
1617                    "a.bynk",
1618                    "commons demo.a\n\ntype Foo = Int where Positive\n",
1619                ),
1620                (
1621                    "b.bynk",
1622                    "commons demo.b\n\nuses demo.a\n\ntype Bar = Int where NonNegative\n",
1623                ),
1624            ],
1625        );
1626        let current = root.join("b.bynk");
1627        let found = find_declaration_cross_file(&files, &current, "Foo")
1628            .expect("Foo should resolve into a.bynk");
1629        let expected = root.join("a.bynk");
1630        assert_eq!(found.path, expected);
1631        assert!(
1632            found.source.contains("type Foo = Int where Positive"),
1633            "source returned does not contain Foo declaration"
1634        );
1635    }
1636
1637    #[test]
1638    fn cross_file_definition_skips_current_file() {
1639        let root = PathBuf::from("/synthetic/cross_file_skip_current");
1640        let files = project_files(
1641            &root,
1642            &[(
1643                "only.bynk",
1644                "commons demo.only\n\ntype Foo = Int where Positive\n",
1645            )],
1646        );
1647        let current = root.join("only.bynk");
1648        // The only file containing Foo is current; cross-file must skip it.
1649        assert!(find_declaration_cross_file(&files, &current, "Foo").is_none());
1650    }
1651
1652    #[test]
1653    fn cross_file_hover_returns_markdown_summary() {
1654        let root = PathBuf::from("/synthetic/cross_file_hover");
1655        let files = project_files(
1656            &root,
1657            &[
1658                (
1659                    "money.bynk",
1660                    "commons demo.money\n\n\
1661                     ---\n\
1662                     Amount in minor units of currency.\n\
1663                     ---\n\
1664                     type Money = Int where NonNegative\n",
1665                ),
1666                (
1667                    "orders.bynk",
1668                    "commons demo.orders\n\nuses demo.money\n\ntype OrderId = Int where Positive\n",
1669                ),
1670            ],
1671        );
1672        let current = root.join("orders.bynk");
1673        let (other_path, desc) = describe_symbol_cross_file(&files, &current, "Money")
1674            .expect("Money should produce hover content");
1675        assert_eq!(other_path, root.join("money.bynk"));
1676        assert!(desc.contains("type Money"));
1677        assert!(
1678            desc.contains("Amount in minor units"),
1679            "hover should include the doc block"
1680        );
1681    }
1682
1683    #[test]
1684    fn cross_file_returns_none_for_unknown_name() {
1685        let root = PathBuf::from("/synthetic/cross_file_none");
1686        let files = project_files(
1687            &root,
1688            &[(
1689                "a.bynk",
1690                "commons demo.a\n\ntype Foo = Int where Positive\n",
1691            )],
1692        );
1693        let current = root.join("a.bynk");
1694        assert!(find_declaration_cross_file(&files, &current, "DoesNotExist").is_none());
1695        assert!(describe_symbol_cross_file(&files, &current, "DoesNotExist").is_none());
1696    }
1697
1698    #[test]
1699    fn first_party_symbols_describe_their_signature_and_doc() {
1700        // Slice 9: stdlib/surface symbols live in the embedded sources, not the
1701        // project — the hover/completion-doc fallback finds them there, signature
1702        // and `---` doc block alike.
1703        let reverse = describe_firstparty_symbol("reverse").expect("`bynk.list.reverse` described");
1704        assert!(
1705            reverse.contains("reverse") && reverse.contains("List"),
1706            "{reverse}"
1707        );
1708        assert!(
1709            reverse.contains("reverse order"),
1710            "doc block surfaced: {reverse}"
1711        );
1712        // The `bynk` adapter surface too (a capability, exercising the adapter path).
1713        let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
1714        assert!(
1715            clock.contains("wall-clock"),
1716            "capability doc surfaced: {clock}"
1717        );
1718        // #901: the whole locale surface hovered as nothing before
1719        // `bynk.locale`/`bynk.locale.types` reached the shared source list. A
1720        // type from the leaf, and a fn from the value-level commons.
1721        let locale_tag = describe_firstparty_symbol("LocaleTag")
1722            .expect("`bynk.locale.types.LocaleTag` described");
1723        assert!(
1724            locale_tag.contains("LocaleTag") && locale_tag.contains("Matches"),
1725            "{locale_tag}"
1726        );
1727        let render = describe_firstparty_symbol("render").expect("`bynk.locale.render` described");
1728        assert!(
1729            render.contains("render") && render.contains("LocaleTag"),
1730            "{render}"
1731        );
1732        let with_text =
1733            describe_firstparty_symbol("withText").expect("`bynk.locale.withText` described");
1734        assert!(with_text.contains("withText"), "{with_text}");
1735        // A name in no first-party source yields nothing (the fallback no-ops).
1736        assert!(describe_firstparty_symbol("DoesNotExist").is_none());
1737    }
1738
1739    #[test]
1740    fn unit_reference_spans_finds_uses_and_consumes_targets() {
1741        // Slice 6b: the clickable ranges for document links — `uses`/`consumes`
1742        // unit names, with spans covering the name (resolution is the handler's).
1743        let src = "context app.main\n  uses billing.charge\n  consumes platform.time\n";
1744        let spans = unit_reference_spans(src);
1745        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1746        assert!(names.contains(&"billing.charge"), "{names:?}");
1747        assert!(names.contains(&"platform.time"), "{names:?}");
1748        // The span covers exactly the unit name (so the link underlines it).
1749        let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
1750        assert_eq!(&src[span.start..span.end], "billing.charge");
1751    }
1752
1753    #[test]
1754    fn unit_reference_spans_links_the_suite_target() {
1755        // #609: the `suite <target>` header links to the unit under test, and any
1756        // `uses` clauses the fragment brings in link like the other unit kinds.
1757        let src = "suite todos\n  uses billing.charge\n";
1758        let spans = unit_reference_spans(src);
1759        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1760        assert!(names.contains(&"todos"), "{names:?}");
1761        assert!(names.contains(&"billing.charge"), "{names:?}");
1762        // The span covers exactly the target name (so the link underlines it).
1763        let (_, span) = spans.iter().find(|(n, _)| n == "todos").unwrap();
1764        assert_eq!(&src[span.start..span.end], "todos");
1765    }
1766
1767    #[test]
1768    fn scan_doc_link_candidates_finds_a_bare_shortcut() {
1769        let cands = scan_doc_link_candidates("See [Limiter] for details.");
1770        assert_eq!(cands.len(), 1);
1771        assert_eq!(cands[0].name, "Limiter");
1772        assert_eq!(cands[0].display, "Limiter");
1773        assert_eq!(
1774            &"See [Limiter] for details."[cands[0].span.clone()],
1775            "[Limiter]"
1776        );
1777    }
1778
1779    #[test]
1780    fn scan_doc_link_candidates_finds_a_dotted_shortcut() {
1781        let cands = scan_doc_link_candidates("See [RateView.remaining].");
1782        assert_eq!(cands.len(), 1);
1783        assert_eq!(cands[0].name, "RateView.remaining");
1784    }
1785
1786    #[test]
1787    fn scan_doc_link_candidates_finds_a_code_span_shortcut() {
1788        let text = "The [`RateView.remaining`] field is never negative.";
1789        let cands = scan_doc_link_candidates(text);
1790        assert_eq!(cands.len(), 1);
1791        assert_eq!(cands[0].name, "RateView.remaining");
1792        assert_eq!(cands[0].display, "`RateView.remaining`");
1793        assert_eq!(&text[cands[0].span.clone()], "[`RateView.remaining`]");
1794    }
1795
1796    #[test]
1797    fn scan_doc_link_candidates_finds_a_full_reference() {
1798        let cands = scan_doc_link_candidates("See [the limiter][Limiter] for details.");
1799        assert_eq!(cands.len(), 1);
1800        assert_eq!(cands[0].name, "Limiter");
1801        assert_eq!(cands[0].display, "the limiter");
1802    }
1803
1804    #[test]
1805    fn scan_doc_link_candidates_leaves_an_explicit_url_untouched() {
1806        let cands = scan_doc_link_candidates("See [Limiter](https://example.com).");
1807        assert!(cands.is_empty(), "{cands:?}");
1808    }
1809
1810    #[test]
1811    fn scan_doc_link_candidates_leaves_an_author_defined_reference_untouched() {
1812        let text = "See [foo] for details.\n\n[foo]: https://example.com\n";
1813        let cands = scan_doc_link_candidates(text);
1814        assert!(cands.is_empty(), "{cands:?}");
1815    }
1816
1817    #[test]
1818    fn scan_doc_link_candidates_ignores_non_identifier_bracket_content() {
1819        let cands = scan_doc_link_candidates("See [see note] and [1] below.");
1820        assert!(cands.is_empty(), "{cands:?}");
1821    }
1822
1823    #[test]
1824    fn scan_doc_link_candidates_ignores_fenced_code_block_content() {
1825        let text = "Example:\n```\n[Bracket, Syntax]\n[label]: not-a-real-def\n```\nSee [Limiter].";
1826        let cands = scan_doc_link_candidates(text);
1827        assert_eq!(cands.len(), 1);
1828        assert_eq!(cands[0].name, "Limiter");
1829    }
1830
1831    #[test]
1832    fn scan_doc_link_candidates_does_not_recognise_the_collapsed_reference_form() {
1833        // `[label][]` (CommonMark's "collapsed reference") is deliberately out
1834        // of scope this increment — use the shortcut form `[label]` instead.
1835        let cands = scan_doc_link_candidates("See [Limiter][].");
1836        assert!(cands.is_empty(), "{cands:?}");
1837    }
1838
1839    #[test]
1840    fn doc_link_spans_reports_absolute_offsets_in_an_indented_doc_block() {
1841        let src = "context app.main\n  ---\n  See [Limiter] here.\n  ---\n  service api {}\n";
1842        let spans = doc_link_spans(src);
1843        assert_eq!(spans.len(), 1);
1844        let (name, span) = &spans[0];
1845        assert_eq!(name, "Limiter");
1846        assert_eq!(&src[span.start..span.end], "[Limiter]");
1847    }
1848
1849    #[test]
1850    fn doc_link_spans_finds_links_across_multiple_doc_blocks() {
1851        let src = "---\nSee [Foo].\n---\ntype Foo = Int\n\n---\nSee [Bar].\n---\ntype Bar = Int\n";
1852        let spans = doc_link_spans(src);
1853        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
1854        assert_eq!(names, vec!["Foo", "Bar"]);
1855    }
1856
1857    #[test]
1858    fn own_declaration_name_finds_context_and_commons_names() {
1859        // #302: the rename target when a unit's own file moves.
1860        let src = "context app.main\n  uses billing.charge\n";
1861        let (name, span) = own_declaration_name(src).expect("context has its own name");
1862        assert_eq!(name, "app.main");
1863        assert_eq!(&src[span.start..span.end], "app.main");
1864
1865        let src = "commons app.util\n\ntype Foo = Int where Positive\n";
1866        let (name, _) = own_declaration_name(src).expect("commons has its own name");
1867        assert_eq!(name, "app.util");
1868    }
1869
1870    #[test]
1871    fn own_declaration_name_none_for_suites() {
1872        // A suite's `SourceUnit::name()` is its *target*'s name, not its own —
1873        // nothing `uses`/`consumes` a suite by name, so renaming its file
1874        // needs no declaration rewrite.
1875        let src = "suite todos\n";
1876        assert!(own_declaration_name(src).is_none());
1877    }
1878
1879    // v0.123 (slice 2): hover renders the real shape of each declaration —
1880    // record fields, sum variants, the refined `where`, the opaque base.
1881    #[test]
1882    fn describe_type_renders_fields_variants_and_refinements() {
1883        let record = describe_symbol(
1884            "commons demo.m\n\ntype Order = {\n  id: OrderId,\n  total: Money,\n}\n",
1885            "Order",
1886        )
1887        .unwrap();
1888        assert!(record.contains("type Order = {"), "{record}");
1889        assert!(record.contains("id: OrderId"), "{record}");
1890        assert!(record.contains("total: Money"), "{record}");
1891
1892        let sum = describe_symbol(
1893            "commons demo.m\n\ntype Status = enum { Pending, Shipped }\n",
1894            "Status",
1895        )
1896        .unwrap();
1897        assert!(sum.contains("enum {"), "{sum}");
1898        assert!(sum.contains("Pending") && sum.contains("Shipped"), "{sum}");
1899
1900        let refined = describe_symbol(
1901            "commons demo.m\n\ntype Email = String where NonEmpty\n",
1902            "Email",
1903        )
1904        .unwrap();
1905        assert!(
1906            refined.contains("type Email = String where NonEmpty"),
1907            "{refined}"
1908        );
1909
1910        let opaque =
1911            describe_symbol("commons demo.m\n\ntype Token = opaque String\n", "Token").unwrap();
1912        assert!(opaque.contains("type Token = opaque String"), "{opaque}");
1913    }
1914
1915    // v0.123 (slice 2): a function's `requires`/`ensures` contracts render
1916    // beneath its signature.
1917    #[test]
1918    fn describe_fn_renders_contracts() {
1919        let src = "commons demo.m\n\nfn discount(p: Int, pct: Int) -> Int\n  requires p_nonneg: p >= 0\n  ensures never_negative: result >= 0\n{\n  p\n}\n";
1920        let out = describe_symbol(src, "discount").unwrap();
1921        assert!(
1922            out.contains("fn discount(p: Int, pct: Int) -> Int"),
1923            "{out}"
1924        );
1925        assert!(out.contains("requires p_nonneg: p >= 0"), "{out}");
1926        assert!(out.contains("ensures never_negative: result >= 0"), "{out}");
1927    }
1928
1929    // v0.123 (slice 2): a service renders its protocol header and route lines.
1930    #[test]
1931    fn describe_service_renders_routes() {
1932        let src = "context demo.app\n\nservice greeter {\n  on call(name: String) -> Effect[String] {\n    Effect.pure(name)\n  }\n}\n";
1933        let out = describe_symbol(src, "greeter").unwrap();
1934        assert!(out.contains("service greeter {"), "{out}");
1935        assert!(out.contains("on call"), "{out}");
1936    }
1937
1938    // v0.123 (slice 2): an agent renders its store fields and the
1939    // `invariant`/`transition` step invariants.
1940    #[test]
1941    fn describe_agent_renders_store_and_invariants() {
1942        let src = "context demo.app\n\nagent Counter {\n  key id: String\n  store count: Cell[Int] = 0\n  invariant non_negative: count >= 0\n  transition monotonic: new.count >= old.count\n  on call bump() -> Effect[Result[(), String]] {\n    Ok(())\n  }\n}\n";
1943        let out = describe_symbol(src, "Counter").unwrap();
1944        assert!(out.contains("agent Counter {"), "{out}");
1945        assert!(out.contains("key id: String"), "{out}");
1946        assert!(out.contains("store count: Cell[Int]"), "{out}");
1947        assert!(out.contains("invariant non_negative: count >= 0"), "{out}");
1948        assert!(
1949            out.contains("transition monotonic: new.count >= old.count"),
1950            "{out}"
1951        );
1952    }
1953
1954    // v0.123 (slice 2, DECISION B): the `Recv.member` detection that feeds
1955    // capability-op call-site hover through `resolve_label`.
1956    #[test]
1957    fn qualified_callee_detects_upper_receiver_only() {
1958        let text = "  let t = Clock.now()";
1959        let now = text.find("now").unwrap();
1960        assert_eq!(
1961            qualified_callee_at(text, Span::new(now, now + 3)).as_deref(),
1962            Some("Clock.now")
1963        );
1964        // A lowercase (value) receiver is not our case — resolve_label can't
1965        // resolve it anyway.
1966        let text2 = "  xs.fold(0)";
1967        let fold = text2.find("fold").unwrap();
1968        assert!(qualified_callee_at(text2, Span::new(fold, fold + 4)).is_none());
1969        // A bare identifier (no receiver) → None.
1970        let text3 = "  total";
1971        assert!(qualified_callee_at(text3, Span::new(2, 7)).is_none());
1972    }
1973
1974    // v0.137.0 (ADR 0161): hover for the `key`/`store` contextual keywords and
1975    // the agent state fields they introduce — on the keyword or on the field
1976    // name alike, with a `store` field's annotations rendered.
1977    #[test]
1978    fn describe_agent_state_covers_key_store_keywords_and_fields() {
1979        let src = "context demo.app\n\nagent Sessions {\n  key id: String\n  store items: Map[String, Int] @indexed( by: id ) @bounded( 10000 )\n  on call read() -> Effect[Int] {\n    Effect.pure(0)\n  }\n}\n";
1980
1981        // The `key` keyword and the key field name both render `key id: String`
1982        // plus the contextual-keyword doc.
1983        let at_key_kw = src.find("key id").unwrap();
1984        let key_kw = describe_agent_state_at(src, at_key_kw).expect("hover on `key`");
1985        assert!(key_kw.contains("key id: String"), "{key_kw}");
1986        assert!(key_kw.contains("identity field"), "doc line: {key_kw}");
1987        let at_key_name = src.find("id: String").unwrap();
1988        let key_name = describe_agent_state_at(src, at_key_name).expect("hover on the key field");
1989        assert_eq!(key_kw, key_name, "keyword and name hover match");
1990
1991        // The `store` keyword and the store field name both render the field
1992        // signature — kind and annotations — plus the doc.
1993        let at_store_kw = src.find("store items").unwrap();
1994        let store_kw = describe_agent_state_at(src, at_store_kw).expect("hover on `store`");
1995        assert!(
1996            store_kw.contains("store items: Map[String, Int]"),
1997            "{store_kw}"
1998        );
1999        assert!(
2000            store_kw.contains("@indexed(by: id)"),
2001            "annotation: {store_kw}"
2002        );
2003        assert!(
2004            store_kw.contains("@bounded(10000)"),
2005            "annotation: {store_kw}"
2006        );
2007        assert!(
2008            store_kw.contains("persisted agent-state"),
2009            "doc line: {store_kw}"
2010        );
2011        let at_store_name = src.find("items:").unwrap();
2012        let store_name =
2013            describe_agent_state_at(src, at_store_name).expect("hover on the store field");
2014        assert_eq!(store_kw, store_name, "keyword and name hover match");
2015
2016        // Not on a `key`/`store` keyword or state-field name → no hover (the
2017        // agent name, and the store kind, both fall through to other paths).
2018        assert!(describe_agent_state_at(src, src.find("Sessions").unwrap()).is_none());
2019        assert!(describe_agent_state_at(src, src.find("Map").unwrap()).is_none());
2020        // The word `id` inside `by: id` is an annotation argument, not the key
2021        // field's declaration — it must not masquerade as the key field.
2022        assert!(describe_agent_state_at(src, src.find("by: id").unwrap() + 4).is_none());
2023    }
2024
2025    /// #611 (gap A): the reference half of the test above. Hover on a `key`/
2026    /// `store` field *use* inside the agent body — the case that resolved
2027    /// nowhere: state fields are absent from the project index and are not
2028    /// `let`/param locals, so every earlier hover path misses them.
2029    const TODOS: &str = "context demo.todos\n\
2030        \n\
2031        agent Todos {\n\
2032        \x20 key owner: String\n\
2033        \n\
2034        \x20 store items:   Map[String, Int]\n\
2035        \x20 store lastSeq: Cell[Int]\n\
2036        \n\
2037        \x20 invariant nonneg: lastSeq >= 0\n\
2038        \n\
2039        \x20 on call add(n: Int) -> Effect[()] {\n\
2040        \x20   let next = lastSeq + 1\n\
2041        \x20   let _ <- items.put(owner, next)\n\
2042        \x20   lastSeq := next\n\
2043        \x20   Effect.pure(())\n\
2044        \x20 }\n\
2045        }\n";
2046
2047    #[test]
2048    fn describe_agent_state_covers_references_in_handler_bodies() {
2049        let src = TODOS;
2050        let at = |needle: &str| src.find(needle).expect("needle is in the fixture");
2051
2052        // Every reference renders exactly what the declaration renders.
2053        let store_decl = describe_agent_state_at(src, at("store lastSeq")).unwrap();
2054        assert!(
2055            store_decl.contains("store lastSeq: Cell[Int]"),
2056            "{store_decl}"
2057        );
2058        for (what, needle) in [
2059            ("a bare read", "lastSeq + 1"),
2060            ("a `:=` write target", "lastSeq := next"),
2061            ("an invariant subject", "lastSeq >= 0"),
2062        ] {
2063            let hover = describe_agent_state_at(src, at(needle))
2064                .unwrap_or_else(|| panic!("no hover on {what}"));
2065            assert_eq!(hover, store_decl, "{what} hovers as its declaration");
2066        }
2067        // A store op's receiver — the `items` half of `items.put(…)`.
2068        let recv = describe_agent_state_at(src, at("items.put")).expect("hover on the receiver");
2069        assert_eq!(
2070            recv,
2071            describe_agent_state_at(src, at("store items")).unwrap()
2072        );
2073
2074        // The `key` field is referenceable the same way.
2075        let key_decl = describe_agent_state_at(src, at("key owner")).unwrap();
2076        let key_ref = describe_agent_state_at(src, at("owner, next")).expect("hover on `owner`");
2077        assert_eq!(key_ref, key_decl);
2078        assert!(key_ref.contains("key owner: String"), "{key_ref}");
2079
2080        // A name that is not state, and a state-shaped name outside the agent's
2081        // reference scope, both fall through to the other hover paths.
2082        assert!(describe_agent_state_at(src, at("next = lastSeq")).is_none());
2083        assert!(describe_agent_state_at(src, at("Effect.pure")).is_none());
2084    }
2085
2086    /// #611 (gap C): hover on a `store` field's operation renders the registry
2087    /// signature over the field's declared kind.
2088    #[test]
2089    fn describe_store_op_renders_the_operation_signature() {
2090        let src = TODOS;
2091        let at_put = src
2092            .find("put(owner")
2093            .expect("the store op is in the fixture");
2094        let put = describe_store_op_at(src, at_put, &[]).expect("hover on `items.put`");
2095        assert!(put.contains("put(key: K, value: V) -> Effect[()]"), "{put}");
2096        // The field's declared kind grounds `K`/`V`, so it rides along.
2097        assert!(put.contains("store items: Map[String, Int]"), "{put}");
2098
2099        // A local shadowing the receiver makes this an ordinary value method,
2100        // not a store op — mirroring the checker's by-provenance dispatch.
2101        let shadow = [bynk_check::locals::LocalBinding {
2102            name: "items".into(),
2103            def_span: Span::new(0, 5),
2104            kind: bynk_check::locals::LocalKind::Let,
2105            ty: "Map[String, Int]".into(),
2106            scope: Span::new(0, src.len()),
2107        }];
2108        assert!(describe_store_op_at(src, at_put, &shadow).is_none());
2109
2110        // An operation the kind does not have, a receiver that is not a store
2111        // field, and a non-member identifier all fall through.
2112        let cell_put = src.replace("items.put(owner, next)", "lastSeq.put(owner, next)");
2113        assert!(
2114            describe_store_op_at(&cell_put, cell_put.find("put(owner").unwrap(), &[]).is_none(),
2115            "a `Cell` has no `put`"
2116        );
2117        assert!(describe_store_op_at(src, src.find("pure(()").unwrap(), &[]).is_none());
2118        assert!(describe_store_op_at(src, src.find("next = lastSeq").unwrap(), &[]).is_none());
2119    }
2120
2121    /// A store op binds a **bare** ident receiver. A *qualified* receiver
2122    /// (`p.items.put(…)`) is an ordinary value method on a record field that
2123    /// merely shares a store field's name — the same class of confidently-wrong
2124    /// hover as gap B, and invisible to the index (which does not cover value
2125    /// methods), so nothing upstream would catch it.
2126    #[test]
2127    fn a_qualified_receiver_is_not_a_store_op_or_a_state_reference() {
2128        let src = "context demo.todos\n\
2129            \n\
2130            type Inner = { put: Int }\n\
2131            type Payload = { items: Inner, lastSeq: Int }\n\
2132            \n\
2133            agent Todos {\n\
2134            \x20 key owner: String\n\
2135            \x20 store items:   Map[String, Int]\n\
2136            \x20 store lastSeq: Cell[Int]\n\
2137            \n\
2138            \x20 on call add(p: Payload) -> Effect[()] {\n\
2139            \x20   let a = p.items.put\n\
2140            \x20   let b = p.lastSeq\n\
2141            \x20   Effect.pure(())\n\
2142            \x20 }\n\
2143            }\n";
2144        // `p.items.put` — the `put` is a field of `Inner`, not the store op.
2145        let at_put = src.find("put\n").expect("the qualified member");
2146        assert!(describe_store_op_at(src, at_put, &[]).is_none());
2147        // …and the `items` in `p.items` is a field of `Payload`, not the store
2148        // field — the same root cause, in the state-reference pass.
2149        let at_items = src.find("p.items").unwrap() + "p.".len();
2150        assert!(describe_agent_state_at(src, at_items).is_none());
2151        let at_seq = src.find("p.lastSeq").unwrap() + "p.".len();
2152        assert!(describe_agent_state_at(src, at_seq).is_none());
2153
2154        // The bare forms in the same body still resolve.
2155        let bare = src.find("store items").unwrap();
2156        assert!(describe_agent_state_at(src, bare).is_some());
2157    }
2158
2159    /// #611 (gap B): the index resolves a record-construction field label / field
2160    /// access to a `Field` key (`"Stored.title"`); hover must render it rather
2161    /// than fall through to the locals path, which name-matches in scope and
2162    /// bound `title:` to a same-named handler param.
2163    #[test]
2164    fn describe_symbol_renders_a_resolved_record_field() {
2165        let src = "context demo.todos\n\n\
2166            type Title = String where NonEmpty\n\n\
2167            type Stored = {\n\
2168            \x20 seq:   Int where NonNegative,\n\
2169            \x20 title: Title,\n\
2170            }\n";
2171        let title = describe_symbol(src, "Stored.title").expect("hover on `Stored.title`");
2172        assert!(title.contains("title: Title"), "{title}");
2173        assert!(title.contains("A field of `Stored`"), "{title}");
2174        // A field refinement rides along, as it does in the record body.
2175        let seq = describe_symbol(src, "Stored.seq").expect("hover on `Stored.seq`");
2176        assert!(seq.contains("seq: Int where NonNegative"), "{seq}");
2177
2178        // The bare type name still renders the type, not a field.
2179        assert!(
2180            describe_symbol(src, "Stored")
2181                .unwrap()
2182                .contains("type Stored")
2183        );
2184        // An unknown field, an unknown owner, and a non-record owner yield none.
2185        assert!(describe_symbol(src, "Stored.nope").is_none());
2186        assert!(describe_symbol(src, "Nope.title").is_none());
2187        assert!(describe_symbol(src, "Title.title").is_none());
2188    }
2189
2190    // #972 (Events slice 3a): an event's own hover and its per-field hover
2191    // both render a declared default, mirroring an ordinary record's hover
2192    // (above) — this is the arm that was entirely missing before this slice
2193    // (no `CommonsItem::Event` case at all), not just a formatting gap.
2194    #[test]
2195    fn describe_symbol_renders_an_event_and_its_defaulted_field() {
2196        let src = "context demo.orders\n\n\
2197            type Region = enum { Domestic, International }\n\n\
2198            event PaymentConfirmed = {\n\
2199            \x20 orderId: String,\n\
2200            \x20 region: Region = Region.Domestic,\n\
2201            }\n";
2202        let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
2203        assert!(whole.contains("type PaymentConfirmed = {"), "{whole}");
2204        assert!(whole.contains("orderId: String"), "{whole}");
2205        assert!(
2206            whole.contains("region: Region = Region.Domestic"),
2207            "{whole}"
2208        );
2209
2210        let field = describe_symbol(src, "PaymentConfirmed.region")
2211            .expect("hover on `PaymentConfirmed.region`");
2212        assert!(
2213            field.contains("region: Region = Region.Domestic"),
2214            "{field}"
2215        );
2216        assert!(field.contains("A field of `PaymentConfirmed`"), "{field}");
2217
2218        // The non-defaulted field renders with no trailing `= ...`.
2219        let order_id = describe_symbol(src, "PaymentConfirmed.orderId")
2220            .expect("hover on `PaymentConfirmed.orderId`");
2221        assert!(order_id.contains("orderId: String"), "{order_id}");
2222        assert!(!order_id.contains('='), "{order_id}");
2223
2224        assert!(describe_symbol(src, "PaymentConfirmed.nope").is_none());
2225        assert!(describe_symbol(src, "Nope.region").is_none());
2226    }
2227
2228    // Events slice 3b (#978): a declared `@schema(N)` renders in the
2229    // event's own hover, additively — an event with none (the test above)
2230    // stays byte-identical.
2231    #[test]
2232    fn describe_symbol_renders_an_events_schema_annotation() {
2233        let src = "context demo.orders\n\n\
2234            event PaymentConfirmed @schema(2) = {\n\
2235            \x20 orderId: String,\n\
2236            }\n";
2237        let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
2238        assert!(
2239            whole.contains("type PaymentConfirmed @schema(2) = {"),
2240            "{whole}"
2241        );
2242        assert!(whole.contains("orderId: String"), "{whole}");
2243    }
2244
2245    /// v0.166 (#616): the actor arm — both declaration forms. The reference-offset
2246    /// fixture in `hover_references.rs` covers the `Bearer` form against real
2247    /// analysis output; the schemes without config and ADR 0091's refinement form
2248    /// are declared by no example project, so they are pinned here.
2249    #[test]
2250    fn describe_symbol_renders_an_actor_in_both_forms() {
2251        let src = "context demo.auth\n\n\
2252            type UserId = String where NonEmpty\n\n\
2253            ---\n\
2254            A signed-in user.\n\
2255            ---\n\
2256            actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }\n\n\
2257            actor Public { auth = None }\n\n\
2258            actor Worker { auth = Internal }\n\n\
2259            actor Admin = User where hasClaim(\"admin\")\n";
2260
2261        let user = describe_symbol(src, "User").expect("hover on `User`");
2262        assert!(
2263            user.contains(
2264                "actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }"
2265            ),
2266            "{user}"
2267        );
2268        // The doc block rides along, as it does for every other declaration.
2269        assert!(user.contains("A signed-in user."), "{user}");
2270
2271        // A scheme with no config and no identity renders neither.
2272        let public = describe_symbol(src, "Public").expect("hover on `Public`");
2273        assert!(
2274            public.contains("actor Public { auth = None }") && !public.contains("identity"),
2275            "{public}"
2276        );
2277        assert!(
2278            describe_symbol(src, "Worker")
2279                .unwrap()
2280                .contains("actor Worker { auth = Internal }")
2281        );
2282
2283        // ADR 0091's refinement form renders its base and claim predicate.
2284        let admin = describe_symbol(src, "Admin").expect("hover on `Admin`");
2285        assert!(
2286            admin.contains("actor Admin = User where hasClaim(\"admin\")"),
2287            "{admin}"
2288        );
2289
2290        assert!(describe_symbol(src, "Nobody").is_none());
2291    }
2292
2293    /// v0.166 (#616, review): the actor arm claims to mirror `bynk-fmt`'s
2294    /// `format_actor`, so it must escape a scheme-config string the same way.
2295    /// `SchemeArgValue::Str` holds the value *unescaped* — the parser resolves
2296    /// `\"`/`\\`/`\n`/`\t` at lex time — so rendering it raw put invalid Bynk
2297    /// inside a ```bynk fence. Pinned against the formatter's own output rather
2298    /// than a hand-written expectation: a copy would agree only until one moved.
2299    #[test]
2300    fn describe_symbol_escapes_an_actors_scheme_config() {
2301        let src = "context demo.auth\n\n\
2302            actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }\n";
2303        let hover = describe_symbol(src, "User").expect("hover on `User`");
2304        assert!(
2305            hover.contains("actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }"),
2306            "the config value must round-trip escaped:\n{hover}"
2307        );
2308
2309        // The fenced declaration is exactly what the formatter emits for it.
2310        let formatted = bynk_fmt::format_source(src, &bynk_fmt::FormatOptions::default())
2311            .expect("the fixture formats");
2312        let actor_line = formatted
2313            .lines()
2314            .find(|l| l.starts_with("actor User"))
2315            .expect("the actor line");
2316        assert!(
2317            hover.contains(actor_line),
2318            "hover:\n{hover}\nfmt: {actor_line}"
2319        );
2320    }
2321
2322    /// v0.166 (#616): the capability-op arm, keyed `"Cap.op"` (ADR 0069) —
2323    /// attributed to its owner, as a field is to its record.
2324    #[test]
2325    fn describe_symbol_renders_a_capability_operation() {
2326        let src = "context demo.svc\n\n\
2327            capability Logger {\n\
2328            \x20 ---\n\
2329            \x20 Record a line.\n\
2330            \x20 ---\n\
2331            \x20 fn info(message: String) -> Effect[()]\n\
2332            }\n\n\
2333            capability Clock {\n\
2334            \x20 fn now() -> Effect[Int]\n\
2335            }\n";
2336
2337        let info = describe_symbol(src, "Logger.info").expect("hover on `Logger.info`");
2338        assert!(
2339            info.contains("fn info(message: String) -> Effect[()]"),
2340            "{info}"
2341        );
2342        assert!(
2343            info.contains("An operation of capability `Logger`"),
2344            "{info}"
2345        );
2346        assert!(info.contains("Record a line."), "{info}");
2347
2348        // A no-arg op on another capability — the owner is what disambiguates.
2349        let now = describe_symbol(src, "Clock.now").expect("hover on `Clock.now`");
2350        assert!(now.contains("fn now() -> Effect[Int]"), "{now}");
2351
2352        // The bare capability name still renders the capability itself.
2353        assert!(
2354            describe_symbol(src, "Logger")
2355                .unwrap()
2356                .contains("capability Logger")
2357        );
2358        // An unknown op, and an op read against the wrong owner, yield none.
2359        assert!(describe_symbol(src, "Logger.nope").is_none());
2360        assert!(describe_symbol(src, "Clock.info").is_none());
2361    }
2362
2363    #[test]
2364    fn describe_symbol_renders_a_messages_bundle() {
2365        // message-bundles slice 1 (#859): describe_item's Messages arm — a
2366        // silent hover gap before this slice (the wildcard fell through to
2367        // "no hover" for a construct that didn't exist yet).
2368        let src = "commons app.bundle\n\n\
2369            ---\n\
2370            The English reference bundle.\n\
2371            ---\n\
2372            messages \"en\" @reference {\n\
2373            \x20 \"greeting\" => \"Hello, {name}!\"\n\
2374            \x20 \"farewell\" => \"Bye\"\n\
2375            }\n";
2376
2377        let info = describe_symbol(src, "en").expect("hover on the `en` messages tag");
2378        assert!(info.contains("messages \"en\" @reference {"), "{info}");
2379        assert!(info.contains("\"greeting\" => …"), "{info}");
2380        assert!(info.contains("\"farewell\" => …"), "{info}");
2381        assert!(info.contains("The English reference bundle."), "{info}");
2382    }
2383
2384    /// v0.166 (#616, ADR 0191 D2): a bare key names a *free* function. Matching a
2385    /// method on its bare name answered with whichever type declared it first —
2386    /// `Gauge.bump`'s own declaration rendered `Counter.bump` — and silently
2387    /// outranked the index's real answer.
2388    #[test]
2389    fn describe_symbol_keys_methods_by_their_compound_name_only() {
2390        let src = "context demo.shop\n\n\
2391            type Counter = { count: Int }\n\
2392            type Gauge = { level: Int }\n\n\
2393            fn Counter.bump(self) -> Counter { Counter { count: self.count + 1 } }\n\n\
2394            fn Gauge.bump(self) -> Gauge { Gauge { level: self.level + 1 } }\n\n\
2395            fn free(n: Int) -> Int { n }\n";
2396
2397        // The type prefix is the identity: each compound key renders its own.
2398        let counter = describe_symbol(src, "Counter.bump").expect("hover on `Counter.bump`");
2399        assert!(
2400            counter.contains("fn Counter.bump(self) -> Counter") && !counter.contains("Gauge"),
2401            "{counter}"
2402        );
2403        let gauge = describe_symbol(src, "Gauge.bump").expect("hover on `Gauge.bump`");
2404        assert!(
2405            gauge.contains("fn Gauge.bump(self) -> Gauge") && !gauge.contains("Counter"),
2406            "{gauge}"
2407        );
2408
2409        // A bare `bump` names no method: it is a guess between the two, and the
2410        // index's compound key is what resolves them.
2411        assert!(describe_symbol(src, "bump").is_none());
2412        // A free function is still keyed by its bare name.
2413        assert!(
2414            describe_symbol(src, "free")
2415                .unwrap()
2416                .contains("fn free(n: Int) -> Int")
2417        );
2418        assert!(describe_symbol(src, "Counter.nope").is_none());
2419    }
2420
2421    // v0.122 (slice 1): `self` hover renders its receiver/agent type, reading
2422    // the type from `expr_types` and un-synthesising the agent-self record.
2423    #[test]
2424    fn describe_self_renders_receiver_and_unwraps_agent() {
2425        use bynk_check::checker::{NamedKind, Ty, Types};
2426        let text = "self";
2427        let tys = &Types::new();
2428        let span = Span::new(0, 4);
2429        // A method receiver — a plain named type renders verbatim.
2430        let account = vec![(
2431            span,
2432            tys.intern(Ty::Named {
2433                name: "Account".into(),
2434                kind: NamedKind::Record,
2435                args: Vec::new(),
2436            }),
2437        )];
2438        assert_eq!(
2439            describe_self_at(text, 0, &account, tys).as_deref(),
2440            Some("```bynk\nself: Account\n```")
2441        );
2442        // An agent handler — the synthetic `__CounterSelf` record un-synthesises
2443        // to the agent name.
2444        let agent = vec![(
2445            span,
2446            tys.intern(Ty::Named {
2447                name: "__CounterSelf".into(),
2448                kind: NamedKind::Record,
2449                args: Vec::new(),
2450            }),
2451        )];
2452        assert_eq!(
2453            describe_self_at(text, 0, &agent, tys).as_deref(),
2454            Some("```bynk\nself: Counter\n```")
2455        );
2456        // Not on the `self` keyword — a different token yields nothing, even
2457        // when a type sits at the offset.
2458        let other = "total";
2459        assert!(
2460            describe_self_at(
2461                other,
2462                0,
2463                &[(
2464                    Span::new(0, 5),
2465                    tys.intern(Ty::Named {
2466                        name: "Int".into(),
2467                        kind: NamedKind::Record,
2468                        args: Vec::new(),
2469                    }),
2470                )],
2471                tys
2472            )
2473            .is_none()
2474        );
2475    }
2476
2477    const CACHE_SVC: &str = "context api\nservice api from http {\n  @cache(maxAge: 5.minutes, scope: public)\n  on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor {\n    Ok(\"y\")\n  }\n}\n";
2478
2479    #[test]
2480    fn hover_on_cache_annotation_describes_it() {
2481        // Offset on the `cache` name token.
2482        let offset = CACHE_SVC.find("cache").unwrap() + 1;
2483        let hover = describe_handler_annotation_at(CACHE_SVC, offset).expect("hovers @cache");
2484        assert!(hover.contains("`@cache`"), "names the annotation: {hover}");
2485        assert!(hover.contains("maxAge"), "documents maxAge: {hover}");
2486        assert!(hover.contains("scope"), "documents scope: {hover}");
2487        // Off the annotation (on the `Ok` body) — no annotation hover.
2488        let ok_offset = CACHE_SVC.find("Ok(").unwrap() + 1;
2489        assert!(describe_handler_annotation_at(CACHE_SVC, ok_offset).is_none());
2490    }
2491
2492    #[test]
2493    fn annotation_token_spans_cover_name_and_labels() {
2494        let spans = handler_annotation_token_spans(CACHE_SVC);
2495        // `@cache` + the two argument labels `maxAge`/`scope`.
2496        assert_eq!(spans.len(), 3, "{spans:?}");
2497        let texts: Vec<&str> = spans.iter().map(|s| &CACHE_SVC[s.start..s.end]).collect();
2498        assert_eq!(texts, ["@cache", "maxAge", "scope"]);
2499        // A service with no annotations yields nothing.
2500        let plain = "context api\nservice api from http {\n  on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor { Ok(\"y\") }\n}\n";
2501        assert!(handler_annotation_token_spans(plain).is_empty());
2502    }
2503}