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    // S4a (#1966): a derived capability is rendered with its base, which the
64    // unit selects in a `consumes U { Base }`.
65    if let SourceUnit::Context(ctx) = &unit
66        && let Some(c) = items.iter().find_map(|item| match item {
67            CommonsItem::Capability(c) if c.name.name == name && c.derived.is_some() => Some(c),
68            _ => None,
69        })
70        && let Some(d) = &c.derived
71    {
72        let base_unit = ctx.consumes.iter().find_map(|cd| {
73            cd.selected
74                .as_ref()?
75                .iter()
76                .any(|s| s.name == d.base.name)
77                .then(|| cd.target.joined())
78        });
79        let base = base_unit
80            .as_deref()
81            .and_then(|u| crate::completion::embedded_capability(u, &d.base.name));
82        return Some(describe_derived_capability(
83            c,
84            base_unit.as_deref(),
85            base.as_ref(),
86        ));
87    }
88    for item in items {
89        if let Some(summary) = describe_item(item, name) {
90            return Some(summary);
91        }
92    }
93    None
94}
95
96/// v0.121 (ADR 0156): the reserved-keyword doc for the token at `offset` in
97/// `source`, if the cursor sits on one — matched by source text against
98/// `bynk_syntax::keywords::KEYWORDS`, independent of the token's `TokenKind`
99/// (unlike the identifier-only lexical hover fallback above). This is hover's
100/// floor for the mechanical coverage test: every lowercase-initial keyword
101/// gets at least this, even where `describe_symbol` has no richer path for it
102/// (e.g. the testing-track clause keywords — `requires`/`ensures`/`suite`/…).
103pub fn describe_keyword_at(source: &str, offset: usize) -> Option<&'static str> {
104    let tokens = tokenize(source).ok()?;
105    let word = tokens
106        .iter()
107        .find(|t| t.span.start <= offset && offset < t.span.end)
108        .map(|t| &source[t.span.start..t.span.end])?;
109    bynk_syntax::keywords::KEYWORDS
110        .iter()
111        .find(|k| k.word == word)
112        .map(|k| k.meaning)
113}
114
115/// v0.137.0 (ADR 0161): hover for the `key`/`store` *contextual* keywords and
116/// the agent state fields they introduce. Both are lexed as `Ident`s (not
117/// reserved `KEYWORDS`), and the fields they declare are neither `let`/param
118/// locals nor top-level declarations — so neither the keyword fallback nor the
119/// `describe_symbol`/locals paths in the hover handler reach them. This closes
120/// that gap: for the cursor on the `key`/`store` keyword *or* on the field name
121/// it declares, render the field's signature (type, and a `store` field's
122/// `@indexed`/`@bounded`/… annotations) followed by the contextual-keyword doc.
123///
124/// #611 (gap A): a *reference* to a state field inside the agent's body — a
125/// bare read (`lastSeq + 1`), a `:=` write target, an invariant subject, a store
126/// op's receiver (`items.put(…)`) — renders the same hover as its declaration.
127/// State fields are absent from the project index and are not `let`/param
128/// locals, so a reference resolved nowhere before this. The hover handler tries
129/// the locals path first, so a local shadowing a field name still hovers as the
130/// local — matching the checker, which dispatches a store op only on a bare
131/// ident that is *not* in the value scope.
132///
133/// `None` when the cursor is not on an agent's `key`/`store` keyword, its
134/// state-field name, or a reference to one within the agent.
135pub fn describe_agent_state_at(source: &str, offset: usize) -> Option<String> {
136    let tokens = tokenize(source).ok()?;
137    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
138    let unit = unit?;
139    let items: &[CommonsItem] = match &unit {
140        SourceUnit::Commons(c) => &c.items,
141        SourceUnit::Context(c) => &c.items,
142        SourceUnit::Adapter(a) => &a.items,
143        SourceUnit::Suite(_) => &[],
144    };
145    // State-migrations S3 (#2248): `removed agent Old`, on the `removed`
146    // keyword or the class name.
147    if let SourceUnit::Context(c) = &unit {
148        for r in &c.removed_agents {
149            if span_covers(r.keyword, offset) || span_covers(r.name.span, offset) {
150                return Some(format!(
151                    "```bynk\nremoved agent {}\n```\n\nThe agent class `{}` is gone: its stored \
152                     data is deleted at the next deploy, which needs `--confirm-delete \
153                     <context>.{}`.",
154                    r.name.name, r.name.name, r.name.name
155                ));
156            }
157        }
158    }
159    for item in items {
160        let CommonsItem::Agent(a) = item else {
161            continue;
162        };
163        // State-migrations S3 (#2248): `agent New replaces Old` — on the
164        // `replaces` keyword or the old name.
165        if let Some(old) = &a.replaces {
166            let on_kw = preceding_ident_span(&tokens, source, old.span, "replaces")
167                .is_some_and(|s| span_covers(s, offset));
168            if on_kw || span_covers(old.span, offset) {
169                return Some(format!(
170                    "```bynk\nagent {} replaces {}\n```\n\nRenames the agent class `{}` to `{}`: \
171                     its stored data and schema history move to `{}`.",
172                    a.name.name, old.name, old.name, a.name.name, a.name.name
173                ));
174            }
175        }
176        // `key <name>: <type>` — the cursor on the field name, or on the `key`
177        // keyword token immediately preceding it.
178        let on_key_kw = preceding_ident_span(&tokens, source, a.key_name.span, "key")
179            .is_some_and(|s| span_covers(s, offset));
180        if on_key_kw || span_covers(a.key_name.span, offset) {
181            return Some(key_hover(a));
182        }
183        // `store <name>: <kind> <annotations>` — the parser sets each field's
184        // span to start at its `store` keyword, so the keyword span is derivable
185        // without re-scanning.
186        for f in &a.store_fields {
187            let store_kw = Span {
188                file: f.span.file,
189                start: f.span.start,
190                end: f.span.start + "store".len(),
191            };
192            let on_store_kw = source.get(store_kw.start..store_kw.end) == Some("store")
193                && span_covers(store_kw, offset);
194            if on_store_kw || span_covers(f.name.span, offset) {
195                return Some(store_field_hover(f));
196            }
197        }
198        // State-migrations S2 (#2224): the contextual `migrate` keyword (its
199        // span starts each step) and `drop` (each drop item's keyword span).
200        for m in &a.migrations {
201            if span_covers(m.keyword, offset)
202                && source.get(m.keyword.start..m.keyword.end) == Some("migrate")
203            {
204                return Some(migrate_hover(m));
205            }
206            for item in &m.items {
207                if let MigrateItem::Drop(d) = item
208                    && span_covers(d.keyword, offset)
209                {
210                    return Some(format!(
211                        "```bynk\ndrop {}\n```\n\nDiscards the stored value of `{}` on purpose: \
212                         a removed field the step doesn't read, or a changed `Set`, `Log` or \
213                         `Cache` field, which starts afresh.",
214                        d.field.name, d.field.name
215                    ));
216                }
217            }
218        }
219        // #611: a reference to `key`/`store` state from within this agent.
220        if !in_state_scope(a, offset) {
221            continue;
222        }
223        let Some((name, name_span)) = ident_at(&tokens, source, offset) else {
224            continue;
225        };
226        // State is referenced by **bare** name, so a member of another value
227        // (`p.items`) is not a state reference even when the names coincide.
228        if is_dot_preceded(source, name_span.start) {
229            continue;
230        }
231        if name == a.key_name.name {
232            return Some(key_hover(a));
233        }
234        if let Some(f) = a.store_fields.iter().find(|f| f.name.name == name) {
235            return Some(store_field_hover(f));
236        }
237    }
238    None
239}
240
241/// #706, #2135: the reserved call-site principals of a test case — each with
242/// what the `system` driver sends for it. Not declarations: `by <name>` names
243/// one of these, never an actor.
244pub const RESERVED_PRINCIPALS: &[(&str, &str)] = &[
245    (
246        "Nobody",
247        "Sends no credential — no `Authorization` header on a Bearer route, no signature headers on a `Signature` route — so the real seam refuses it (`Rejected(Unauthorized)`). `system` tier only.",
248    ),
249    (
250        "Forged",
251        "Signs a `Signature` route's request with a secret the seam does not hold, so the real seam refuses it (`Rejected(Unauthorized)`). `system` tier only.",
252    ),
253    (
254        "Stale",
255        "Signs a `Signature` route's request correctly, timestamped `tolerance + 60` seconds in the past, so the real seam refuses it as a replay (`Rejected(Unauthorized)`). Needs an actor with `timestamp` and `tolerance`. `system` tier only.",
256    ),
257];
258
259/// #2135: hover for a reserved principal (`Nobody`, `Forged`, `Stale`) in a
260/// call-site `by` clause: what the driver sends. `None` when the cursor is not
261/// on one of those names right after `by`.
262pub fn describe_reserved_principal_at(source: &str, offset: usize) -> Option<String> {
263    let tokens = tokenize(source).ok()?;
264    let (name, span) = ident_at(&tokens, source, offset)?;
265    let (_, doc) = RESERVED_PRINCIPALS.iter().find(|(n, _)| *n == name)?;
266    preceding_ident_span(&tokens, source, span, "by")
267        .map(|_| format!("```bynk\nby {name}\n```\n\n{doc}"))
268}
269
270/// #2134: the contextual `env` word's one-line doc, shown when hovering it.
271pub const ENV_KEYWORD_DOC: &str = "The variables and secrets a suite's participants see (`env { NAME = \"value\" … }`): on each Worker's `env` at `system`, and what the `Secrets` double answers at `unit`/`integration`. A case's `env` layers over its suite's, name by name.";
272
273/// #2134: hover for a suite's or case's `env { … }` clause — on the `env`
274/// word, its doc ([`ENV_KEYWORD_DOC`]); on an entry's name, the entry and
275/// where it applies: "variable given to every participant (suite)", or
276/// "(case, overrides suite)" for a case entry its suite also gives. `env` is
277/// lexed as an identifier, so neither the keyword fallback nor the
278/// declaration rungs reach it. `None` when the cursor is on neither.
279pub fn describe_env_at(source: &str, offset: usize) -> Option<String> {
280    let tokens = tokenize(source).ok()?;
281    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
282    let SourceUnit::Suite(suite) = unit? else {
283        return None;
284    };
285    let on_kw = |e: &EnvClause| {
286        span_covers(
287            Span {
288                file: e.span.file,
289                start: e.span.start,
290                end: e.span.start + "env".len(),
291            },
292            offset,
293        )
294    };
295    let suite_gives = |name: &str| {
296        suite
297            .envs
298            .iter()
299            .flat_map(|e| e.entries.iter())
300            .any(|x| x.name.name == name)
301    };
302    let render = |entry: &EnvEntry, scope: &str| {
303        let value = match &entry.value.kind {
304            ExprKind::StrLit(v) => format!("{v:?}"),
305            _ => "…".to_string(),
306        };
307        format!(
308            "```bynk\n{} = {value}\n```\n\nvariable given to every participant ({scope})",
309            entry.name.name
310        )
311    };
312    for e in &suite.envs {
313        if on_kw(e) {
314            return Some(ENV_KEYWORD_DOC.to_string());
315        }
316        if let Some(entry) = e.entries.iter().find(|x| span_covers(x.name.span, offset)) {
317            return Some(render(entry, "suite"));
318        }
319    }
320    for case in &suite.cases {
321        for e in &case.envs {
322            if on_kw(e) {
323                return Some(ENV_KEYWORD_DOC.to_string());
324            }
325            if let Some(entry) = e.entries.iter().find(|x| span_covers(x.name.span, offset)) {
326                let scope = if suite_gives(&entry.name.name) {
327                    "case, overrides suite"
328                } else {
329                    "case"
330                };
331                return Some(render(entry, scope));
332            }
333        }
334    }
335    None
336}
337
338/// #611: true when `offset` sits where an agent's `key`/`store` state is
339/// referenceable by bare name — a handler body, or an invariant/transition
340/// predicate. Deliberately narrower than the agent's own span: the declaration
341/// region names things that are *not* state references, and a store annotation
342/// argument (`@indexed(by: id)`) names a field of the **stored value**, which
343/// must not masquerade as a same-named `key`/`store` field.
344fn in_state_scope(a: &AgentDecl, offset: usize) -> bool {
345    a.handlers.iter().any(|h| span_covers(h.body.span, offset))
346        || a.invariants
347            .iter()
348            .any(|i| span_covers(i.predicate.span, offset))
349        || a.transitions
350            .iter()
351            .any(|t| span_covers(t.predicate.span, offset))
352}
353
354/// Hover for a `migrate from N` step (state-migrations S2, #2224).
355fn migrate_hover(m: &MigrateDecl) -> String {
356    let old = m
357        .old
358        .as_ref()
359        .map(|o| format!(" ({}: {})", o.name.name, type_ref_str(&o.type_ref)))
360        .unwrap_or_default();
361    format!(
362        "```bynk\nmigrate from {}{old}\n```\n\nA migration step: carries a stored record from \
363         schema version {} to the agent's current shape when it next loads.",
364        m.from, m.from
365    )
366}
367
368/// The hover for an agent's `key` field — its declaration and every reference.
369fn key_hover(a: &AgentDecl) -> String {
370    let sig = format!("key {}: {}", a.key_name.name, type_ref_str(&a.key_type));
371    render_state_hover(&sig, "key")
372}
373
374/// The hover for a `store` field — its declaration and every reference.
375fn store_field_hover(f: &StoreField) -> String {
376    let mut sig = format!("store {}: {}", f.name.name, store_kind_str(&f.kind));
377    for ann in &f.annotations {
378        sig.push(' ');
379        sig.push_str(&bynk_fmt::annotation_to_string(ann));
380    }
381    render_state_hover(&sig, "store")
382}
383
384/// #611: hover for a `store` field's operation — the `<op>` of a
385/// `<field>.<op>(…)` call on an agent's `store` field (`items.put(id, item)`).
386/// Store operations are checked but never indexed and are not value-receiver
387/// methods, so `qualified_callee_at` (name-receivers only) never reaches them
388/// and they resolved nowhere. Renders the operation's signature from the
389/// enumerable [`bynk_check::store_ops`] registry — generic in the kind's
390/// key/value/element type — over the field's declared kind, which grounds it.
391///
392/// `locals` guards the receiver the way the checker's dispatch does: a store op
393/// is a bare ident receiver that is *not* in the value scope, so a local
394/// shadowing the field name makes this an ordinary value method, not a store op.
395/// `None` when the cursor is not on a store operation of the enclosing agent.
396pub fn describe_store_op_at(
397    source: &str,
398    offset: usize,
399    locals: &[bynk_check::locals::LocalBinding],
400) -> Option<String> {
401    let tokens = tokenize(source).ok()?;
402    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
403    let unit = unit?;
404    let items: &[CommonsItem] = match &unit {
405        SourceUnit::Commons(c) => &c.items,
406        SourceUnit::Context(c) => &c.items,
407        SourceUnit::Adapter(a) => &a.items,
408        SourceUnit::Suite(_) => &[],
409    };
410    // The cursor must sit on the `<op>` of a `<recv>.<op>` access.
411    let (op, op_span) = ident_at(&tokens, source, offset)?;
412    let (recv, recv_start) = receiver_segment_at(source, op_span)?;
413    // The checker dispatches a store op on a **bare** ident receiver only, so a
414    // qualified one is not one: `p.items.contains(…)` is an ordinary value method
415    // on a record field that merely shares a store field's name.
416    if is_dot_preceded(source, recv_start) {
417        return None;
418    }
419    // A local of the receiver's name shadows the store field (the same
420    // by-provenance dispatch) — then this is a value method, not a store op.
421    if bynk_check::locals::locals_at(locals, recv_start)
422        .iter()
423        .any(|b| b.name == recv)
424    {
425        return None;
426    }
427    for item in items {
428        let CommonsItem::Agent(a) = item else {
429            continue;
430        };
431        if !in_state_scope(a, offset) {
432            continue;
433        }
434        let f = a.store_fields.iter().find(|f| f.name.name == recv)?;
435        let sig = bynk_check::store_ops::ops_for(&f.kind.head.name)
436            .iter()
437            .find(|o| o.name == op)?
438            .signature;
439        return Some(format!(
440            "```bynk\n{sig}\n```\n\nA `{}` store operation on `store {}: {}` — the field's \
441             declared kind grounds the operation's type parameters.",
442            f.kind.head.name,
443            f.name.name,
444            store_kind_str(&f.kind),
445        ));
446    }
447    None
448}
449
450/// The identifier token covering `offset` — its text and span — if the cursor is
451/// on one.
452fn ident_at<'a>(
453    tokens: &[bynk_syntax::lexer::Token],
454    source: &'a str,
455    offset: usize,
456) -> Option<(&'a str, Span)> {
457    tokens
458        .iter()
459        .find(|t| t.kind == bynk_syntax::lexer::TokenKind::Ident && span_covers(t.span, offset))
460        .and_then(|t| Some((source.get(t.span.start..t.span.end)?, t.span)))
461}
462
463/// The receiver segment of a `<recv>.<member>` access whose member sits at
464/// `member_span`: the identifier run immediately before the dot, and the offset
465/// it starts at. `None` when the member is not dot-preceded. Shared by every
466/// caller that reads a receiver off the line prefix, so the extraction has one
467/// definition rather than a copy per call site — delegates the actual boundary
468/// scan to `completion::ident_ending_at`.
469fn receiver_segment_at(text: &str, member_span: Span) -> Option<(&str, usize)> {
470    let before = text.get(..member_span.start)?.strip_suffix('.')?;
471    crate::completion::ident_ending_at(before, before.len())
472}
473
474/// True when the identifier starting at `start` is itself the member of a
475/// further access (the `items` of `p.items`) rather than a bare name.
476pub(crate) fn is_dot_preceded(text: &str, start: usize) -> bool {
477    text[..start].ends_with('.')
478}
479
480/// #596: the storage-kind vocabulary a bare receiver is eligible for at
481/// completion's `<recv>.` position — `recv_end` is the offset just past the
482/// receiver identifier (where its dot sat before
483/// `completion::value_receiver_rewrite` dropped it, so `source` here is that
484/// rewritten buffer). Mirrors `describe_store_op_at`'s by-provenance receiver
485/// check (not shadowed by a local, inside the declaring agent's state scope),
486/// but starts from the receiver's own end offset rather than walking back from
487/// an operation token, since completion fires before any member name is
488/// typed — and the rewrite's postcondition already guarantees a bare,
489/// non-dot-qualified name reaches here, so unlike `describe_store_op_at` there
490/// is no further `is_dot_preceded` check to make.
491///
492/// Returns the field's storage-kind head (`"Map"`, `"Cache"`, `"Set"`,
493/// `"Cell"`, `"Log"`) and, for a `Map`, whether its value type is a held
494/// `Connection` (v0.158, ADR 0184: `.entries`/`.keys`/`.values` are refused on
495/// one) — enough for the completion layer to look up each kind's registry
496/// without re-parsing.
497pub fn store_field_kind_at(
498    source: &str,
499    recv_end: usize,
500    locals: &[bynk_check::locals::LocalBinding],
501) -> Option<(String, bool)> {
502    let (recv, recv_start) = crate::completion::ident_ending_at(source, recv_end)?;
503    if bynk_check::locals::locals_at(locals, recv_start)
504        .iter()
505        .any(|b| b.name == recv)
506    {
507        return None;
508    }
509    let tokens = tokenize(source).ok()?;
510    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
511    let unit = unit?;
512    let items: &[CommonsItem] = match &unit {
513        SourceUnit::Commons(c) => &c.items,
514        SourceUnit::Context(c) => &c.items,
515        SourceUnit::Adapter(a) => &a.items,
516        SourceUnit::Suite(_) => &[],
517    };
518    for item in items {
519        let CommonsItem::Agent(a) = item else {
520            continue;
521        };
522        if !in_state_scope(a, recv_end) {
523            continue;
524        }
525        if let Some(f) = a.store_fields.iter().find(|f| f.name.name == recv) {
526            let held = f.kind.head.name == "Map"
527                && f.kind.args.len() == 2
528                && matches!(f.kind.args[1], TypeRef::Connection(..));
529            return Some((f.kind.head.name.clone(), held));
530        }
531    }
532    None
533}
534
535/// v0.140 (ADR 0163): hover for a handler-position annotation (`@cache`). Handler
536/// annotations are not symbols and declare no local, so they miss both the
537/// `describe_symbol` and locals paths — this closes the gap. For the cursor
538/// anywhere within a handler's `@cache( … )` annotation, render the formatted
539/// annotation followed by a prose description of `@cache` and its fields. `None`
540/// when the cursor is not inside a handler annotation.
541pub fn describe_handler_annotation_at(source: &str, offset: usize) -> Option<String> {
542    let tokens = tokenize(source).ok()?;
543    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
544    let unit = unit?;
545    let items: &[CommonsItem] = match &unit {
546        SourceUnit::Commons(c) => &c.items,
547        SourceUnit::Context(c) => &c.items,
548        SourceUnit::Adapter(a) => &a.items,
549        SourceUnit::Suite(_) => &[],
550    };
551    for item in items {
552        let (handlers, service_cap): (&[Handler], Option<i64>) = match item {
553            CommonsItem::Service(s) => (&s.handlers, s.limits.as_ref().and_then(|l| l.max_body())),
554            CommonsItem::Agent(a) => (&a.handlers, None),
555            _ => continue,
556        };
557        for h in handlers {
558            for ann in &h.annotations {
559                if span_covers(ann.span, offset) {
560                    return Some(render_handler_annotation_hover(ann, h, service_cap));
561                }
562            }
563        }
564    }
565    None
566}
567
568/// #2137 (ADR 0156): hover for the contextual `advance` keyword of an
569/// `advance Clock by <Duration>` statement. `None` anywhere else, so an
570/// ordinary identifier named `advance` keeps its own hover.
571pub fn describe_advance_at(source: &str, offset: usize) -> Option<String> {
572    let tokens = tokenize(source).ok()?;
573    let idx = tokens
574        .iter()
575        .position(|t| t.span.start <= offset && offset < t.span.end)?;
576    let text = |i: usize| tokens.get(i).map(|t| &source[t.span.start..t.span.end]);
577    if text(idx)? != "advance"
578        || tokens.get(idx + 1)?.kind != bynk_syntax::lexer::TokenKind::Ident
579        || tokens.get(idx + 2)?.kind != bynk_syntax::lexer::TokenKind::By
580    {
581        return None;
582    }
583    Some(
584        "```bynk\nadvance Clock by <Duration>\n```\n\nMoves the test harness's clock forward. Each case's clock starts at the epoch; at `unit` and `integration` the `Clock` double reads it, and at `system` every participant's first-party `Clock` does. It governs `Clock` only: JWT expiry, the `Signature` tolerance and Durable Object alarms keep wall-clock time."
585            .to_string(),
586    )
587}
588
589/// #2138: the doc `run.id` shows on hover and in completion.
590pub const RUN_ID_DOC: &str = "**`run.id`** — this test run's identity, a `String`. In process it is always `\"local\"`; each `bynk test --against` run gets a fresh one, so a key built from it (`\"order-\\(run.id)\"`) doesn't collide with an earlier run's data on a shared deployment.";
591
592/// #2138: whether the `run` identifier starting at `run_start` in `source`
593/// is the test run's `run` — inside a suite's `case` body, with no binder of
594/// its own in scope there. #2175: the scope is the checker's: a `let` earlier
595/// in any enclosing block, a lambda parameter, a `match` arm's pattern, or an
596/// `is <pattern>` test's bindings all shadow it. #2181: an `is` test binds
597/// where the checker says it does (`bynk_check::narrowing`): in an `if`'s
598/// `then` block when the condition holds, in its `else` block when it fails
599/// (`if !(v is Some(run)) { … } else { run.id }`), and on the right of `&&`
600/// and `implies` (`v is Some(run) && run.id == "x"`).
601pub fn is_test_run_receiver(source: &str, run_start: usize) -> bool {
602    let Ok(tokens) = tokenize(source) else {
603        return false;
604    };
605    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
606    let Some(SourceUnit::Suite(suite)) = unit else {
607        return false;
608    };
609    let Some(case) = suite.cases.iter().find(|c| span_covers(c.span, run_start)) else {
610        return false;
611    };
612    run_bound_in_block(&case.body, run_start, &mut Vec::new()) != Some(true)
613}
614
615/// Whether the `run` at `at` is bound by a binder in scope, if `at` is in
616/// `block`; `bound` holds the names bound around it.
617fn run_bound_in_block(block: &Block, at: usize, bound: &mut Vec<String>) -> Option<bool> {
618    let mark = bound.len();
619    let mut found = None;
620    for stmt in &block.statements {
621        let mut exprs = Vec::new();
622        statement_exprs(stmt, &mut exprs);
623        found = exprs
624            .into_iter()
625            .find_map(|e| run_bound_in_expr(e, at, bound));
626        if found.is_some() {
627            break;
628        }
629        // A `let`'s own value doesn't see its name; what follows does.
630        if let Statement::Let(l) | Statement::EffectLet(l) = stmt {
631            bound.push(l.name.name.clone());
632        }
633    }
634    let found = found.or_else(|| run_bound_in_expr(&block.tail, at, bound));
635    bound.truncate(mark);
636    found
637}
638
639fn run_bound_in_expr(e: &Expr, at: usize, bound: &mut Vec<String>) -> Option<bool> {
640    if !span_covers(e.span, at) {
641        return None;
642    }
643    let mark = bound.len();
644    let found = match &e.kind {
645        ExprKind::Ident(id) if id.span.start == at && id.name == "run" => {
646            Some(bound.iter().any(|n| n == "run"))
647        }
648        ExprKind::Block(b) => run_bound_in_block(b, at, bound),
649        ExprKind::Lambda(l) => {
650            bound.extend(l.params.iter().map(|p| p.name.name.clone()));
651            run_bound_in_expr(&l.body, at, bound)
652        }
653        ExprKind::If {
654            cond,
655            then_block,
656            else_block,
657        } => run_bound_in_expr(cond, at, bound)
658            .or_else(|| {
659                let inner = bound.len();
660                bound.extend(is_bound_names(cond, true));
661                let r = run_bound_in_block(then_block, at, bound);
662                bound.truncate(inner);
663                r
664            })
665            .or_else(|| {
666                let inner = bound.len();
667                bound.extend(is_bound_names(cond, false));
668                let r = run_bound_in_block(else_block, at, bound);
669                bound.truncate(inner);
670                r
671            }),
672        // The left side's `is` bindings reach the right side.
673        ExprKind::BinOp(BinOp::And | BinOp::Implies, lhs, rhs) => run_bound_in_expr(lhs, at, bound)
674            .or_else(|| {
675                bound.extend(is_bound_names(lhs, true));
676                run_bound_in_expr(rhs, at, bound)
677            }),
678        ExprKind::Match { discriminant, arms } => run_bound_in_expr(discriminant, at, bound)
679            .or_else(|| {
680                arms.iter()
681                    .find(|a| span_covers(a.span, at))
682                    .and_then(|arm| {
683                        bound.extend(
684                            arm.pattern
685                                .bound_names()
686                                .into_iter()
687                                .map(|id| id.name.clone()),
688                        );
689                        arm.guard
690                            .as_ref()
691                            .and_then(|g| run_bound_in_expr(g, at, bound))
692                            .or_else(|| match &arm.body {
693                                MatchBody::Expr(body) => run_bound_in_expr(body, at, bound),
694                                MatchBody::Block(b) => run_bound_in_block(b, at, bound),
695                            })
696                    })
697            }),
698        _ => expr_children(e)
699            .into_iter()
700            .find_map(|c| run_bound_in_expr(c, at, bound)),
701    };
702    bound.truncate(mark);
703    found
704}
705
706/// The names `cond`'s `is <pattern>` tests bind when it evaluates to
707/// `when_true`, by the checker's own rule.
708fn is_bound_names(cond: &Expr, when_true: bool) -> Vec<String> {
709    bynk_check::narrowing::matched_is_tests(cond, when_true)
710        .into_iter()
711        .filter_map(|test| match &test.kind {
712            ExprKind::Is { pattern, .. } => Some(pattern.bound_names()),
713            _ => None,
714        })
715        .flatten()
716        .map(|id| id.name.clone())
717        .collect()
718}
719
720/// #2138 (ADR 0156): hover for `run.id` in a `case` body — on either the
721/// `run` or the `id`. `None` anywhere else, and where the case binds its own
722/// `run`, so an ordinary value keeps its own hover.
723pub fn describe_run_id_at(source: &str, offset: usize) -> Option<String> {
724    // A character scan, not the token stream: `run.id` is often inside a
725    // string interpolation (`"order-\(run.id)"`), which lexes as one token.
726    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
727    let offset = offset.min(source.len());
728    let start = source[..offset]
729        .char_indices()
730        .rev()
731        .take_while(|(_, c)| is_ident(*c))
732        .last()
733        .map_or(offset, |(i, _)| i);
734    let end = source[start..]
735        .char_indices()
736        .find(|(_, c)| !is_ident(*c))
737        .map_or(source.len(), |(i, _)| start + i);
738    let ends_ident = |rest: &str| !rest.starts_with(is_ident);
739    let run = match &source[start..end] {
740        "run" if source[end..].strip_prefix(".id").is_some_and(ends_ident) => start,
741        "id" if source[..start].ends_with("run.") => start - "run.".len(),
742        _ => return None,
743    };
744    if source[..run].ends_with(|c: char| is_ident(c) || c == '.') {
745        return None; // `x.run.id` or `myrun.id`: not the test run
746    }
747    is_test_run_receiver(source, run).then(|| RUN_ID_DOC.to_string())
748}
749
750/// #2136 (ADR 0156): hover for the `schedule` / `message` address name in a
751/// `system`-tier case (`sweeper.schedule(…)`, `deliveries.message(m)`), which
752/// runs the handler through the participant Worker's own `scheduled` / `queue`
753/// entry point rather than in process. `None` anywhere else, including the
754/// same address at `unit` or `integration`, and a `.schedule` / `.message`
755/// whose receiver is not a `cron` / `queue` service of the suite's target (a
756/// record field named `message`, say). The target's declaration is looked up
757/// in `source` and `others` (the project's other files).
758pub fn describe_system_job_address_at<'s>(
759    source: &'s str,
760    offset: usize,
761    others: impl IntoIterator<Item = &'s str>,
762) -> Option<String> {
763    let is_ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
764    let start = source[..offset.min(source.len())]
765        .char_indices()
766        .rev()
767        .take_while(|(_, c)| is_ident(*c))
768        .last()
769        .map_or(offset, |(i, _)| i);
770    let end = source[start..]
771        .char_indices()
772        .find(|(_, c)| !is_ident(*c))
773        .map_or(source.len(), |(i, _)| start + i);
774    let name = &source[start..end];
775    if !source[..start].ends_with('.') || !matches!(name, "schedule" | "message") {
776        return None;
777    }
778    let tokens = tokenize(source).ok()?;
779    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
780    let SourceUnit::Suite(suite) = unit? else {
781        return None;
782    };
783    let case = suite.cases.iter().find(|c| span_covers(c.span, start))?;
784    if case.tier.or(suite.tier) != Some(TestTier::System) {
785        return None;
786    }
787    // The receiver: the identifier right before the `.`.
788    let before = &source[..start - 1];
789    let receiver = &before[before
790        .char_indices()
791        .rev()
792        .take_while(|(_, c)| is_ident(*c))
793        .last()
794        .map_or(before.len(), |(i, _)| i)..];
795    let target = suite.target.joined();
796    let is_job_service = std::iter::once(source).chain(others).any(|src| {
797        let Ok(tokens) = tokenize(src) else {
798            return false;
799        };
800        let (unit, _errs) = parse_unit_with_recovery(&tokens, src);
801        let Some(SourceUnit::Context(ctx)) = unit else {
802            return false;
803        };
804        ctx.name.joined() == target
805            && ctx.items.iter().any(|item| {
806                matches!(item, CommonsItem::Service(svc)
807                if svc.name.name == receiver
808                    && match name {
809                        "schedule" => matches!(svc.protocol, ServiceProtocol::Cron),
810                        _ => matches!(svc.protocol, ServiceProtocol::Queue { .. }),
811                    })
812            })
813    });
814    if !is_job_service {
815        return None;
816    }
817    Some(if name == "schedule" {
818        "**`svc.schedule(\"<expr>\", at)`** at `system` — runs the cron handler through the Worker's `scheduled` entry, with `{ cron, scheduledTime: at }`, and yields the handler's own `Result`.".to_string()
819    } else {
820        "**`q.message(m)`** at `system` — delivers `m` through the Worker's `queue` entry, after a JSON round trip, and yields `Ack` or `Retry(reason)` as the consumer acknowledged or retried it.".to_string()
821    })
822}
823
824/// v0.140 (ADR 0163): the spans to classify as `decorator` semantic tokens — each
825/// handler annotation's `@name` (the `@` through the name) and its argument labels
826/// (`maxAge:`, `scope:`). Parsed from `source`; empty when it carries no handler
827/// annotations. Feeds the semantic-tokens producer, which is otherwise a
828/// parse-free index read, so the parse lives here beside the hover parse.
829pub fn handler_annotation_token_spans(source: &str) -> Vec<Span> {
830    let Ok(tokens) = tokenize(source) else {
831        return Vec::new();
832    };
833    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
834    let Some(unit) = unit else {
835        return Vec::new();
836    };
837    let items: &[CommonsItem] = match &unit {
838        SourceUnit::Commons(c) => &c.items,
839        SourceUnit::Context(c) => &c.items,
840        SourceUnit::Adapter(a) => &a.items,
841        SourceUnit::Suite(_) => &[],
842    };
843    let mut spans = Vec::new();
844    for item in items {
845        let handlers: &[Handler] = match item {
846            CommonsItem::Service(s) => &s.handlers,
847            CommonsItem::Agent(a) => &a.handlers,
848            _ => continue,
849        };
850        for h in handlers {
851            for ann in &h.annotations {
852                // The `@name` — from the annotation's leading `@` through its name.
853                spans.push(Span {
854                    file: ann.span.file,
855                    start: ann.span.start,
856                    end: ann.name.span.end,
857                });
858                // Each argument label (`maxAge:`, `scope:`).
859                for arg in &ann.args {
860                    if let Some(label) = &arg.label {
861                        spans.push(label.span);
862                    }
863                }
864            }
865        }
866    }
867    spans
868}
869
870/// The `Cache-Control` value a well-formed `@cache` emits — the same reading
871/// `bynk_lower::lower_route_cache_ir` makes, restated here because the IDE does
872/// not depend on the lowering crate. `None` when `maxAge` is missing or not a
873/// whole-second `Duration` literal (the checker flags those).
874fn cache_control_of(ann: &Annotation) -> Option<String> {
875    let mut max_age_secs = None;
876    let mut scope = "private";
877    let mut immutable = false;
878    for arg in &ann.args {
879        match (arg.label.as_ref().map(|l| l.name.as_str()), &arg.value.kind) {
880            (Some("maxAge"), ExprKind::DurationLit { millis, .. })
881                if *millis > 0 && millis % 1000 == 0 =>
882            {
883                max_age_secs = Some(millis / 1000);
884            }
885            (Some("scope"), ExprKind::Ident(id)) if id.name == "public" => scope = "public",
886            (Some("immutable"), ExprKind::BoolLit(b)) => immutable = *b,
887            _ => {}
888        }
889    }
890    let secs = max_age_secs?;
891    let suffix = if immutable { ", immutable" } else { "" };
892    Some(format!("{scope}, max-age={secs}{suffix}"))
893}
894
895/// The formatted annotation in a code block, plus a prose description for the
896/// closed handler-annotation set. `@cache`, `@limit` (v0.142) and `@raw` carry prose; any
897/// other name (a typo the checker will flag) still hovers as its formatted form so
898/// the surface is never silent.
899fn render_handler_annotation_hover(
900    ann: &Annotation,
901    handler: &Handler,
902    service_cap: Option<i64>,
903) -> String {
904    let sig = bynk_fmt::annotation_to_string(ann);
905    if ann.name.name == "cache" {
906        let emitted = match cache_control_of(ann) {
907            Some(header) => format!("\n\nEmits `Cache-Control: {header}`."),
908            None => String::new(),
909        };
910        return format!(
911            "```bynk\n{sig}\n```\n\n\
912             **`@cache`** — cache this `GET` read. A `GET` returning `Ok` (or a `Raw` body \
913             of at most 1 MiB) already carries a synthesised weak `ETag`, and `RawTagged` \
914             carries the handler's own; a matching `If-None-Match` is answered \
915             `304 Not Modified`. `@cache` adds a `Cache-Control` freshness window on top.\n\n\
916             - **`maxAge`** — the freshness window, a `Duration` (e.g. `5.minutes`), lowered to \
917             `Cache-Control: max-age`.\n\
918             - **`scope`** — `public` or `private` (default `private`; a shared cache stores the \
919             response only when `public`).\n\
920             - **`immutable`** — `true` promises the response at this URL never changes, so \
921             caches do not revalidate it within `maxAge` (default `false`).{emitted}"
922        );
923    }
924    // v0.142 (ADR 0165): `@limit` caps the request body size on a write route.
925    if ann.name.name == "limit" {
926        return format!(
927            "```bynk\n{sig}\n```\n\n\
928             **`@limit`** — cap the request body size on this `POST`/`PUT`/`PATCH` route. A \
929             request whose body exceeds the `maxBody` byte ceiling is answered `413 Payload Too \
930             Large`, synthesised before the body is read.\n\n\
931             - **`maxBody`** — the maximum request body size in bytes, a positive `Int`."
932        );
933    }
934    // `@raw` takes the request body as the octets sent, under a declared media type.
935    if ann.name.name == "raw" {
936        let cap = match handler.limit_max_body().or(service_cap) {
937            Some(bytes) => format!("{bytes} bytes (from `@limit` or the service `limits`)"),
938            None => "none — a `@raw` route needs one (`bynk.http.raw_body_unbounded`)".to_string(),
939        };
940        let content_type = match handler.raw_content_type() {
941            Some("*/*") => "any".to_string(),
942            Some(ct) => format!("`{ct}`"),
943            None => "—".to_string(),
944        };
945        return format!(
946            "```bynk\n{sig}\n```\n\n\
947             **`@raw`** — take this route's `body: Bytes` as the raw request body: the octets as \
948             sent, with no JSON and no base64. Any other `Content-Type` is answered `415 \
949             Unsupported Media Type`.\n\n\
950             - **`contentType`** — the media type accepted: {content_type}.\n\
951             - **Effective body cap** — {cap}."
952        );
953    }
954    format!("```bynk\n{sig}\n```")
955}
956
957/// True when `offset` falls within `span` (half-open, as hover offsets are).
958fn span_covers(span: Span, offset: usize) -> bool {
959    span.start <= offset && offset < span.end
960}
961
962/// The span of the token immediately preceding the token that begins at
963/// `name_span`, if that preceding token's source text is exactly `kw` — used to
964/// locate a contextual keyword (`key`) that the AST records only by its effect,
965/// not with a span of its own.
966fn preceding_ident_span(
967    tokens: &[bynk_syntax::lexer::Token],
968    source: &str,
969    name_span: Span,
970    kw: &str,
971) -> Option<Span> {
972    let idx = tokens
973        .iter()
974        .position(|t| t.span.start == name_span.start)?;
975    let prev = tokens.get(idx.checked_sub(1)?)?;
976    (source.get(prev.span.start..prev.span.end) == Some(kw)).then_some(prev.span)
977}
978
979/// A code-block field signature followed by the contextual keyword's one-line
980/// doc from [`bynk_syntax::keywords::CONTEXTUAL_KEYWORDS`].
981fn render_state_hover(sig: &str, contextual_kw: &str) -> String {
982    let doc = bynk_syntax::keywords::CONTEXTUAL_KEYWORDS
983        .iter()
984        .find(|k| k.word == contextual_kw)
985        .map(|k| k.meaning)
986        .unwrap_or_default();
987    format!("```bynk\n{sig}\n```\n\n{doc}")
988}
989
990/// v0.122 (editor-currency slice 1): a hover summary for `self` under the
991/// cursor — `self: <Type>`. `self` is a reserved keyword (never an `Ident`, so
992/// it does not flow through `locals_nav`), but a `self` *use* is a typed
993/// expression, so its type is in `expr_types` at the token's span. For a method
994/// the type is the receiver's name; for an agent handler the checker gives
995/// `self` a synthetic record type `__<Agent>Self` (to resolve `self.<key>`),
996/// which is un-synthesised here to `<Agent>`. `None` when the cursor is not on
997/// the `self` keyword or its type is unknown (no `expr_types` entry for it —
998/// on a broken buffer ADR 0094's partial types usually still carry one; when
999/// they don't, this degrades to the keyword doc).
1000pub fn describe_self_at(
1001    text: &str,
1002    offset: usize,
1003    expr_types: &[(Span, bynk_check::checker::TyId)],
1004    tys: &bynk_check::checker::Types,
1005) -> Option<String> {
1006    let tokens = tokenize(text).ok()?;
1007    let on_self = tokens.iter().any(|t| {
1008        t.span.start <= offset && offset < t.span.end && &text[t.span.start..t.span.end] == "self"
1009    });
1010    if !on_self {
1011        return None;
1012    }
1013    let ty = bynk_check::expr_types::type_at_offset(expr_types, offset, tys)?;
1014    let display = ty.display(tys);
1015    let name = display
1016        .strip_prefix("__")
1017        .and_then(|s| s.strip_suffix("Self"))
1018        .unwrap_or(&display);
1019    Some(format!("```bynk\nself: {name}\n```"))
1020}
1021
1022/// v0.123 (editor-currency slice 2, DECISION B): if the identifier at
1023/// `ident_span` is the member of an `Upper.member` name-receiver access
1024/// (`Clock.now`, `Email.of`), return the full `Recv.member` callee for
1025/// [`crate::signature_help::resolve_label`] to resolve to its signature — the
1026/// same resolution completion and signature help perform, no new index.
1027/// `None` for a bare identifier or a lowercase (value-receiver) method, which
1028/// `resolve_label` does not handle.
1029pub fn qualified_callee_at(text: &str, ident_span: Span) -> Option<String> {
1030    let (recv, _) = receiver_segment_at(text, ident_span)?;
1031    if !recv.chars().next()?.is_uppercase() {
1032        return None;
1033    }
1034    let member = text.get(ident_span.start..ident_span.end)?;
1035    Some(format!("{recv}.{member}"))
1036}
1037
1038/// Describe a symbol declared in the embedded first-party sources — the `bynk`
1039/// and `bynk.cloudflare` adapters and the `bynk.list`/`bynk.map`/`bynk.string`
1040/// stdlib. Hover and completion-doc resolution otherwise walk only the project's
1041/// files (`walk_bynk_files`), so stdlib/surface symbols had no surfaced signature
1042/// or doc; this is the fallback after the project scan. Any `---` doc block on a
1043/// first-party declaration rides along (via `describe_fn`/`describe_type`/…),
1044/// once the sources carry one.
1045pub fn describe_firstparty_symbol(name: &str) -> Option<String> {
1046    // The single first-party source list (`bynk-check::firstparty`), so a new
1047    // first-party commons is hoverable without a second edit here (#901).
1048    bynk_check::firstparty::FIRSTPARTY_SOURCES
1049        .iter()
1050        .find_map(|(unit, src)| {
1051            describe_symbol(src, name).map(|d| d + &firstparty_notes(unit, name))
1052        })
1053}
1054
1055/// Standard-library S3 (#1963): what hover adds about a first-party item's
1056/// stability: that it is experimental (no compatibility promise, and an
1057/// `[unstable] allow` to use), and that an enum is extensible (a `match` on it
1058/// needs a wildcard). Empty for a stable, closed item.
1059pub fn firstparty_notes(unit: &str, name: &str) -> String {
1060    let mut out = String::new();
1061    let path = format!("{unit}.{name}");
1062    if bynk_check::firstparty::stability_of(&path)
1063        == Some(bynk_check::firstparty::Stability::Experimental)
1064    {
1065        out.push_str(&format!(
1066            "\n\n**Experimental**: it may change or disappear in any release. To use it, add `\"{path}\"` to `[unstable] allow` in `bynk.toml`."
1067        ));
1068    }
1069    if bynk_check::firstparty::is_extensible(unit, name) {
1070        out.push_str(
1071            "\n\n**Extensible**: it may gain variants in a minor release, so a `match` on it needs a wildcard arm.",
1072        );
1073    }
1074    out
1075}
1076
1077/// Slice 6b: the `(unit name, name span)` of every `uses`/`consumes` target in
1078/// the source — the clickable ranges for document links. The link's target file
1079/// is resolved by the handler through the unit→source map (ADR 0095); this only
1080/// finds the spans, so it works on the live buffer regardless of the map.
1081pub fn unit_reference_spans(source: &str) -> Vec<(String, Span)> {
1082    let Ok(tokens) = tokenize(source) else {
1083        return Vec::new();
1084    };
1085    let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
1086        return Vec::new();
1087    };
1088    let mut out: Vec<(String, Span)> = Vec::new();
1089    // A suite links its target (the unit under test) plus any `uses` clauses,
1090    // mirroring the `uses`/`consumes` links on the other unit kinds (#609).
1091    let (uses, consumes): (&[UsesDecl], &[ConsumesDecl]) = match &unit {
1092        SourceUnit::Commons(c) => (&c.uses, &[]),
1093        SourceUnit::Context(c) => (&c.uses, &c.consumes),
1094        SourceUnit::Adapter(a) => (&a.uses, &a.consumes),
1095        SourceUnit::Suite(s) => {
1096            out.push((s.target.joined(), s.target.span));
1097            (&s.uses, &[])
1098        }
1099    };
1100    for u in uses {
1101        out.push((u.target.joined(), u.target.span));
1102    }
1103    for c in consumes {
1104        out.push((c.target.joined(), c.target.span));
1105    }
1106    out
1107}
1108
1109/// #1885: the qualified unit name under `offset`, when the cursor sits on the
1110/// file's own unit-header name or on a `uses`/`consumes` (or a suite's) target
1111/// path — the positions where hover shows a unit's module doc.
1112///
1113/// The match is on the name's byte range (`start..=end`), never on `Span`
1114/// equality, so it holds for a buffer re-parsed with `FileId::UNKNOWN`. Every
1115/// segment of a dotted path answers with the whole name: the cursor on
1116/// `model` in `uses compat.model` names `compat.model`, not a `model`.
1117pub fn unit_name_at(source: &str, offset: usize) -> Option<String> {
1118    let own = own_declaration_name(source);
1119    own.into_iter()
1120        .chain(unit_reference_spans(source))
1121        .find(|(_, span)| span.start <= offset && offset <= span.end)
1122        .map(|(name, _)| name)
1123}
1124
1125/// #1885: hover Markdown for the unit `name` — a fenced `<kind> <name>` line,
1126/// followed by its module doc when one of its files carries one.
1127///
1128/// `sources` are the texts to search: every file of the project the caller
1129/// can see (a multi-file context's doc may live in a sibling of the file the
1130/// cursor is in). A file that declares `name` as a `commons`, `context` or
1131/// `adapter` contributes; `suite` files never do, since a suite's own doc is
1132/// not its target's. At most one file of a unit carries a module doc
1133/// (`bynk.project.duplicate_module_doc`); should a broken project have more,
1134/// the first one in `sources` order is shown. `None` when no source declares
1135/// `name`.
1136pub fn describe_unit<'s>(sources: impl IntoIterator<Item = &'s str>, name: &str) -> Option<String> {
1137    let mut kind: Option<&'static str> = None;
1138    let mut doc: Option<String> = None;
1139    for source in sources {
1140        let Ok(tokens) = tokenize(source) else {
1141            continue;
1142        };
1143        let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
1144            continue;
1145        };
1146        if matches!(unit, SourceUnit::Suite(_)) || unit.name().joined() != name {
1147            continue;
1148        }
1149        kind.get_or_insert(unit.kind_name());
1150        if doc.is_none() {
1151            doc = unit.module_doc().map(str::to_string);
1152        }
1153        if doc.is_some() {
1154            break;
1155        }
1156    }
1157    let kind = kind?;
1158    let mut out = format!("```bynk\n{kind} {name}\n```\n");
1159    if let Some(doc) = doc {
1160        out.push('\n');
1161        out.push_str(&doc);
1162        out.push('\n');
1163    }
1164    Some(out)
1165}
1166
1167/// #1885: the module-doc hover for the unit name under `offset` in `text` (see
1168/// [`unit_name_at`]), resolved against `text` itself, then `others` (the
1169/// project's other files), then the embedded first-party sources (so
1170/// `consumes bynk.cloudflare` still names its unit). `None` when the cursor is
1171/// not on a unit name, or no source declares it.
1172pub fn describe_unit_at<'s>(
1173    text: &'s str,
1174    offset: usize,
1175    others: impl IntoIterator<Item = &'s str>,
1176) -> Option<String> {
1177    let name = unit_name_at(text, offset)?;
1178    let firstparty = bynk_check::firstparty::FIRSTPARTY_SOURCES
1179        .iter()
1180        .map(|(_, src)| *src);
1181    describe_unit(std::iter::once(text).chain(others).chain(firstparty), &name)
1182}
1183
1184/// #848: one intra-doc-link candidate scanned from doc-comment text — a
1185/// `[Name]` shortcut, a `` [`Name`] `` code-span-wrapped shortcut, or a
1186/// `[text][Name]` full reference. `span` is the byte range (into the text the
1187/// scanner was given) of the whole construct to rewrite when `name` resolves;
1188/// `display` is the link text to keep (`Name` for a shortcut, the code span
1189/// including backticks for the code-span form, `text` for a full reference).
1190#[derive(Debug, Clone, PartialEq, Eq)]
1191pub struct DocLinkCandidate {
1192    pub name: String,
1193    pub span: std::ops::Range<usize>,
1194    pub display: String,
1195}
1196
1197/// #848: is `s` shaped like a doc-link candidate name — a bare identifier, or
1198/// two identifiers joined by `.` (an `Owner.member` reference)? Ordinary
1199/// bracketed prose (`[note]`, `[sic]`) is never a candidate.
1200fn is_doc_link_name(s: &str) -> bool {
1201    fn is_ident(part: &str) -> bool {
1202        let mut chars = part.chars();
1203        matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
1204            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1205    }
1206    match s.split_once('.') {
1207        Some((owner, member)) => is_ident(owner) && is_ident(member),
1208        None => is_ident(s),
1209    }
1210}
1211
1212/// #848: fenced-code-block byte ranges (paired ` ``` ` lines only — doc
1213/// comments are free text, not guaranteed well-formed Markdown, so a `~~~`
1214/// fence or an unterminated one just gets a best-effort range to end of text)
1215/// and the set of `[label]: destination`-shaped reference-definition labels,
1216/// both found in `text` line by line. [`scan_doc_link_candidates`] excludes
1217/// both: fenced content is never scanned for candidates, and a label with an
1218/// author-defined reference keeps its ordinary Markdown meaning instead of
1219/// becoming an intra-doc link.
1220fn doc_link_prepass(text: &str) -> (Vec<(usize, usize)>, std::collections::HashSet<String>) {
1221    let mut fenced = Vec::new();
1222    let mut defined = std::collections::HashSet::new();
1223    let mut in_fence = false;
1224    let mut fence_start = 0usize;
1225    let mut pos = 0usize;
1226    loop {
1227        let line_end = text[pos..]
1228            .find('\n')
1229            .map(|i| pos + i + 1)
1230            .unwrap_or(text.len());
1231        let line = &text[pos..line_end];
1232        let trimmed = line.trim_start();
1233        if trimmed.starts_with("```") {
1234            if in_fence {
1235                fenced.push((fence_start, line_end));
1236                in_fence = false;
1237            } else {
1238                fence_start = pos;
1239                in_fence = true;
1240            }
1241        } else if !in_fence {
1242            // A reference definition (`[label]: url`) — CommonMark allows up
1243            // to 3 leading spaces.
1244            let indent = line.len() - trimmed.len();
1245            if indent <= 3
1246                && let Some(rest) = trimmed.strip_prefix('[')
1247                && let Some(close) = rest.find(']')
1248            {
1249                let label = &rest[..close];
1250                if let Some(after_colon) = rest[close + 1..].strip_prefix(':')
1251                    && !label.is_empty()
1252                    && !after_colon.trim().is_empty()
1253                {
1254                    defined.insert(label.to_string());
1255                }
1256            }
1257        }
1258        if line_end >= text.len() {
1259            break;
1260        }
1261        pos = line_end;
1262    }
1263    if in_fence {
1264        fenced.push((fence_start, text.len()));
1265    }
1266    (fenced, defined)
1267}
1268
1269/// #848: scans the bracket construct starting at `text[start]` (which must be
1270/// `[`) for one of the recognised intra-doc-link forms. `None` when the
1271/// bracket isn't well-formed, is an explicit-URL link (`[text](url)`), is a
1272/// collapsed reference (`[text][]` — out of scope), names something that
1273/// isn't identifier-shaped, or names an author-defined reference label.
1274fn scan_bracket_candidate(
1275    text: &str,
1276    start: usize,
1277    defined: &std::collections::HashSet<String>,
1278) -> Option<DocLinkCandidate> {
1279    let bytes = text.as_bytes();
1280    debug_assert_eq!(bytes[start], b'[');
1281    // `` [`Name`] `` — a code-span-wrapped shortcut.
1282    if bytes.get(start + 1) == Some(&b'`') {
1283        let inner_start = start + 2;
1284        let close_tick = text[inner_start..].find('`')? + inner_start;
1285        if bytes.get(close_tick + 1) != Some(&b']') {
1286            return None;
1287        }
1288        let name = &text[inner_start..close_tick];
1289        if !is_doc_link_name(name) || defined.contains(name) {
1290            return None;
1291        }
1292        return Some(DocLinkCandidate {
1293            name: name.to_string(),
1294            span: start..close_tick + 2,
1295            display: text[start + 1..close_tick + 1].to_string(),
1296        });
1297    }
1298    let inner_start = start + 1;
1299    let close = text[inner_start..].find(']')? + inner_start;
1300    let inner = &text[inner_start..close];
1301    let after = close + 1;
1302    match bytes.get(after) {
1303        // `[text](url)` — an explicit URL, unchanged Markdown.
1304        Some(b'(') => None,
1305        // A reference-definition line reached mid-scan (pass 1 already
1306        // excludes true line-start ones) — never a candidate.
1307        Some(b':') => None,
1308        // `[text][label]` — a full reference.
1309        Some(b'[') => {
1310            let label_start = after + 1;
1311            let label_close = text[label_start..].find(']')? + label_start;
1312            let label = &text[label_start..label_close];
1313            // `[text][]` (collapsed reference) is out of scope this increment.
1314            if label.is_empty() || !is_doc_link_name(label) || defined.contains(label) {
1315                return None;
1316            }
1317            Some(DocLinkCandidate {
1318                name: label.to_string(),
1319                span: start..label_close + 1,
1320                display: inner.to_string(),
1321            })
1322        }
1323        // `[Name]` — a shortcut.
1324        _ => {
1325            if !is_doc_link_name(inner) || defined.contains(inner) {
1326                return None;
1327            }
1328            Some(DocLinkCandidate {
1329                name: inner.to_string(),
1330                span: start..after,
1331                display: inner.to_string(),
1332            })
1333        }
1334    }
1335}
1336
1337/// #848: every intra-doc-link candidate in `text`, in order. `[text](url)`,
1338/// an author-defined `[label]: url` reference, fenced-code-block content, and
1339/// non-identifier bracket content (`[note]`) are excluded — see
1340/// `scan_bracket_candidate` and `doc_link_prepass` (private below).
1341pub fn scan_doc_link_candidates(text: &str) -> Vec<DocLinkCandidate> {
1342    let (fenced, defined) = doc_link_prepass(text);
1343    let in_fenced = |i: usize| fenced.iter().any(|&(s, e)| i >= s && i < e);
1344    let bytes = text.as_bytes();
1345    let mut out = Vec::new();
1346    let mut i = 0usize;
1347    while i < bytes.len() {
1348        if bytes[i] != b'[' || in_fenced(i) {
1349            i += 1;
1350            continue;
1351        }
1352        match scan_bracket_candidate(text, i, &defined) {
1353            Some(cand) => {
1354                i = cand.span.end;
1355                out.push(cand);
1356            }
1357            None => i += 1,
1358        }
1359    }
1360    out
1361}
1362
1363/// #848: the `(candidate name, absolute source span)` of every intra-doc-link
1364/// candidate inside every `DocBlock` token in `source` — the clickable
1365/// ranges for doc-comment document links, mirroring [`unit_reference_spans`]'s
1366/// role for `uses`/`consumes` targets. Spans are computed against the raw,
1367/// unstripped doc-block body (`bynk_syntax::lexer::doc_block_body_range`),
1368/// not the common-indent-stripped `doc_block_content` text — that stripping
1369/// is not offset-preserving, so a span-based caller must avoid it. Only
1370/// tokenizes (not a full parse), so links still surface even when the rest of
1371/// the file has a parse error elsewhere, as long as tokenization succeeds.
1372pub fn doc_link_spans(source: &str) -> Vec<(String, Span)> {
1373    let Ok(tokens) = tokenize(source) else {
1374        return Vec::new();
1375    };
1376    let mut out = Vec::new();
1377    for t in tokens {
1378        // #1888: a `--|` doc line links too, one line at a time.
1379        let range = match t.kind {
1380            bynk_syntax::lexer::TokenKind::DocBlock => {
1381                bynk_syntax::lexer::doc_block_body_range(source, t.span)
1382            }
1383            bynk_syntax::lexer::TokenKind::DocLine => {
1384                Some(bynk_syntax::lexer::doc_line_text_range(source, t.span))
1385            }
1386            _ => None,
1387        };
1388        let Some(range) = range else {
1389            continue;
1390        };
1391        for cand in scan_doc_link_candidates(&source[range.clone()]) {
1392            out.push((
1393                cand.name,
1394                Span::new(range.start + cand.span.start, range.start + cand.span.end),
1395            ));
1396        }
1397    }
1398    out
1399}
1400
1401/// #302: the source's own declared qualified name and its span — the
1402/// rewrite target when the file backing this unit is renamed. `None` for a
1403/// `suite` (its `SourceUnit::name()` is its *target*'s name, not one of its
1404/// own; nothing else addresses a suite by name) or on a parse bail.
1405pub fn own_declaration_name(source: &str) -> Option<(String, Span)> {
1406    let tokens = tokenize(source).ok()?;
1407    let (Some(unit), _) = parse_unit_with_recovery(&tokens, source) else {
1408        return None;
1409    };
1410    if matches!(unit, SourceUnit::Suite(_)) {
1411        return None;
1412    }
1413    let name = unit.name();
1414    Some((name.joined(), name.span))
1415}
1416
1417fn describe_item(item: &CommonsItem, name: &str) -> Option<String> {
1418    match item {
1419        CommonsItem::Type(t) if t.name.name == name => Some(describe_type(t)),
1420        // v0.166 (#616): a bare key names a *free* function. A method's identity
1421        // is its compound `"Type.method"` key (below); matching one by its bare
1422        // method name answered with whichever type declared it first, so
1423        // `g.bump()` and even `fn Gauge.bump`'s own declaration rendered
1424        // `Counter.bump`. `signature_help::resolve_label` guards its free-fn path
1425        // the same way.
1426        CommonsItem::Fn(f) if matches!(f.name, FnName::Free(_)) && f.name.ident().name == name => {
1427            Some(describe_fn(f))
1428        }
1429        CommonsItem::Capability(c) if c.name.name == name => Some(describe_capability(c)),
1430        CommonsItem::Service(s) if s.name.name == name => Some(describe_service(s)),
1431        CommonsItem::Agent(a) if a.name.name == name => Some(describe_agent(a)),
1432        CommonsItem::Provider(p) if p.provider_name.name == name => Some(describe_provider(p)),
1433        // v0.166 (#616): an actor, keyed by its plain name — the `Actor` index
1434        // kind ADR 0190 filed as the clearest evidence that the renderer, not the
1435        // ladder, is where these were missing. `by u: User` resolved here and
1436        // rendered nothing.
1437        CommonsItem::Actor(a) if a.name.name == name => Some(describe_actor(a)),
1438        // message-bundles slice 1 (#859): a messages block, keyed by its tag —
1439        // the same top-level, plain-name convention as Capability/Service/
1440        // Agent above.
1441        CommonsItem::Messages(m) if m.tag == name => Some(describe_messages(m)),
1442        // #1883: an asset's hover and docs.
1443        CommonsItem::Asset(a) if a.name.name == name => Some(describe_asset(a)),
1444        // #2387, slice 1: an embed's hover and docs.
1445        CommonsItem::Embed(e) if e.name.name == name => Some(describe_embed(e)),
1446        // #972 (Events slice 3a): an event, keyed by its plain name — the same
1447        // top-level convention as the arms above. Reuses `describe_type` via
1448        // the synthetic `TypeDecl` `as_type_decl` already builds for every
1449        // other event-as-type consumer (exports/consumes/construction).
1450        CommonsItem::Event(e) if e.name.name == name => Some(describe_event(e)),
1451        // #611 (gap B): a record field, keyed `"Type.field"` by the index — the
1452        // checker records construction labels and field accesses as `Field` refs,
1453        // so hover resolves the key but had no arm to render it and fell through
1454        // to the locals path, which name-matches in scope (a `title:` label bound
1455        // to a same-named handler param). Top-level names carry no `.`, so the
1456        // compound key can only match here.
1457        CommonsItem::Type(t) => {
1458            let (owner, field) = name.rsplit_once('.')?;
1459            if t.name.name != owner {
1460                return None;
1461            }
1462            let TypeBody::Record(r) = &t.body else {
1463                return None;
1464            };
1465            r.fields
1466                .iter()
1467                .find(|f| f.name.name == field)
1468                .map(|f| describe_record_field(t, f))
1469        }
1470        // #972 (Events slice 3a): an event field, keyed `"Event.field"` —
1471        // mirrors the `CommonsItem::Type` arm above via the same synthetic
1472        // `TypeDecl`, so a defaulted event field's hover also renders its
1473        // `= <expr>`.
1474        CommonsItem::Event(e) => {
1475            let (owner, field) = name.rsplit_once('.')?;
1476            if e.name.name != owner {
1477                return None;
1478            }
1479            let synthetic = e.as_type_decl();
1480            e.body
1481                .fields
1482                .iter()
1483                .find(|f| f.name.name == field)
1484                .map(|f| describe_record_field(&synthetic, f))
1485        }
1486        // v0.166 (#616): a method, keyed `"Type.method"` (ADR 0069). `display()`
1487        // renders exactly that key, so the compound name matches the one method
1488        // it names — the type prefix is what disambiguates `Counter.bump` from
1489        // `Gauge.bump`.
1490        CommonsItem::Fn(f) => (f.name.display() == name).then(|| describe_fn(f)),
1491        // v0.166 (#616): a capability operation, keyed `"Cap.op"` (ADR 0069).
1492        CommonsItem::Capability(c) => {
1493            let (owner, op) = name.rsplit_once('.')?;
1494            if c.name.name != owner {
1495                return None;
1496            }
1497            c.ops
1498                .iter()
1499                .find(|o| o.name.name == op)
1500                .map(|o| describe_capability_op(c, o))
1501        }
1502        // #304: an agent handler, keyed `"Agent.handler"` — the checker
1503        // records a dispatch call (`agentInstance.handler(...)`) as this
1504        // compound key, mirroring the method/field/op convention above.
1505        CommonsItem::Agent(a) => {
1506            let (owner, handler) = name.rsplit_once('.')?;
1507            if a.name.name != owner {
1508                return None;
1509            }
1510            a.handlers
1511                .iter()
1512                .find(|h| h.method_name.as_ref().is_some_and(|n| n.name == handler))
1513                .map(|h| describe_agent_handler(a, h, handler))
1514        }
1515        _ => None,
1516    }
1517}
1518
1519/// v0.166 (#616): an actor as declared — the `auth` scheme with its config, the
1520/// `identity` type, or the refinement form's base and claim predicate.
1521/// Mirrors `bynk-fmt`'s `format_actor`, as [`describe_agent`] mirrors an agent.
1522///
1523/// #847: `pub(crate)` so the documentation-view aggregator ([`crate::documentation`])
1524/// renders each declaration through the *same* signature+doc assembly hover uses,
1525/// rather than a parallel renderer that could drift from it.
1526pub(crate) fn describe_actor(a: &ActorDecl) -> String {
1527    let mut out = String::from("```bynk\n");
1528    match &a.refinement {
1529        // `actor Admin = User where hasClaim("admin")` (ADR 0091).
1530        Some(r) => out.push_str(&format!(
1531            "actor {} = {} where {}",
1532            a.name.name,
1533            r.base.name,
1534            bynk_fmt::expr_to_string(&r.predicate)
1535        )),
1536        None => {
1537            // An absent `auth` is the `None` scheme, which is how it parses.
1538            let auth = a.auth.as_ref().map_or("None", |i| i.name.as_str());
1539            out.push_str(&format!("actor {} {{ auth = {auth}", a.name.name));
1540            if !a.auth_config.is_empty() {
1541                let args: Vec<String> = a
1542                    .auth_config
1543                    .iter()
1544                    .map(|arg| match &arg.value {
1545                        // The parser resolves escapes at lex time, so the stored
1546                        // value is *unescaped* — re-escape it through the
1547                        // formatter's own escaper, or a `"` in the config renders
1548                        // as invalid Bynk inside the fence below.
1549                        SchemeArgValue::Str(s) => {
1550                            format!("{} = \"{}\"", arg.key.name, bynk_fmt::escape_string(s))
1551                        }
1552                        SchemeArgValue::Int(n) => format!("{} = {n}", arg.key.name),
1553                    })
1554                    .collect();
1555                out.push_str(&format!("({})", args.join(", ")));
1556            }
1557            if let Some(id) = &a.identity {
1558                out.push_str(&format!(", identity = {}", type_ref_str(id)));
1559            }
1560            out.push_str(" }");
1561        }
1562    }
1563    out.push_str("\n```\n");
1564    if let Some(doc) = &a.documentation {
1565        out.push('\n');
1566        out.push_str(doc);
1567        out.push('\n');
1568    }
1569    out
1570}
1571
1572/// #926: an op's own `[T, …]` type parameters, rendered for hover — `""` when
1573/// the op is non-generic. Shared by [`describe_capability_op`] and
1574/// [`describe_capability`] so the two signature renderings can't drift.
1575fn capability_op_type_params_str(op: &CapabilityOp) -> String {
1576    if op.type_params.is_empty() {
1577        return String::new();
1578    }
1579    let names: Vec<&str> = op
1580        .type_params
1581        .iter()
1582        .map(|tp| tp.name.name.as_str())
1583        .collect();
1584    format!("[{}]", names.join(", "))
1585}
1586
1587/// v0.166 (#616): a capability operation as declared, attributed to the
1588/// capability that owns it. Mirrors how [`describe_capability`] renders the same
1589/// op within the capability body, as [`describe_record_field`] does for a field.
1590pub(crate) fn describe_capability_op(c: &CapabilityDecl, op: &CapabilityOp) -> String {
1591    let params: Vec<String> = op
1592        .params
1593        .iter()
1594        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1595        .collect();
1596    let mut out = format!(
1597        "```bynk\nfn {}{}({}) -> {}\n```\n\nAn operation of capability `{}`.\n",
1598        op.name.name,
1599        capability_op_type_params_str(op),
1600        params.join(", "),
1601        type_ref_str(&op.return_type),
1602        c.name.name
1603    );
1604    if let Some(doc) = &op.documentation {
1605        out.push('\n');
1606        out.push_str(doc);
1607        out.push('\n');
1608    }
1609    out
1610}
1611
1612/// #304: an agent handler as declared, attributed to the agent that owns it —
1613/// its dispatch name, params, and return type. Mirrors [`describe_capability_op`].
1614pub(crate) fn describe_agent_handler(a: &AgentDecl, h: &Handler, handler_name: &str) -> String {
1615    let params: Vec<String> = h
1616        .params
1617        .iter()
1618        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1619        .collect();
1620    let mut out = format!(
1621        "```bynk\nfn {}({}) -> {}\n```\n\nA handler of agent `{}`.\n",
1622        handler_name,
1623        params.join(", "),
1624        type_ref_str(&h.return_type),
1625        a.name.name
1626    );
1627    if let Some(doc) = &h.documentation {
1628        out.push('\n');
1629        out.push_str(doc);
1630        out.push('\n');
1631    }
1632    out
1633}
1634
1635/// #847: a service handler as declared, attributed to the service that owns it —
1636/// its route/protocol (`on GET("/x")`, `on call`, …) with the typed params and
1637/// return type. The documentation-view counterpart to [`describe_agent_handler`]:
1638/// a service handler has no compound index key (its route, not a dispatch name,
1639/// identifies it — see `bynk-lsp/src/sequence_request.rs`), so hover never
1640/// describes one on its own; the doc page is the first surface that renders each
1641/// individually, and it does so through the same fenced-signature + doc-prose
1642/// shape every other `describe_*` uses.
1643pub(crate) fn describe_service_handler(s: &ServiceDecl, h: &Handler) -> String {
1644    let params: Vec<String> = h
1645        .params
1646        .iter()
1647        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1648        .collect();
1649    let mut out = format!(
1650        "```bynk\n{}({}) -> {}\n```\n\nA handler of service `{}`.\n",
1651        handler_line(h),
1652        params.join(", "),
1653        type_ref_str(&h.return_type),
1654        s.name.name
1655    );
1656    if let Some(doc) = &h.documentation {
1657        out.push('\n');
1658        out.push_str(doc);
1659        out.push('\n');
1660    }
1661    out
1662}
1663
1664/// #611: a record field as declared — its type and any `where` refinement —
1665/// attributed to the record that owns it. Mirrors how [`describe_type`] renders
1666/// the same field within the record body.
1667///
1668/// #1888: a documented field shows its own doc in place of the generic
1669/// "A field of `T`." line.
1670pub(crate) fn describe_record_field(t: &TypeDecl, f: &RecordField) -> String {
1671    let mut sig = format!("{}: {}", f.name.name, type_ref_str(&f.type_ref));
1672    if let Some(r) = &f.refinement {
1673        sig.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1674    }
1675    // #972 (Events slice 3a): render a field default the same way the
1676    // formatter does — `= <expr>` — so a subscriber-visible default is not
1677    // silently dropped from hover.
1678    if let Some(init) = &f.init {
1679        sig.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1680    }
1681    format!(
1682        "```bynk\n{sig}\n```\n\n{}",
1683        member_prose(&f.documentation, || format!(
1684            "A field of `{}`.",
1685            t.name.name
1686        ))
1687    )
1688}
1689
1690/// #1888: a member's doc, trailing whitespace trimmed, or `fallback` when it
1691/// has none.
1692fn member_prose(doc: &Option<Doc>, fallback: impl FnOnce() -> String) -> String {
1693    match doc {
1694        Some(d) => d.trim_end().to_string(),
1695        None => fallback(),
1696    }
1697}
1698
1699/// #1888: a sum variant as declared, `| Name(field: Type, …)`, attributed to
1700/// its sum unless it carries its own doc, which shows in its place. A payload
1701/// field's doc follows, one line of the list per documented field.
1702pub(crate) fn describe_variant(t: &TypeDecl, v: &Variant) -> String {
1703    let mut sig = format!("| {}", v.name.name);
1704    if !v.payload.is_empty() {
1705        let parts: Vec<String> = v
1706            .payload
1707            .iter()
1708            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1709            .collect();
1710        sig.push_str(&format!("({})", parts.join(", ")));
1711    }
1712    let mut out = format!(
1713        "```bynk\n{sig}\n```\n\n{}",
1714        member_prose(&v.documentation, || format!(
1715            "A variant of `{}`.",
1716            t.name.name
1717        ))
1718    );
1719    for p in &v.payload {
1720        if let Some(doc) = &p.documentation {
1721            out.push_str(&format!("\n\n`{}`: {}", p.name.name, doc.trim_end()));
1722        }
1723    }
1724    out
1725}
1726
1727/// #1888: one payload field of a sum variant, attributed to its variant
1728/// unless it carries its own doc.
1729pub(crate) fn describe_variant_field(t: &TypeDecl, v: &Variant, p: &VariantField) -> String {
1730    format!(
1731        "```bynk\n{}: {}\n```\n\n{}",
1732        p.name.name,
1733        type_ref_str(&p.type_ref),
1734        member_prose(&p.documentation, || format!(
1735            "A field of `{}.{}`.",
1736            t.name.name, v.name.name
1737        ))
1738    )
1739}
1740
1741/// #1888: every sum variant declared in `source`'s unit, with its sum.
1742fn with_sum_variants<R>(
1743    source: &str,
1744    mut f: impl FnMut(&TypeDecl, &Variant) -> Option<R>,
1745) -> Option<R> {
1746    let tokens = tokenize(source).ok()?;
1747    let (unit, _errs) = parse_unit_with_recovery(&tokens, source);
1748    let items: &[CommonsItem] = match unit.as_ref()? {
1749        SourceUnit::Commons(c) => &c.items,
1750        SourceUnit::Context(c) => &c.items,
1751        SourceUnit::Adapter(a) => &a.items,
1752        SourceUnit::Suite(_) => &[],
1753    };
1754    for item in items {
1755        if let CommonsItem::Type(t) = item
1756            && let TypeBody::Sum(s) = &t.body
1757        {
1758            for v in &s.variants {
1759                if let Some(r) = f(t, v) {
1760                    return Some(r);
1761                }
1762            }
1763        }
1764    }
1765    None
1766}
1767
1768/// #1888: the hover for a sum variant named by a qualified key,
1769/// `"Shape.Circle"`, declared in `source`; `None` when no sum there has it.
1770pub fn describe_variant_key(source: &str, key: &str) -> Option<String> {
1771    let (owner, name) = key.rsplit_once('.')?;
1772    with_sum_variants(source, |t, v| {
1773        (t.name.name == owner && v.name.name == name).then(|| describe_variant(t, v))
1774    })
1775}
1776
1777/// #1888: the hover for a sum variant named bare, as a pattern or a
1778/// constructor names it (`Circle(r)`), when exactly one sum in `source`
1779/// declares a variant of that name; `None` otherwise, so an ambiguous name
1780/// answers nothing rather than the wrong variant.
1781pub fn describe_variant_named(source: &str, name: &str) -> Option<String> {
1782    let mut found: Vec<String> = Vec::new();
1783    with_sum_variants::<()>(source, |t, v| {
1784        if v.name.name == name {
1785            found.push(describe_variant(t, v));
1786        }
1787        None
1788    });
1789    (found.len() == 1).then(|| found.remove(0))
1790}
1791
1792pub(crate) fn describe_type(t: &TypeDecl) -> String {
1793    let mut out = String::new();
1794    out.push_str("```bynk\n");
1795    // v0.157 (ADR 0183): render `[A, B]` type parameters on a generic type.
1796    let params = if t.type_params.is_empty() {
1797        String::new()
1798    } else {
1799        let names: Vec<&str> = t
1800            .type_params
1801            .iter()
1802            .map(|tp| tp.name.name.as_str())
1803            .collect();
1804        format!("[{}]", names.join(", "))
1805    };
1806    out.push_str(&format!("type {}{} = ", t.name.name, params));
1807    match &t.body {
1808        // v0.123 (slice 2): render the refined/opaque `where` predicate (was
1809        // collapsed to the bare base) via the formatter's own renderer.
1810        TypeBody::Refined {
1811            base, refinement, ..
1812        } => {
1813            out.push_str(base.name());
1814            if let Some(r) = refinement {
1815                out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1816            }
1817        }
1818        TypeBody::Opaque {
1819            base, refinement, ..
1820        } => {
1821            out.push_str(&format!("opaque {}", base.name()));
1822            if let Some(r) = refinement {
1823                out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1824            }
1825        }
1826        // Record fields, one per line (was collapsed to `record`).
1827        TypeBody::Record(r) => {
1828            if r.fields.is_empty() {
1829                out.push_str("{}");
1830            } else {
1831                out.push_str("{\n");
1832                for f in &r.fields {
1833                    out.push_str(&format!("\t{}: {}", f.name.name, type_ref_str(&f.type_ref)));
1834                    if let Some(r) = &f.refinement {
1835                        out.push_str(&format!(" where {}", bynk_fmt::refinement_to_string(r)));
1836                    }
1837                    // #972 (Events slice 3a): an event field's default,
1838                    // reachable here via `EventDecl::as_type_decl`'s
1839                    // synthetic `TypeDecl`.
1840                    if let Some(init) = &f.init {
1841                        out.push_str(&format!(" = {}", bynk_fmt::expr_to_string(init)));
1842                    }
1843                    out.push_str(",\n");
1844                }
1845                out.push('}');
1846            }
1847        }
1848        // Sum variants, with payloads (was collapsed to `sum`).
1849        TypeBody::Sum(s) => {
1850            out.push_str("enum {\n");
1851            for v in &s.variants {
1852                out.push_str(&format!("\t{}", v.name.name));
1853                if !v.payload.is_empty() {
1854                    let parts: Vec<String> = v
1855                        .payload
1856                        .iter()
1857                        .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1858                        .collect();
1859                    out.push_str(&format!("({})", parts.join(", ")));
1860                }
1861                out.push_str(",\n");
1862            }
1863            out.push('}');
1864        }
1865    }
1866    out.push_str("\n```\n");
1867    if let Some(doc) = &t.documentation {
1868        out.push('\n');
1869        out.push_str(doc);
1870        out.push('\n');
1871    }
1872    out
1873}
1874
1875/// Events slice 3b (#978): additively render an event's `@schema(N)` (or any
1876/// future event annotation) into its hover, the same rendering
1877/// `store_field_hover` already uses. `describe_type`'s signature is shared
1878/// with every ordinary type's hover, so this post-processes its output for
1879/// the one synthetic-`TypeDecl` caller that has annotations to show, rather
1880/// than widening that signature. A no-op when the event declares none —
1881/// absent-annotation hover stays byte-identical to before this slice.
1882fn describe_event(e: &EventDecl) -> String {
1883    let base = describe_type(&e.as_type_decl());
1884    if e.annotations.is_empty() {
1885        return base;
1886    }
1887    let annotations: String = e
1888        .annotations
1889        .iter()
1890        .map(|a| format!(" {}", bynk_fmt::annotation_to_string(a)))
1891        .collect();
1892    let from = format!("type {} = ", e.name.name);
1893    let to = format!("type {}{annotations} = ", e.name.name);
1894    base.replacen(&from, &to, 1)
1895}
1896
1897pub(crate) fn describe_fn(f: &FnDecl) -> String {
1898    let mut out = String::new();
1899    out.push_str("```bynk\n");
1900    out.push_str("fn ");
1901    out.push_str(&f.name.display());
1902    out.push('(');
1903    let mut parts: Vec<String> = Vec::new();
1904    if f.has_self {
1905        parts.push("self".into());
1906    }
1907    for p in &f.params {
1908        parts.push(format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)));
1909    }
1910    out.push_str(&parts.join(", "));
1911    out.push_str(") -> ");
1912    out.push_str(&type_ref_str(&f.return_type));
1913    // v0.123 (slice 2): the contract clauses (v0.115), beneath the signature —
1914    // rendered through the formatter's own predicate renderer.
1915    for c in &f.requires {
1916        out.push_str(&format!(
1917            "\n\trequires {}: {}",
1918            c.name.name,
1919            bynk_fmt::expr_to_string(&c.predicate)
1920        ));
1921    }
1922    for c in &f.ensures {
1923        out.push_str(&format!(
1924            "\n\tensures {}: {}",
1925            c.name.name,
1926            bynk_fmt::expr_to_string(&c.predicate)
1927        ));
1928    }
1929    out.push_str("\n```\n");
1930    if let Some(doc) = &f.documentation {
1931        out.push('\n');
1932        out.push_str(doc);
1933        out.push('\n');
1934    }
1935    out
1936}
1937
1938pub(crate) fn describe_capability(c: &CapabilityDecl) -> String {
1939    let mut out = String::new();
1940    // S4a (#1966): a derived capability has no body of its own to render.
1941    if c.derived.is_some() {
1942        return describe_derived_capability(c, None, None);
1943    }
1944    out.push_str("```bynk\ncapability ");
1945    out.push_str(&c.name.name);
1946    out.push_str(" {\n");
1947    for op in &c.ops {
1948        out.push_str("\tfn ");
1949        out.push_str(&op.name.name);
1950        out.push_str(&capability_op_type_params_str(op));
1951        out.push('(');
1952        let parts: Vec<String> = op
1953            .params
1954            .iter()
1955            .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
1956            .collect();
1957        out.push_str(&parts.join(", "));
1958        out.push_str(") -> ");
1959        out.push_str(&type_ref_str(&op.return_type));
1960        out.push('\n');
1961    }
1962    out.push_str("}\n```\n");
1963    if let Some(doc) = &c.documentation {
1964        out.push('\n');
1965        out.push_str(doc);
1966        out.push('\n');
1967    }
1968    out
1969}
1970
1971/// S4a (#1966): a derived capability (`capability Sessions = Kv`): its
1972/// declaration, that it is a distinct instance of its base (in `base_unit`,
1973/// when known), and the base's operations, when its declaration is at hand.
1974pub(crate) fn describe_derived_capability(
1975    c: &CapabilityDecl,
1976    base_unit: Option<&str>,
1977    base: Option<&CapabilityDecl>,
1978) -> String {
1979    let Some(d) = &c.derived else {
1980        return describe_capability(c);
1981    };
1982    let mut out = format!("```bynk\ncapability {} = {}", c.name.name, d.base.name);
1983    for a in &d.annotations {
1984        out.push_str(" @");
1985        out.push_str(&a.name.name);
1986    }
1987    let from = base_unit.map_or(String::new(), |u| format!(" (`{u}`)"));
1988    // L7 (#2414): an `EdgeCache` instance owns no storage: it switches the
1989    // Worker's edge cache on.
1990    let what = if d.base.name == "EdgeCache" {
1991        "this context's Worker's edge cache"
1992    } else {
1993        "its own storage"
1994    };
1995    out.push_str(&format!(
1996        "\n```\n\nA distinct instance of `{}`{from}: {what}, with `{}`'s operations",
1997        d.base.name, d.base.name
1998    ));
1999    match base {
2000        Some(b) if !b.ops.is_empty() => {
2001            out.push_str(":\n\n```bynk\n");
2002            for op in &b.ops {
2003                let params: Vec<String> = op
2004                    .params
2005                    .iter()
2006                    .map(|p| format!("{}: {}", p.name.name, type_ref_str(&p.type_ref)))
2007                    .collect();
2008                out.push_str(&format!(
2009                    "fn {}{}({}) -> {}\n",
2010                    op.name.name,
2011                    capability_op_type_params_str(op),
2012                    params.join(", "),
2013                    type_ref_str(&op.return_type)
2014                ));
2015            }
2016            out.push_str("```\n");
2017        }
2018        _ => out.push_str(".\n"),
2019    }
2020    // P3 (#2342): a rate limiter's emitted stanza, and how it counts.
2021    if d.base.name == "RateLimit" {
2022        let number = |name: &str| {
2023            d.annotations
2024                .iter()
2025                .find(|a| a.name.name == name)
2026                .and_then(|a| a.args.first())
2027                .and_then(|arg| match &arg.value.kind {
2028                    ExprKind::IntLit { value, .. } => Some(*value),
2029                    ExprKind::DurationLit { millis, .. } => Some(millis / 1000),
2030                    _ => None,
2031                })
2032        };
2033        if let (Some(limit), Some(period)) = (number("limit"), number("period")) {
2034            out.push_str(&format!(
2035                "\nEmits `[[ratelimits]]` with `simple = {{ limit = {limit}, period = {period} }}`: {limit} calls per key every {period} seconds. Counting is approximate (per Cloudflare location, eventually consistent).\n"
2036            ));
2037        }
2038    }
2039    if d.base.name == "EdgeCache" {
2040        out.push_str("\nEmits `[cache] enabled = true`: a `@cache` route's `2xx` is cached at Cloudflare's edge, and every other `GET` or `HEAD` answer is `no-store`.\n");
2041    }
2042    if let Some(doc) = &c.documentation {
2043        out.push('\n');
2044        out.push_str(doc);
2045        out.push('\n');
2046    }
2047    out
2048}
2049
2050/// message-bundles slice 1 (#859): a message bundle's tag, its annotations
2051/// (`@reference`, unresolved cardinality shown as-is — that's a checker
2052/// concern, not hover's), and its declared codes.
2053pub(crate) fn describe_messages(m: &MessagesDecl) -> String {
2054    let mut out = String::new();
2055    out.push_str("```bynk\nmessages ");
2056    out.push('"');
2057    out.push_str(&m.tag);
2058    out.push('"');
2059    for ann in &m.annotations {
2060        out.push(' ');
2061        out.push_str(&bynk_fmt::annotation_to_string(ann));
2062    }
2063    out.push_str(" {\n");
2064    for entry in &m.entries {
2065        // message-bundles slice 3 (#878): name any non-plain (plural/
2066        // select/number/date) placeholders inline, so an author can tell
2067        // at a glance which codes carry ICU dispatch without expanding the
2068        // (elided) template text itself.
2069        let icu_kinds: Vec<String> = bynk_check::icu::template_format_kinds(&entry.template)
2070            .into_iter()
2071            .map(|(name, kind)| (name.to_string(), kind.as_str()))
2072            .filter(|(_, kind)| *kind != "plain")
2073            .map(|(name, kind)| format!("{name}: {kind}"))
2074            .collect();
2075        if icu_kinds.is_empty() {
2076            out.push_str(&format!("\t\"{}\" => …\n", entry.code));
2077        } else {
2078            out.push_str(&format!(
2079                "\t\"{}\" => …  // {}\n",
2080                entry.code,
2081                icu_kinds.join(", ")
2082            ));
2083        }
2084    }
2085    out.push_str("}\n```\n");
2086    if let Some(doc) = &m.documentation {
2087        out.push('\n');
2088        out.push_str(doc);
2089        out.push('\n');
2090    }
2091    out
2092}
2093
2094/// #1883, slice 1a: an `asset` declaration's markdown — the declaration
2095/// itself, the rule that it is public, then its doc.
2096/// #2387, slice 1: an embed's hover and docs. An embed is private: its file's
2097/// text is compiled into the code as a `String`, never served.
2098pub(crate) fn describe_embed(e: &EmbedDecl) -> String {
2099    let mut out = format!(
2100        "```bynk\nembed {} = \"{}\"\n```\n\nPrivate: the file's text, compiled into the code as a `String`; never served.\n",
2101        e.name.name,
2102        bynk_fmt::escape_string(&e.path)
2103    );
2104    if let Some(doc) = &e.documentation {
2105        out.push('\n');
2106        out.push_str(doc);
2107        out.push('\n');
2108    }
2109    out
2110}
2111
2112pub(crate) fn describe_asset(a: &AssetDecl) -> String {
2113    let at =
2114        a.at.as_ref()
2115            .map(|(p, _)| format!(" at \"{}\"", bynk_fmt::escape_string(p)))
2116            .unwrap_or_default();
2117    // #1883: where it is served. The editor lists the `assets/` folder but
2118    // never reads its bytes, so a content-hashed URL shows its shape with
2119    // `<hash>` in place of the content hash — from the same function the
2120    // build uses, so the two cannot drift.
2121    let served = match &a.at {
2122        Some((p, _)) => format!("Served at `{p}`."),
2123        None => format!(
2124            "Served at `{}` (content-hashed, cached as immutable).",
2125            bynk_check::assets::served_path_with_hash(
2126                &bynk_project::assets::normalise_asset_path(&a.path),
2127                "<hash>"
2128            )
2129        ),
2130    };
2131    let mut out = format!(
2132        "```bynk\nasset {} = \"{}\"{at}\n```\n\n{served} Public: served by the platform, never by the Worker.\n",
2133        a.name.name,
2134        bynk_fmt::escape_string(&a.path)
2135    );
2136    if let Some(doc) = &a.documentation {
2137        out.push('\n');
2138        out.push_str(doc);
2139        out.push('\n');
2140    }
2141    out
2142}
2143
2144/// v0.123 (slice 2): the `from <protocol>` header suffix for a service, or the
2145/// empty string for a plain `on call` service.
2146fn service_protocol_suffix(p: &ServiceProtocol) -> String {
2147    match p {
2148        ServiceProtocol::Call => String::new(),
2149        ServiceProtocol::Http => " from http".to_string(),
2150        ServiceProtocol::Cron => " from cron".to_string(),
2151        ServiceProtocol::Queue { name } => format!(" from queue(\"{name}\")"),
2152        ServiceProtocol::WebSocket { .. } => " from websocket".to_string(),
2153        // Events track slice 1 (spine #936): a subscription pattern is not
2154        // rendered in this short suffix, matching how WebSocket's in/out
2155        // types are also omitted here — this is a one-line summary label,
2156        // not a full re-render of the header.
2157        ServiceProtocol::Events { event_type, .. } => {
2158            format!(" from Events({})", type_ref_str(event_type))
2159        }
2160    }
2161}
2162
2163/// v0.131: a one-line summary of a `cors { }` policy for hover — the origins
2164/// (always present), then `credentials`/`maxAge` when set.
2165fn cors_summary(cors: &CorsPolicy) -> String {
2166    let mut parts = vec![format!("origins: {:?}", cors.origins())];
2167    if cors.credentials() {
2168        parts.push("credentials: true".to_string());
2169    }
2170    if let Some(secs) = cors.max_age_secs() {
2171        parts.push(format!("maxAge: {secs}s"));
2172    }
2173    parts.join(", ")
2174}
2175
2176/// v0.141: a one-line summary of a `security { }` policy for hover — `nosniff`
2177/// (default on, shown when off) and `hsts` (when opted in).
2178fn security_summary(security: &SecurityPolicy) -> String {
2179    let mut parts = Vec::new();
2180    if !security.nosniff() {
2181        parts.push("nosniff: false".to_string());
2182    }
2183    if let Some(secs) = security.hsts_max_age_secs() {
2184        parts.push(format!("hsts: {secs}s"));
2185    }
2186    if parts.is_empty() {
2187        // The default posture (nosniff on, no HSTS) with an empty block.
2188        "nosniff".to_string()
2189    } else {
2190        parts.join(", ")
2191    }
2192}
2193
2194/// v0.142 (ADR 0165): a one-line summary of a `limits { }` policy for hover — the
2195/// `maxBody` byte ceiling when set.
2196fn limits_summary(limits: &LimitsPolicy) -> String {
2197    match limits.max_body() {
2198        Some(bytes) => format!("maxBody: {bytes} bytes"),
2199        None => "maxBody".to_string(),
2200    }
2201}
2202
2203/// v0.123 (slice 2): the `on …` line for a handler — its route/protocol shape.
2204pub(crate) fn handler_line(h: &Handler) -> String {
2205    match &h.kind {
2206        HandlerKind::Call => "on call".to_string(),
2207        HandlerKind::Http { method, path } => format!("on {}(\"{}\")", method.as_str(), path),
2208        HandlerKind::Cron { expr } => format!("on schedule(\"{expr}\")"),
2209        HandlerKind::Message => "on message".to_string(),
2210        HandlerKind::Open => "on open".to_string(),
2211        HandlerKind::Close => "on close".to_string(),
2212        HandlerKind::Event => "on event".to_string(),
2213        HandlerKind::Rejected => "on rejected".to_string(),
2214    }
2215}
2216
2217pub(crate) fn describe_service(s: &ServiceDecl) -> String {
2218    // v0.123 (slice 2): the protocol header and a line per route (was a bare
2219    // handler count).
2220    let mut out = format!(
2221        "```bynk\nservice {}{} {{\n",
2222        s.name.name,
2223        service_protocol_suffix(&s.protocol)
2224    );
2225    // v0.131: the CORS policy, if any, renders as a `cors { … }` header line
2226    // summarising the origins (the load-bearing field).
2227    if let Some(cors) = &s.cors {
2228        out.push_str(&format!("\tcors {{ {} }}\n", cors_summary(cors)));
2229    }
2230    // v0.141: the security-headers policy, if declared, renders similarly.
2231    if let Some(security) = &s.security {
2232        out.push_str(&format!(
2233            "\tsecurity {{ {} }}\n",
2234            security_summary(security)
2235        ));
2236    }
2237    // v0.142 (ADR 0165): the request-limits policy, if declared, renders similarly.
2238    if let Some(limits) = &s.limits {
2239        out.push_str(&format!("\tlimits {{ {} }}\n", limits_summary(limits)));
2240    }
2241    for h in &s.handlers {
2242        out.push_str(&format!("\t{}\n", handler_line(h)));
2243    }
2244    out.push_str("}\n```\n");
2245    if let Some(doc) = &s.documentation {
2246        out.push('\n');
2247        out.push_str(doc);
2248        out.push('\n');
2249    }
2250    out
2251}
2252
2253/// v0.123 (slice 2): a store field's kind — `Cell[Int]`, `Map[K, V]`, or a bare
2254/// head with no type args.
2255fn store_kind_str(k: &StoreKind) -> String {
2256    if k.args.is_empty() {
2257        k.head.name.clone()
2258    } else {
2259        let args: Vec<String> = k.args.iter().map(type_ref_str).collect();
2260        format!("{}[{}]", k.head.name, args.join(", "))
2261    }
2262}
2263
2264pub(crate) fn describe_agent(a: &AgentDecl) -> String {
2265    // v0.123 (slice 2): the store fields plus the `invariant`/`transition` step
2266    // invariants (v0.116), was a bare store-field count.
2267    let mut out = format!(
2268        "```bynk\nagent {} {{\n\tkey {}: {}\n",
2269        a.name.name,
2270        a.key_name.name,
2271        type_ref_str(&a.key_type),
2272    );
2273    for f in &a.store_fields {
2274        out.push_str(&format!(
2275            "\tstore {}: {}\n",
2276            f.name.name,
2277            store_kind_str(&f.kind)
2278        ));
2279    }
2280    for inv in &a.invariants {
2281        out.push_str(&format!(
2282            "\tinvariant {}: {}\n",
2283            inv.name.name,
2284            bynk_fmt::expr_to_string(&inv.predicate)
2285        ));
2286    }
2287    for tr in &a.transitions {
2288        out.push_str(&format!(
2289            "\ttransition {}: {}\n",
2290            tr.name.name,
2291            bynk_fmt::expr_to_string(&tr.predicate)
2292        ));
2293    }
2294    out.push_str("}\n```\n");
2295    if let Some(doc) = &a.documentation {
2296        out.push('\n');
2297        out.push_str(doc);
2298        out.push('\n');
2299    }
2300    out
2301}
2302
2303pub(crate) fn describe_provider(p: &ProviderDecl) -> String {
2304    let mut out = format!(
2305        "```bynk\nprovides {} = {}\n```\n",
2306        p.capability.name, p.provider_name.name
2307    );
2308    if let Some(doc) = &p.documentation {
2309        out.push('\n');
2310        out.push_str(doc);
2311        out.push('\n');
2312    }
2313    out
2314}
2315
2316/// A cross-file declaration lookup result: the path of the file containing
2317/// the declaration, the declaration's source span, and the full source
2318/// text of that file (returned because callers need it to convert the
2319/// span to an LSP range and to build hover content).
2320pub struct CrossFileSymbol {
2321    pub path: PathBuf,
2322    pub span: Span,
2323    pub source: String,
2324}
2325
2326/// The project's files, in the same deterministic order
2327/// `bynk_project::discover_bynk_files` produces (sorted by path) —
2328/// `HashMap` iteration order is unspecified, and "the first hit wins" only
2329/// means something if that order is stable and reproducible.
2330fn sorted_paths(files: &HashMap<PathBuf, String>) -> Vec<&PathBuf> {
2331    let mut paths: Vec<&PathBuf> = files.keys().collect();
2332    paths.sort();
2333    paths
2334}
2335
2336/// Find `name`'s declaration in any project file other than `current_path`.
2337/// Content-ownership track (#1086) slice 1: `files` is a pre-read
2338/// `(path, content)` map (the caller's overlay-then-disk sweep) rather than
2339/// bare paths this function used to read from disk itself. Returns the
2340/// first hit, in path-sorted order (see `sorted_paths`); `None` if the name
2341/// is not found anywhere in the project.
2342///
2343/// Caller is responsible for trying the open file's local symbol table
2344/// first; this function intentionally skips `current_path` so the local
2345/// path remains the fast path.
2346pub fn find_declaration_cross_file(
2347    files: &HashMap<PathBuf, String>,
2348    current_path: &Path,
2349    name: &str,
2350) -> Option<CrossFileSymbol> {
2351    for path in sorted_paths(files) {
2352        if path.as_path() == current_path {
2353            continue;
2354        }
2355        let source = &files[path];
2356        if let Some(span) = find_declaration_span(source, name) {
2357            return Some(CrossFileSymbol {
2358                path: path.clone(),
2359                span,
2360                source: source.clone(),
2361            });
2362        }
2363    }
2364    None
2365}
2366
2367/// Markdown hover content for `name` from any project file other than
2368/// `current_path`, plus the path of the file that contributed it. See
2369/// `find_declaration_cross_file`'s doc for the content-ownership-track
2370/// shape of `files`. Returns `None` if the name is not declared anywhere in
2371/// the project.
2372pub fn describe_symbol_cross_file(
2373    files: &HashMap<PathBuf, String>,
2374    current_path: &Path,
2375    name: &str,
2376) -> Option<(PathBuf, String)> {
2377    for path in sorted_paths(files) {
2378        if path.as_path() == current_path {
2379            continue;
2380        }
2381        let source = &files[path];
2382        if let Some(desc) = describe_symbol(source, name) {
2383            return Some((path.clone(), desc));
2384        }
2385    }
2386    None
2387}
2388
2389pub fn type_ref_str(t: &TypeRef) -> String {
2390    match t {
2391        // v0.20a: function types render in Bynk surface syntax.
2392        TypeRef::Fn(params, ret, _) => {
2393            let lhs = match params.len() {
2394                0 => "()".to_string(),
2395                1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_str(&params[0]),
2396                _ => format!(
2397                    "({})",
2398                    params
2399                        .iter()
2400                        .map(type_ref_str)
2401                        .collect::<Vec<_>>()
2402                        .join(", ")
2403                ),
2404            };
2405            format!("{lhs} -> {}", type_ref_str(ret))
2406        }
2407        TypeRef::Base(b, _) => b.name().to_string(),
2408        TypeRef::Named(id) => id.name.clone(),
2409        TypeRef::Result(a, b, _) => format!("Result[{}, {}]", type_ref_str(a), type_ref_str(b)),
2410        TypeRef::Option(t, _) => format!("Option[{}]", type_ref_str(t)),
2411        TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_str(t)),
2412        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_str(t)),
2413        TypeRef::QueueResult(_) => "QueueResult".to_string(),
2414        TypeRef::Asset(_) => "Asset".to_string(),
2415        TypeRef::RequestHeaders(_) => "RequestHeaders".to_string(),
2416        TypeRef::TokenClaims(_) => "TokenClaims".to_string(),
2417        TypeRef::BoundaryRejection(_) => "BoundaryRejection".to_string(),
2418        // v0.20b: the built-in collection types.
2419        TypeRef::List(t, _) => format!("List[{}]", type_ref_str(t)),
2420        TypeRef::Query(t, _) => format!("Query[{}]", type_ref_str(t)),
2421        TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_str(t)),
2422        TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_str(t)),
2423        TypeRef::History(t, _) => format!("History[{}]", type_ref_str(t)),
2424        TypeRef::Map(k, v, _) => format!("Map[{}, {}]", type_ref_str(k), type_ref_str(v)),
2425        TypeRef::ValidationError(_) => "ValidationError".to_string(),
2426        TypeRef::JsonError(_) => "JsonError".to_string(),
2427        TypeRef::Unit(_) => "()".to_string(),
2428        // v0.157 (ADR 0183): a user generic-type application, as written.
2429        TypeRef::App { name, args, .. } => format!(
2430            "{}[{}]",
2431            name.name,
2432            args.iter().map(type_ref_str).collect::<Vec<_>>().join(", ")
2433        ),
2434    }
2435}
2436
2437#[cfg(test)]
2438mod tests {
2439    use super::*;
2440
2441    /// #2136: the `system` cron/queue hover answers only when the receiver is
2442    /// a `cron` / `queue` service of the suite's target, not a record field
2443    /// named `message`, and not at a lower tier.
2444    #[test]
2445    fn system_job_hover_needs_a_job_service_receiver() {
2446        const CTX: &str = "context cr.jobs\n\ntype Envelope = { message: String }\n\nservice sweeper from cron {\n  on schedule(\"*/5 * * * *\") () -> Effect[Result[(), String]] {\n    Ok(())\n  }\n}\n\nservice deliveries from queue(\"d\") {\n  on message(e: Envelope) -> Effect[QueueResult] {\n    Ack\n  }\n}\n";
2447        let suite = |tier: &str| {
2448            format!(
2449                "suite cr.jobs{tier} {{\n  case \"c\" {{\n    let e = Envelope {{ message: \"hi\" }}\n    let m = e.message\n    let r <- deliveries.message(e)\n    let s <- sweeper.schedule(\"*/5 * * * *\")\n    expect m == \"hi\"\n  }}\n}}\n"
2450            )
2451        };
2452        let at = |src: &str, needle: &str| {
2453            src.find(needle).expect("needle is in the suite") + needle.len() - "message".len()
2454        };
2455        let system = suite(" as system");
2456        let field = at(&system, "e.message");
2457        assert!(describe_system_job_address_at(&system, field, [CTX]).is_none());
2458        let queue = at(&system, "deliveries.message");
2459        let hover =
2460            describe_system_job_address_at(&system, queue, [CTX]).expect("queue address hovers");
2461        assert!(hover.contains("`queue` entry"), "{hover}");
2462        let cron = system.find("sweeper.schedule").expect("cron address") + "sweeper.".len();
2463        let hover =
2464            describe_system_job_address_at(&system, cron, [CTX]).expect("cron address hovers");
2465        assert!(hover.contains("`scheduled` entry"), "{hover}");
2466        let unit = suite("");
2467        let queue = at(&unit, "deliveries.message");
2468        assert!(describe_system_job_address_at(&unit, queue, [CTX]).is_none());
2469    }
2470
2471    /// #2138: `run.id` hovers in a case body, on either half, but not where
2472    /// the case binds its own `run`, nor outside a suite.
2473    #[test]
2474    fn run_id_hovers_only_as_the_test_run() {
2475        let src = "suite rid.orders {\n  case \"a\" {\n    expect run.id == \"local\"\n  }\n  case \"b\" {\n    let run = Wrapper { id: \"x\" }\n    expect run.id == \"x\"\n  }\n}\n";
2476        let first = src.find("run.id").expect("first run.id");
2477        assert!(describe_run_id_at(src, first).is_some());
2478        assert!(describe_run_id_at(src, first + "run.".len()).is_some());
2479        let shadowed = src.rfind("run.id").expect("second run.id");
2480        assert!(describe_run_id_at(src, shadowed).is_none());
2481        let interp = "suite rid.orders as system {\n  case \"a\" {\n    let r <- api.POST(\"/o/:id\", \"o-\\(run.id)\") by Visitor\n    expect r is Ok(true)\n  }\n}\n";
2482        assert!(describe_run_id_at(interp, interp.find("run.id").expect("run.id")).is_some());
2483        let ctx = "context rid.orders\n\nfn f(run: Wrapper) -> String {\n  run.id\n}\n";
2484        assert!(describe_run_id_at(ctx, ctx.find("run.id").expect("run.id")).is_none());
2485    }
2486
2487    /// #2175: the shadowing check follows the checker's scopes — a `let`
2488    /// in a nested block, a lambda parameter, a `match` pattern and an
2489    /// `if … is` binding each shadow `run`; outside their scope it is the
2490    /// test run again.
2491    #[test]
2492    fn run_id_hover_respects_nested_binders() {
2493        let src = "suite rid.orders {\n  case \"a\" {\n    let w = Wrapper { id: \"x\" }\n    let a = if true {\n      let run = w\n      run.id\n    } else { \"\" }\n    let f = (run: Wrapper) => run.id\n    let m = match Some(w) { Some(run) => run.id, None => \"\" }\n    let i = if Some(w) is Some(run) { run.id } else { \"\" }\n    let j = Some(w) is Some(run) && run.id == \"x\"\n    let k = if !(Some(w) is Some(run)) { \"\" } else { run.id }\n    expect run.id == \"local\"\n  }\n}\n";
2494        let starts: Vec<usize> = src.match_indices("run.id").map(|(i, _)| i).collect();
2495        assert_eq!(starts.len(), 7, "{src}");
2496        for &shadowed in &starts[..6] {
2497            assert!(
2498                describe_run_id_at(src, shadowed).is_none(),
2499                "shadowed at {shadowed}: {}",
2500                &src[shadowed.saturating_sub(30)..shadowed + 6]
2501            );
2502        }
2503        assert!(describe_run_id_at(src, starts[6]).is_some(), "the test run");
2504    }
2505
2506    /// #2135: hover on a reserved principal after `by` says what it sends;
2507    /// the same name elsewhere is not one.
2508    #[test]
2509    fn hover_describes_reserved_principals() {
2510        let src = "suite pay.hooks as system {\n\tcase \"c\" {\n\t\tlet r <- webhooks.POST(\"/h\", \"x\") by Forged\n\t\tlet Stale = 1\n\t\texpect r is Rejected(Unauthorized)\n\t}\n}\n";
2511        let forged = describe_reserved_principal_at(src, src.find("Forged").unwrap()).unwrap();
2512        assert!(
2513            forged.contains("by Forged") && forged.contains("does not hold"),
2514            "{forged}"
2515        );
2516        assert!(describe_reserved_principal_at(src, src.find("Stale").unwrap()).is_none());
2517    }
2518
2519    /// #2134: hover on an `env` clause's word and entry names says where the
2520    /// entry applies.
2521    #[test]
2522    fn hover_describes_env_entries() {
2523        let src = "suite shop.pay {\n\tenv {\n\t\tA = \"x\"\n\t}\n\n\tcase \"c\" {\n\t\tenv { A = \"y\"\n\t\t\tB = \"z\" }\n\t\texpect true\n\t}\n}\n";
2524        let at = |needle: &str| src.find(needle).unwrap();
2525        assert_eq!(
2526            describe_env_at(src, at("env")).as_deref(),
2527            Some(ENV_KEYWORD_DOC)
2528        );
2529        let suite_a = describe_env_at(src, at("A = \"x\"")).unwrap();
2530        assert!(suite_a.contains("A = \"x\""), "{suite_a}");
2531        assert!(suite_a.ends_with("(suite)"), "{suite_a}");
2532        let case_a = describe_env_at(src, at("A = \"y\"")).unwrap();
2533        assert!(case_a.ends_with("(case, overrides suite)"), "{case_a}");
2534        let case_b = describe_env_at(src, at("B = ")).unwrap();
2535        assert!(case_b.ends_with("(case)"), "{case_b}");
2536        assert!(describe_env_at(src, at("expect")).is_none());
2537    }
2538
2539    /// S3 (#1963): hover on a first-party extensible enum says a `match` on it
2540    /// needs a wildcard; a closed one, and a stable one, say nothing extra.
2541    #[test]
2542    fn hover_marks_an_extensible_first_party_enum() {
2543        let fetch_error = describe_firstparty_symbol("FetchError").expect("FetchError hovers");
2544        assert!(fetch_error.contains("**Extensible**"), "{fetch_error}");
2545        assert!(
2546            !fetch_error.contains("**Experimental**"),
2547            "nothing ships experimental"
2548        );
2549        assert!(
2550            describe_firstparty_symbol("Method")
2551                .unwrap()
2552                .contains("**Extensible**")
2553        );
2554        let closed = describe_firstparty_symbol("Request").expect("Request hovers");
2555        assert!(
2556            !closed.contains("**Extensible**"),
2557            "a closed first-party type is not marked extensible"
2558        );
2559        assert_eq!(
2560            firstparty_notes("app.user", "FetchError"),
2561            "",
2562            "a user's own enum"
2563        );
2564    }
2565
2566    /// Content-ownership track (#1086) slice 1: `find_declaration_cross_file`/
2567    /// `describe_symbol_cross_file` take a pre-read `(path, content)` map
2568    /// rather than bare paths they used to read from disk themselves, so the
2569    /// tests below build that map directly — a synthetic root plus a
2570    /// `(relative_path, contents)` list, no real filesystem I/O needed at all.
2571    fn project_files(root: &Path, files: &[(&str, &str)]) -> HashMap<PathBuf, String> {
2572        files
2573            .iter()
2574            .map(|(rel, contents)| (root.join(rel), (*contents).to_string()))
2575            .collect()
2576    }
2577
2578    #[test]
2579    fn receiver_segment_survives_a_multibyte_char_before_the_receiver() {
2580        // A multi-byte non-identifier char before the receiver used to make the
2581        // `i + 1` byte offset land mid-codepoint → panic on slice (#715),
2582        // reached from hover on a `recv.member` access inside a string literal.
2583        let text = "\"€p.items"; // member `items` sits after the dot
2584        let member_start = text.rfind("items").unwrap();
2585        let (recv, start) =
2586            receiver_segment_at(text, Span::new(member_start, text.len())).expect("receiver");
2587        assert_eq!(recv, "p");
2588        assert_eq!(&text[start..start + recv.len()], "p");
2589    }
2590
2591    #[test]
2592    fn cross_file_definition_resolves_into_sibling_file() {
2593        let root = PathBuf::from("/synthetic/cross_file_definition");
2594        let files = project_files(
2595            &root,
2596            &[
2597                (
2598                    "a.bynk",
2599                    "commons demo.a\n\ntype Foo = Int where Positive\n",
2600                ),
2601                (
2602                    "b.bynk",
2603                    "commons demo.b\n\nuses demo.a\n\ntype Bar = Int where NonNegative\n",
2604                ),
2605            ],
2606        );
2607        let current = root.join("b.bynk");
2608        let found = find_declaration_cross_file(&files, &current, "Foo")
2609            .expect("Foo should resolve into a.bynk");
2610        let expected = root.join("a.bynk");
2611        assert_eq!(found.path, expected);
2612        assert!(
2613            found.source.contains("type Foo = Int where Positive"),
2614            "source returned does not contain Foo declaration"
2615        );
2616    }
2617
2618    #[test]
2619    fn cross_file_definition_skips_current_file() {
2620        let root = PathBuf::from("/synthetic/cross_file_skip_current");
2621        let files = project_files(
2622            &root,
2623            &[(
2624                "only.bynk",
2625                "commons demo.only\n\ntype Foo = Int where Positive\n",
2626            )],
2627        );
2628        let current = root.join("only.bynk");
2629        // The only file containing Foo is current; cross-file must skip it.
2630        assert!(find_declaration_cross_file(&files, &current, "Foo").is_none());
2631    }
2632
2633    #[test]
2634    fn cross_file_hover_returns_markdown_summary() {
2635        let root = PathBuf::from("/synthetic/cross_file_hover");
2636        let files = project_files(
2637            &root,
2638            &[
2639                (
2640                    "money.bynk",
2641                    "commons demo.money\n\n\
2642                     ---\n\
2643                     Amount in minor units of currency.\n\
2644                     ---\n\
2645                     type Money = Int where NonNegative\n",
2646                ),
2647                (
2648                    "orders.bynk",
2649                    "commons demo.orders\n\nuses demo.money\n\ntype OrderId = Int where Positive\n",
2650                ),
2651            ],
2652        );
2653        let current = root.join("orders.bynk");
2654        let (other_path, desc) = describe_symbol_cross_file(&files, &current, "Money")
2655            .expect("Money should produce hover content");
2656        assert_eq!(other_path, root.join("money.bynk"));
2657        assert!(desc.contains("type Money"));
2658        assert!(
2659            desc.contains("Amount in minor units"),
2660            "hover should include the doc block"
2661        );
2662    }
2663
2664    #[test]
2665    fn cross_file_returns_none_for_unknown_name() {
2666        let root = PathBuf::from("/synthetic/cross_file_none");
2667        let files = project_files(
2668            &root,
2669            &[(
2670                "a.bynk",
2671                "commons demo.a\n\ntype Foo = Int where Positive\n",
2672            )],
2673        );
2674        let current = root.join("a.bynk");
2675        assert!(find_declaration_cross_file(&files, &current, "DoesNotExist").is_none());
2676        assert!(describe_symbol_cross_file(&files, &current, "DoesNotExist").is_none());
2677    }
2678
2679    #[test]
2680    fn first_party_symbols_describe_their_signature_and_doc() {
2681        // Slice 9: stdlib/surface symbols live in the embedded sources, not the
2682        // project — the hover/completion-doc fallback finds them there, signature
2683        // and `---` doc block alike.
2684        let reverse = describe_firstparty_symbol("reverse").expect("`bynk.list.reverse` described");
2685        assert!(
2686            reverse.contains("reverse") && reverse.contains("List"),
2687            "{reverse}"
2688        );
2689        assert!(
2690            reverse.contains("reverse order"),
2691            "doc block surfaced: {reverse}"
2692        );
2693        // The `bynk` adapter surface too (a capability, exercising the adapter path).
2694        let clock = describe_firstparty_symbol("Clock").expect("`bynk`-surface `Clock`");
2695        assert!(
2696            clock.contains("wall-clock"),
2697            "capability doc surfaced: {clock}"
2698        );
2699        // #901: the whole locale surface hovered as nothing before
2700        // `bynk.locale`/`bynk.locale.types` reached the shared source list. A
2701        // type from the leaf, and a fn from the value-level commons.
2702        let locale_tag = describe_firstparty_symbol("LocaleTag")
2703            .expect("`bynk.locale.types.LocaleTag` described");
2704        assert!(
2705            locale_tag.contains("LocaleTag") && locale_tag.contains("Matches"),
2706            "{locale_tag}"
2707        );
2708        let render = describe_firstparty_symbol("render").expect("`bynk.locale.render` described");
2709        assert!(
2710            render.contains("render") && render.contains("LocaleTag"),
2711            "{render}"
2712        );
2713        let with_text =
2714            describe_firstparty_symbol("withText").expect("`bynk.locale.withText` described");
2715        assert!(with_text.contains("withText"), "{with_text}");
2716        // A name in no first-party source yields nothing (the fallback no-ops).
2717        assert!(describe_firstparty_symbol("DoesNotExist").is_none());
2718    }
2719
2720    #[test]
2721    fn unit_reference_spans_finds_uses_and_consumes_targets() {
2722        // Slice 6b: the clickable ranges for document links — `uses`/`consumes`
2723        // unit names, with spans covering the name (resolution is the handler's).
2724        let src = "context app.main\n  uses billing.charge\n  consumes platform.time\n";
2725        let spans = unit_reference_spans(src);
2726        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
2727        assert!(names.contains(&"billing.charge"), "{names:?}");
2728        assert!(names.contains(&"platform.time"), "{names:?}");
2729        // The span covers exactly the unit name (so the link underlines it).
2730        let (_, span) = spans.iter().find(|(n, _)| n == "billing.charge").unwrap();
2731        assert_eq!(&src[span.start..span.end], "billing.charge");
2732    }
2733
2734    #[test]
2735    fn unit_reference_spans_links_the_suite_target() {
2736        // #609: the `suite <target>` header links to the unit under test, and any
2737        // `uses` clauses the fragment brings in link like the other unit kinds.
2738        let src = "suite todos\n  uses billing.charge\n";
2739        let spans = unit_reference_spans(src);
2740        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
2741        assert!(names.contains(&"todos"), "{names:?}");
2742        assert!(names.contains(&"billing.charge"), "{names:?}");
2743        // The span covers exactly the target name (so the link underlines it).
2744        let (_, span) = spans.iter().find(|(n, _)| n == "todos").unwrap();
2745        assert_eq!(&src[span.start..span.end], "todos");
2746    }
2747
2748    #[test]
2749    fn scan_doc_link_candidates_finds_a_bare_shortcut() {
2750        let cands = scan_doc_link_candidates("See [Limiter] for details.");
2751        assert_eq!(cands.len(), 1);
2752        assert_eq!(cands[0].name, "Limiter");
2753        assert_eq!(cands[0].display, "Limiter");
2754        assert_eq!(
2755            &"See [Limiter] for details."[cands[0].span.clone()],
2756            "[Limiter]"
2757        );
2758    }
2759
2760    #[test]
2761    fn scan_doc_link_candidates_finds_a_dotted_shortcut() {
2762        let cands = scan_doc_link_candidates("See [RateView.remaining].");
2763        assert_eq!(cands.len(), 1);
2764        assert_eq!(cands[0].name, "RateView.remaining");
2765    }
2766
2767    #[test]
2768    fn scan_doc_link_candidates_finds_a_code_span_shortcut() {
2769        let text = "The [`RateView.remaining`] field is never negative.";
2770        let cands = scan_doc_link_candidates(text);
2771        assert_eq!(cands.len(), 1);
2772        assert_eq!(cands[0].name, "RateView.remaining");
2773        assert_eq!(cands[0].display, "`RateView.remaining`");
2774        assert_eq!(&text[cands[0].span.clone()], "[`RateView.remaining`]");
2775    }
2776
2777    #[test]
2778    fn scan_doc_link_candidates_finds_a_full_reference() {
2779        let cands = scan_doc_link_candidates("See [the limiter][Limiter] for details.");
2780        assert_eq!(cands.len(), 1);
2781        assert_eq!(cands[0].name, "Limiter");
2782        assert_eq!(cands[0].display, "the limiter");
2783    }
2784
2785    #[test]
2786    fn scan_doc_link_candidates_leaves_an_explicit_url_untouched() {
2787        let cands = scan_doc_link_candidates("See [Limiter](https://example.com).");
2788        assert!(cands.is_empty(), "{cands:?}");
2789    }
2790
2791    #[test]
2792    fn scan_doc_link_candidates_leaves_an_author_defined_reference_untouched() {
2793        let text = "See [foo] for details.\n\n[foo]: https://example.com\n";
2794        let cands = scan_doc_link_candidates(text);
2795        assert!(cands.is_empty(), "{cands:?}");
2796    }
2797
2798    #[test]
2799    fn scan_doc_link_candidates_ignores_non_identifier_bracket_content() {
2800        let cands = scan_doc_link_candidates("See [see note] and [1] below.");
2801        assert!(cands.is_empty(), "{cands:?}");
2802    }
2803
2804    #[test]
2805    fn scan_doc_link_candidates_ignores_fenced_code_block_content() {
2806        let text = "Example:\n```\n[Bracket, Syntax]\n[label]: not-a-real-def\n```\nSee [Limiter].";
2807        let cands = scan_doc_link_candidates(text);
2808        assert_eq!(cands.len(), 1);
2809        assert_eq!(cands[0].name, "Limiter");
2810    }
2811
2812    #[test]
2813    fn scan_doc_link_candidates_does_not_recognise_the_collapsed_reference_form() {
2814        // `[label][]` (CommonMark's "collapsed reference") is deliberately out
2815        // of scope this increment — use the shortcut form `[label]` instead.
2816        let cands = scan_doc_link_candidates("See [Limiter][].");
2817        assert!(cands.is_empty(), "{cands:?}");
2818    }
2819
2820    #[test]
2821    fn doc_link_spans_reports_absolute_offsets_in_an_indented_doc_block() {
2822        let src = "context app.main\n  ---\n  See [Limiter] here.\n  ---\n  service api {}\n";
2823        let spans = doc_link_spans(src);
2824        assert_eq!(spans.len(), 1);
2825        let (name, span) = &spans[0];
2826        assert_eq!(name, "Limiter");
2827        assert_eq!(&src[span.start..span.end], "[Limiter]");
2828    }
2829
2830    /// #1888: a `--|` doc line links too, at its absolute offset.
2831    #[test]
2832    fn doc_link_spans_finds_links_in_doc_lines() {
2833        let src = "--- Wraps a [Foo].\n--- And a [Bar].\ntype Baz = Int\n";
2834        let spans = doc_link_spans(src);
2835        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
2836        assert_eq!(names, vec!["Foo", "Bar"]);
2837        assert_eq!(&src[spans[1].1.start..spans[1].1.end], "[Bar]");
2838    }
2839
2840    #[test]
2841    fn doc_link_spans_finds_links_across_multiple_doc_blocks() {
2842        let src = "---\nSee [Foo].\n---\ntype Foo = Int\n\n---\nSee [Bar].\n---\ntype Bar = Int\n";
2843        let spans = doc_link_spans(src);
2844        let names: Vec<&str> = spans.iter().map(|(n, _)| n.as_str()).collect();
2845        assert_eq!(names, vec!["Foo", "Bar"]);
2846    }
2847
2848    #[test]
2849    fn own_declaration_name_finds_context_and_commons_names() {
2850        // #302: the rename target when a unit's own file moves.
2851        let src = "context app.main\n  uses billing.charge\n";
2852        let (name, span) = own_declaration_name(src).expect("context has its own name");
2853        assert_eq!(name, "app.main");
2854        assert_eq!(&src[span.start..span.end], "app.main");
2855
2856        let src = "commons app.util\n\ntype Foo = Int where Positive\n";
2857        let (name, _) = own_declaration_name(src).expect("commons has its own name");
2858        assert_eq!(name, "app.util");
2859    }
2860
2861    #[test]
2862    fn own_declaration_name_none_for_suites() {
2863        // A suite's `SourceUnit::name()` is its *target*'s name, not its own —
2864        // nothing `uses`/`consumes` a suite by name, so renaming its file
2865        // needs no declaration rewrite.
2866        let src = "suite todos\n";
2867        assert!(own_declaration_name(src).is_none());
2868    }
2869
2870    // v0.123 (slice 2): hover renders the real shape of each declaration —
2871    // record fields, sum variants, the refined `where`, the opaque base.
2872    #[test]
2873    fn describe_type_renders_fields_variants_and_refinements() {
2874        let record = describe_symbol(
2875            "commons demo.m\n\ntype Order = {\n  id: OrderId,\n  total: Money,\n}\n",
2876            "Order",
2877        )
2878        .unwrap();
2879        assert!(record.contains("type Order = {"), "{record}");
2880        assert!(record.contains("id: OrderId"), "{record}");
2881        assert!(record.contains("total: Money"), "{record}");
2882
2883        let sum = describe_symbol(
2884            "commons demo.m\n\ntype Status = enum { Pending, Shipped }\n",
2885            "Status",
2886        )
2887        .unwrap();
2888        assert!(sum.contains("enum {"), "{sum}");
2889        assert!(sum.contains("Pending") && sum.contains("Shipped"), "{sum}");
2890
2891        let refined = describe_symbol(
2892            "commons demo.m\n\ntype Email = String where NonEmpty\n",
2893            "Email",
2894        )
2895        .unwrap();
2896        assert!(
2897            refined.contains("type Email = String where NonEmpty"),
2898            "{refined}"
2899        );
2900
2901        let opaque =
2902            describe_symbol("commons demo.m\n\ntype Token = opaque String\n", "Token").unwrap();
2903        assert!(opaque.contains("type Token = opaque String"), "{opaque}");
2904    }
2905
2906    // v0.123 (slice 2): a function's `requires`/`ensures` contracts render
2907    // beneath its signature.
2908    #[test]
2909    fn describe_fn_renders_contracts() {
2910        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";
2911        let out = describe_symbol(src, "discount").unwrap();
2912        assert!(
2913            out.contains("fn discount(p: Int, pct: Int) -> Int"),
2914            "{out}"
2915        );
2916        assert!(out.contains("requires p_nonneg: p >= 0"), "{out}");
2917        assert!(out.contains("ensures never_negative: result >= 0"), "{out}");
2918    }
2919
2920    // v0.123 (slice 2): a service renders its protocol header and route lines.
2921    #[test]
2922    fn describe_service_renders_routes() {
2923        let src = "context demo.app\n\nservice greeter {\n  on call(name: String) -> Effect[String] {\n    Effect.pure(name)\n  }\n}\n";
2924        let out = describe_symbol(src, "greeter").unwrap();
2925        assert!(out.contains("service greeter {"), "{out}");
2926        assert!(out.contains("on call"), "{out}");
2927    }
2928
2929    // v0.123 (slice 2): an agent renders its store fields and the
2930    // `invariant`/`transition` step invariants.
2931    #[test]
2932    fn describe_agent_renders_store_and_invariants() {
2933        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";
2934        let out = describe_symbol(src, "Counter").unwrap();
2935        assert!(out.contains("agent Counter {"), "{out}");
2936        assert!(out.contains("key id: String"), "{out}");
2937        assert!(out.contains("store count: Cell[Int]"), "{out}");
2938        assert!(out.contains("invariant non_negative: count >= 0"), "{out}");
2939        assert!(
2940            out.contains("transition monotonic: new.count >= old.count"),
2941            "{out}"
2942        );
2943    }
2944
2945    // v0.123 (slice 2, DECISION B): the `Recv.member` detection that feeds
2946    // capability-op call-site hover through `resolve_label`.
2947    #[test]
2948    fn qualified_callee_detects_upper_receiver_only() {
2949        let text = "  let t = Clock.now()";
2950        let now = text.find("now").unwrap();
2951        assert_eq!(
2952            qualified_callee_at(text, Span::new(now, now + 3)).as_deref(),
2953            Some("Clock.now")
2954        );
2955        // A lowercase (value) receiver is not our case — resolve_label can't
2956        // resolve it anyway.
2957        let text2 = "  xs.fold(0)";
2958        let fold = text2.find("fold").unwrap();
2959        assert!(qualified_callee_at(text2, Span::new(fold, fold + 4)).is_none());
2960        // A bare identifier (no receiver) → None.
2961        let text3 = "  total";
2962        assert!(qualified_callee_at(text3, Span::new(2, 7)).is_none());
2963    }
2964
2965    // v0.137.0 (ADR 0161): hover for the `key`/`store` contextual keywords and
2966    // the agent state fields they introduce — on the keyword or on the field
2967    // name alike, with a `store` field's annotations rendered.
2968    #[test]
2969    fn describe_agent_state_covers_key_store_keywords_and_fields() {
2970        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";
2971
2972        // The `key` keyword and the key field name both render `key id: String`
2973        // plus the contextual-keyword doc.
2974        let at_key_kw = src.find("key id").unwrap();
2975        let key_kw = describe_agent_state_at(src, at_key_kw).expect("hover on `key`");
2976        assert!(key_kw.contains("key id: String"), "{key_kw}");
2977        assert!(key_kw.contains("identity field"), "doc line: {key_kw}");
2978        let at_key_name = src.find("id: String").unwrap();
2979        let key_name = describe_agent_state_at(src, at_key_name).expect("hover on the key field");
2980        assert_eq!(key_kw, key_name, "keyword and name hover match");
2981
2982        // The `store` keyword and the store field name both render the field
2983        // signature — kind and annotations — plus the doc.
2984        let at_store_kw = src.find("store items").unwrap();
2985        let store_kw = describe_agent_state_at(src, at_store_kw).expect("hover on `store`");
2986        assert!(
2987            store_kw.contains("store items: Map[String, Int]"),
2988            "{store_kw}"
2989        );
2990        assert!(
2991            store_kw.contains("@indexed(by: id)"),
2992            "annotation: {store_kw}"
2993        );
2994        assert!(
2995            store_kw.contains("@bounded(10000)"),
2996            "annotation: {store_kw}"
2997        );
2998        assert!(
2999            store_kw.contains("persisted agent-state"),
3000            "doc line: {store_kw}"
3001        );
3002        let at_store_name = src.find("items:").unwrap();
3003        let store_name =
3004            describe_agent_state_at(src, at_store_name).expect("hover on the store field");
3005        assert_eq!(store_kw, store_name, "keyword and name hover match");
3006
3007        // Not on a `key`/`store` keyword or state-field name → no hover (the
3008        // agent name, and the store kind, both fall through to other paths).
3009        assert!(describe_agent_state_at(src, src.find("Sessions").unwrap()).is_none());
3010        assert!(describe_agent_state_at(src, src.find("Map").unwrap()).is_none());
3011        // The word `id` inside `by: id` is an annotation argument, not the key
3012        // field's declaration — it must not masquerade as the key field.
3013        assert!(describe_agent_state_at(src, src.find("by: id").unwrap() + 4).is_none());
3014    }
3015
3016    /// #611 (gap A): the reference half of the test above. Hover on a `key`/
3017    /// `store` field *use* inside the agent body — the case that resolved
3018    /// nowhere: state fields are absent from the project index and are not
3019    /// `let`/param locals, so every earlier hover path misses them.
3020    const TODOS: &str = "context demo.todos\n\
3021        \n\
3022        agent Todos {\n\
3023        \x20 key owner: String\n\
3024        \n\
3025        \x20 store items:   Map[String, Int]\n\
3026        \x20 store lastSeq: Cell[Int]\n\
3027        \n\
3028        \x20 invariant nonneg: lastSeq >= 0\n\
3029        \n\
3030        \x20 on call add(n: Int) -> Effect[()] {\n\
3031        \x20   let next = lastSeq + 1\n\
3032        \x20   let _ <- items.put(owner, next)\n\
3033        \x20   lastSeq := next\n\
3034        \x20   Effect.pure(())\n\
3035        \x20 }\n\
3036        }\n";
3037
3038    #[test]
3039    fn describe_agent_state_covers_references_in_handler_bodies() {
3040        let src = TODOS;
3041        let at = |needle: &str| src.find(needle).expect("needle is in the fixture");
3042
3043        // Every reference renders exactly what the declaration renders.
3044        let store_decl = describe_agent_state_at(src, at("store lastSeq")).unwrap();
3045        assert!(
3046            store_decl.contains("store lastSeq: Cell[Int]"),
3047            "{store_decl}"
3048        );
3049        for (what, needle) in [
3050            ("a bare read", "lastSeq + 1"),
3051            ("a `:=` write target", "lastSeq := next"),
3052            ("an invariant subject", "lastSeq >= 0"),
3053        ] {
3054            let hover = describe_agent_state_at(src, at(needle))
3055                .unwrap_or_else(|| panic!("no hover on {what}"));
3056            assert_eq!(hover, store_decl, "{what} hovers as its declaration");
3057        }
3058        // A store op's receiver — the `items` half of `items.put(…)`.
3059        let recv = describe_agent_state_at(src, at("items.put")).expect("hover on the receiver");
3060        assert_eq!(
3061            recv,
3062            describe_agent_state_at(src, at("store items")).unwrap()
3063        );
3064
3065        // The `key` field is referenceable the same way.
3066        let key_decl = describe_agent_state_at(src, at("key owner")).unwrap();
3067        let key_ref = describe_agent_state_at(src, at("owner, next")).expect("hover on `owner`");
3068        assert_eq!(key_ref, key_decl);
3069        assert!(key_ref.contains("key owner: String"), "{key_ref}");
3070
3071        // A name that is not state, and a state-shaped name outside the agent's
3072        // reference scope, both fall through to the other hover paths.
3073        assert!(describe_agent_state_at(src, at("next = lastSeq")).is_none());
3074        assert!(describe_agent_state_at(src, at("Effect.pure")).is_none());
3075    }
3076
3077    /// #611 (gap C): hover on a `store` field's operation renders the registry
3078    /// signature over the field's declared kind.
3079    #[test]
3080    fn describe_store_op_renders_the_operation_signature() {
3081        let src = TODOS;
3082        let at_put = src
3083            .find("put(owner")
3084            .expect("the store op is in the fixture");
3085        let put = describe_store_op_at(src, at_put, &[]).expect("hover on `items.put`");
3086        assert!(put.contains("put(key: K, value: V) -> Effect[()]"), "{put}");
3087        // The field's declared kind grounds `K`/`V`, so it rides along.
3088        assert!(put.contains("store items: Map[String, Int]"), "{put}");
3089
3090        // A local shadowing the receiver makes this an ordinary value method,
3091        // not a store op — mirroring the checker's by-provenance dispatch.
3092        let shadow = [bynk_check::locals::LocalBinding {
3093            name: "items".into(),
3094            def_span: Span::new(0, 5),
3095            kind: bynk_check::locals::LocalKind::Let,
3096            ty: "Map[String, Int]".into(),
3097            scope: Span::new(0, src.len()),
3098        }];
3099        assert!(describe_store_op_at(src, at_put, &shadow).is_none());
3100
3101        // An operation the kind does not have, a receiver that is not a store
3102        // field, and a non-member identifier all fall through.
3103        let cell_put = src.replace("items.put(owner, next)", "lastSeq.put(owner, next)");
3104        assert!(
3105            describe_store_op_at(&cell_put, cell_put.find("put(owner").unwrap(), &[]).is_none(),
3106            "a `Cell` has no `put`"
3107        );
3108        assert!(describe_store_op_at(src, src.find("pure(()").unwrap(), &[]).is_none());
3109        assert!(describe_store_op_at(src, src.find("next = lastSeq").unwrap(), &[]).is_none());
3110    }
3111
3112    /// A store op binds a **bare** ident receiver. A *qualified* receiver
3113    /// (`p.items.put(…)`) is an ordinary value method on a record field that
3114    /// merely shares a store field's name — the same class of confidently-wrong
3115    /// hover as gap B, and invisible to the index (which does not cover value
3116    /// methods), so nothing upstream would catch it.
3117    #[test]
3118    fn a_qualified_receiver_is_not_a_store_op_or_a_state_reference() {
3119        let src = "context demo.todos\n\
3120            \n\
3121            type Inner = { put: Int }\n\
3122            type Payload = { items: Inner, lastSeq: Int }\n\
3123            \n\
3124            agent Todos {\n\
3125            \x20 key owner: String\n\
3126            \x20 store items:   Map[String, Int]\n\
3127            \x20 store lastSeq: Cell[Int]\n\
3128            \n\
3129            \x20 on call add(p: Payload) -> Effect[()] {\n\
3130            \x20   let a = p.items.put\n\
3131            \x20   let b = p.lastSeq\n\
3132            \x20   Effect.pure(())\n\
3133            \x20 }\n\
3134            }\n";
3135        // `p.items.put` — the `put` is a field of `Inner`, not the store op.
3136        let at_put = src.find("put\n").expect("the qualified member");
3137        assert!(describe_store_op_at(src, at_put, &[]).is_none());
3138        // …and the `items` in `p.items` is a field of `Payload`, not the store
3139        // field — the same root cause, in the state-reference pass.
3140        let at_items = src.find("p.items").unwrap() + "p.".len();
3141        assert!(describe_agent_state_at(src, at_items).is_none());
3142        let at_seq = src.find("p.lastSeq").unwrap() + "p.".len();
3143        assert!(describe_agent_state_at(src, at_seq).is_none());
3144
3145        // The bare forms in the same body still resolve.
3146        let bare = src.find("store items").unwrap();
3147        assert!(describe_agent_state_at(src, bare).is_some());
3148    }
3149
3150    /// #611 (gap B): the index resolves a record-construction field label / field
3151    /// access to a `Field` key (`"Stored.title"`); hover must render it rather
3152    /// than fall through to the locals path, which name-matches in scope and
3153    /// bound `title:` to a same-named handler param.
3154    #[test]
3155    fn describe_symbol_renders_a_resolved_record_field() {
3156        let src = "context demo.todos\n\n\
3157            type Title = String where NonEmpty\n\n\
3158            type Stored = {\n\
3159            \x20 seq:   Int where NonNegative,\n\
3160            \x20 title: Title,\n\
3161            }\n";
3162        let title = describe_symbol(src, "Stored.title").expect("hover on `Stored.title`");
3163        assert!(title.contains("title: Title"), "{title}");
3164        assert!(title.contains("A field of `Stored`"), "{title}");
3165        // A field refinement rides along, as it does in the record body.
3166        let seq = describe_symbol(src, "Stored.seq").expect("hover on `Stored.seq`");
3167        assert!(seq.contains("seq: Int where NonNegative"), "{seq}");
3168
3169        // The bare type name still renders the type, not a field.
3170        assert!(
3171            describe_symbol(src, "Stored")
3172                .unwrap()
3173                .contains("type Stored")
3174        );
3175        // An unknown field, an unknown owner, and a non-record owner yield none.
3176        assert!(describe_symbol(src, "Stored.nope").is_none());
3177        assert!(describe_symbol(src, "Nope.title").is_none());
3178        assert!(describe_symbol(src, "Title.title").is_none());
3179    }
3180
3181    // #972 (Events slice 3a): an event's own hover and its per-field hover
3182    // both render a declared default, mirroring an ordinary record's hover
3183    // (above) — this is the arm that was entirely missing before this slice
3184    // (no `CommonsItem::Event` case at all), not just a formatting gap.
3185    #[test]
3186    fn describe_symbol_renders_an_event_and_its_defaulted_field() {
3187        let src = "context demo.orders\n\n\
3188            type Region = enum { Domestic, International }\n\n\
3189            event PaymentConfirmed = {\n\
3190            \x20 orderId: String,\n\
3191            \x20 region: Region = Region.Domestic,\n\
3192            }\n";
3193        let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
3194        assert!(whole.contains("type PaymentConfirmed = {"), "{whole}");
3195        assert!(whole.contains("orderId: String"), "{whole}");
3196        assert!(
3197            whole.contains("region: Region = Region.Domestic"),
3198            "{whole}"
3199        );
3200
3201        let field = describe_symbol(src, "PaymentConfirmed.region")
3202            .expect("hover on `PaymentConfirmed.region`");
3203        assert!(
3204            field.contains("region: Region = Region.Domestic"),
3205            "{field}"
3206        );
3207        assert!(field.contains("A field of `PaymentConfirmed`"), "{field}");
3208
3209        // The non-defaulted field renders with no trailing `= ...`.
3210        let order_id = describe_symbol(src, "PaymentConfirmed.orderId")
3211            .expect("hover on `PaymentConfirmed.orderId`");
3212        assert!(order_id.contains("orderId: String"), "{order_id}");
3213        assert!(!order_id.contains('='), "{order_id}");
3214
3215        assert!(describe_symbol(src, "PaymentConfirmed.nope").is_none());
3216        assert!(describe_symbol(src, "Nope.region").is_none());
3217    }
3218
3219    // Events slice 3b (#978): a declared `@schema(N)` renders in the
3220    // event's own hover, additively — an event with none (the test above)
3221    // stays byte-identical.
3222    #[test]
3223    fn describe_symbol_renders_an_events_schema_annotation() {
3224        let src = "context demo.orders\n\n\
3225            event PaymentConfirmed @schema(2) = {\n\
3226            \x20 orderId: String,\n\
3227            }\n";
3228        let whole = describe_symbol(src, "PaymentConfirmed").expect("hover on the event itself");
3229        assert!(
3230            whole.contains("type PaymentConfirmed @schema(2) = {"),
3231            "{whole}"
3232        );
3233        assert!(whole.contains("orderId: String"), "{whole}");
3234    }
3235
3236    /// v0.166 (#616): the actor arm — both declaration forms. The reference-offset
3237    /// fixture in `hover_references.rs` covers the `Bearer` form against real
3238    /// analysis output; the schemes without config and ADR 0091's refinement form
3239    /// are declared by no example project, so they are pinned here.
3240    #[test]
3241    fn describe_symbol_renders_an_actor_in_both_forms() {
3242        let src = "context demo.auth\n\n\
3243            type UserId = String where NonEmpty\n\n\
3244            ---\n\
3245            A signed-in user.\n\
3246            ---\n\
3247            actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }\n\n\
3248            actor Public { auth = None }\n\n\
3249            actor Worker { auth = Internal }\n\n\
3250            actor Admin = User where hasClaim(\"admin\")\n";
3251
3252        let user = describe_symbol(src, "User").expect("hover on `User`");
3253        assert!(
3254            user.contains(
3255                "actor User { auth = Bearer(secret = \"AUTH_JWT_SECRET\"), identity = UserId }"
3256            ),
3257            "{user}"
3258        );
3259        // The doc block rides along, as it does for every other declaration.
3260        assert!(user.contains("A signed-in user."), "{user}");
3261
3262        // A scheme with no config and no identity renders neither.
3263        let public = describe_symbol(src, "Public").expect("hover on `Public`");
3264        assert!(
3265            public.contains("actor Public { auth = None }") && !public.contains("identity"),
3266            "{public}"
3267        );
3268        assert!(
3269            describe_symbol(src, "Worker")
3270                .unwrap()
3271                .contains("actor Worker { auth = Internal }")
3272        );
3273
3274        // ADR 0091's refinement form renders its base and claim predicate.
3275        let admin = describe_symbol(src, "Admin").expect("hover on `Admin`");
3276        assert!(
3277            admin.contains("actor Admin = User where hasClaim(\"admin\")"),
3278            "{admin}"
3279        );
3280
3281        assert!(describe_symbol(src, "Nobody").is_none());
3282    }
3283
3284    /// v0.166 (#616, review): the actor arm claims to mirror `bynk-fmt`'s
3285    /// `format_actor`, so it must escape a scheme-config string the same way.
3286    /// `SchemeArgValue::Str` holds the value *unescaped* — the parser resolves
3287    /// `\"`/`\\`/`\n`/`\t` at lex time — so rendering it raw put invalid Bynk
3288    /// inside a ```bynk fence. Pinned against the formatter's own output rather
3289    /// than a hand-written expectation: a copy would agree only until one moved.
3290    #[test]
3291    fn describe_symbol_escapes_an_actors_scheme_config() {
3292        let src = "context demo.auth\n\n\
3293            actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }\n";
3294        let hover = describe_symbol(src, "User").expect("hover on `User`");
3295        assert!(
3296            hover.contains("actor User { auth = Bearer(secret = \"a\\\"b\\\\c\") }"),
3297            "the config value must round-trip escaped:\n{hover}"
3298        );
3299
3300        // The fenced declaration is exactly what the formatter emits for it.
3301        let formatted = bynk_fmt::format_source(src, &bynk_fmt::FormatOptions::default())
3302            .expect("the fixture formats");
3303        let actor_line = formatted
3304            .lines()
3305            .find(|l| l.starts_with("actor User"))
3306            .expect("the actor line");
3307        assert!(
3308            hover.contains(actor_line),
3309            "hover:\n{hover}\nfmt: {actor_line}"
3310        );
3311    }
3312
3313    /// v0.166 (#616): the capability-op arm, keyed `"Cap.op"` (ADR 0069) —
3314    /// attributed to its owner, as a field is to its record.
3315    #[test]
3316    fn describe_symbol_renders_a_capability_operation() {
3317        let src = "context demo.svc\n\n\
3318            capability Logger {\n\
3319            \x20 ---\n\
3320            \x20 Record a line.\n\
3321            \x20 ---\n\
3322            \x20 fn info(message: String) -> Effect[()]\n\
3323            }\n\n\
3324            capability Clock {\n\
3325            \x20 fn now() -> Effect[Int]\n\
3326            }\n";
3327
3328        let info = describe_symbol(src, "Logger.info").expect("hover on `Logger.info`");
3329        assert!(
3330            info.contains("fn info(message: String) -> Effect[()]"),
3331            "{info}"
3332        );
3333        assert!(
3334            info.contains("An operation of capability `Logger`"),
3335            "{info}"
3336        );
3337        assert!(info.contains("Record a line."), "{info}");
3338
3339        // A no-arg op on another capability — the owner is what disambiguates.
3340        let now = describe_symbol(src, "Clock.now").expect("hover on `Clock.now`");
3341        assert!(now.contains("fn now() -> Effect[Int]"), "{now}");
3342
3343        // The bare capability name still renders the capability itself.
3344        assert!(
3345            describe_symbol(src, "Logger")
3346                .unwrap()
3347                .contains("capability Logger")
3348        );
3349        // An unknown op, and an op read against the wrong owner, yield none.
3350        assert!(describe_symbol(src, "Logger.nope").is_none());
3351        assert!(describe_symbol(src, "Clock.info").is_none());
3352    }
3353
3354    #[test]
3355    fn describe_symbol_renders_a_messages_bundle() {
3356        // message-bundles slice 1 (#859): describe_item's Messages arm — a
3357        // silent hover gap before this slice (the wildcard fell through to
3358        // "no hover" for a construct that didn't exist yet).
3359        let src = "commons app.bundle\n\n\
3360            ---\n\
3361            The English reference bundle.\n\
3362            ---\n\
3363            messages \"en\" @reference {\n\
3364            \x20 \"greeting\" => \"Hello, {name}!\"\n\
3365            \x20 \"farewell\" => \"Bye\"\n\
3366            }\n";
3367
3368        let info = describe_symbol(src, "en").expect("hover on the `en` messages tag");
3369        assert!(info.contains("messages \"en\" @reference {"), "{info}");
3370        assert!(info.contains("\"greeting\" => …"), "{info}");
3371        assert!(info.contains("\"farewell\" => …"), "{info}");
3372        assert!(info.contains("The English reference bundle."), "{info}");
3373    }
3374
3375    /// v0.166 (#616, ADR 0191 D2): a bare key names a *free* function. Matching a
3376    /// method on its bare name answered with whichever type declared it first —
3377    /// `Gauge.bump`'s own declaration rendered `Counter.bump` — and silently
3378    /// outranked the index's real answer.
3379    #[test]
3380    fn describe_symbol_keys_methods_by_their_compound_name_only() {
3381        let src = "context demo.shop\n\n\
3382            type Counter = { count: Int }\n\
3383            type Gauge = { level: Int }\n\n\
3384            fn Counter.bump(self) -> Counter { Counter { count: self.count + 1 } }\n\n\
3385            fn Gauge.bump(self) -> Gauge { Gauge { level: self.level + 1 } }\n\n\
3386            fn free(n: Int) -> Int { n }\n";
3387
3388        // The type prefix is the identity: each compound key renders its own.
3389        let counter = describe_symbol(src, "Counter.bump").expect("hover on `Counter.bump`");
3390        assert!(
3391            counter.contains("fn Counter.bump(self) -> Counter") && !counter.contains("Gauge"),
3392            "{counter}"
3393        );
3394        let gauge = describe_symbol(src, "Gauge.bump").expect("hover on `Gauge.bump`");
3395        assert!(
3396            gauge.contains("fn Gauge.bump(self) -> Gauge") && !gauge.contains("Counter"),
3397            "{gauge}"
3398        );
3399
3400        // A bare `bump` names no method: it is a guess between the two, and the
3401        // index's compound key is what resolves them.
3402        assert!(describe_symbol(src, "bump").is_none());
3403        // A free function is still keyed by its bare name.
3404        assert!(
3405            describe_symbol(src, "free")
3406                .unwrap()
3407                .contains("fn free(n: Int) -> Int")
3408        );
3409        assert!(describe_symbol(src, "Counter.nope").is_none());
3410    }
3411
3412    // v0.122 (slice 1): `self` hover renders its receiver/agent type, reading
3413    // the type from `expr_types` and un-synthesising the agent-self record.
3414    #[test]
3415    fn describe_self_renders_receiver_and_unwraps_agent() {
3416        use bynk_check::checker::{NamedKind, Ty, Types};
3417        let text = "self";
3418        let tys = &Types::new();
3419        let span = Span::new(0, 4);
3420        // A method receiver — a plain named type renders verbatim.
3421        let account = vec![(
3422            span,
3423            tys.intern(Ty::Named {
3424                name: "Account".into(),
3425                kind: NamedKind::Record,
3426                args: Vec::new(),
3427            }),
3428        )];
3429        assert_eq!(
3430            describe_self_at(text, 0, &account, tys).as_deref(),
3431            Some("```bynk\nself: Account\n```")
3432        );
3433        // An agent handler — the synthetic `__CounterSelf` record un-synthesises
3434        // to the agent name.
3435        let agent = vec![(
3436            span,
3437            tys.intern(Ty::Named {
3438                name: "__CounterSelf".into(),
3439                kind: NamedKind::Record,
3440                args: Vec::new(),
3441            }),
3442        )];
3443        assert_eq!(
3444            describe_self_at(text, 0, &agent, tys).as_deref(),
3445            Some("```bynk\nself: Counter\n```")
3446        );
3447        // Not on the `self` keyword — a different token yields nothing, even
3448        // when a type sits at the offset.
3449        let other = "total";
3450        assert!(
3451            describe_self_at(
3452                other,
3453                0,
3454                &[(
3455                    Span::new(0, 5),
3456                    tys.intern(Ty::Named {
3457                        name: "Int".into(),
3458                        kind: NamedKind::Record,
3459                        args: Vec::new(),
3460                    }),
3461                )],
3462                tys
3463            )
3464            .is_none()
3465        );
3466    }
3467
3468    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";
3469
3470    #[test]
3471    fn an_edge_cache_hovers_with_its_stanza() {
3472        // L7 (#2414): what declaring the instance switches on.
3473        let src =
3474            "context a\nconsumes bynk.cloudflare { EdgeCache }\ncapability Edge = EdgeCache\n";
3475        let tokens = tokenize(src).unwrap();
3476        let (unit, _) = parse_unit_with_recovery(&tokens, src);
3477        let Some(SourceUnit::Context(ctx)) = unit else {
3478            panic!("a context")
3479        };
3480        let decl = ctx
3481            .items
3482            .iter()
3483            .find_map(|i| match i {
3484                CommonsItem::Capability(c) => Some(c),
3485                _ => None,
3486            })
3487            .unwrap();
3488        let hover = describe_derived_capability(decl, Some("bynk.cloudflare"), None);
3489        assert!(hover.contains("Emits `[cache] enabled = true`"), "{hover}");
3490        assert!(!hover.contains("its own storage"), "{hover}");
3491    }
3492
3493    #[test]
3494    fn a_rate_limiter_hovers_with_its_stanza() {
3495        // P3 (#2342): the emitted limit and window, and the counting caveat.
3496        let src = "context a\nconsumes bynk.cloudflare { RateLimit }\ncapability LoginLimit = RateLimit @limit(20) @period(1.minutes)\n";
3497        let tokens = tokenize(src).unwrap();
3498        let (unit, _) = parse_unit_with_recovery(&tokens, src);
3499        let Some(SourceUnit::Context(ctx)) = unit else {
3500            panic!("a context")
3501        };
3502        let decl = ctx
3503            .items
3504            .iter()
3505            .find_map(|i| match i {
3506                CommonsItem::Capability(c) => Some(c),
3507                _ => None,
3508            })
3509            .unwrap();
3510        let hover = describe_derived_capability(decl, Some("bynk.cloudflare"), None);
3511        assert!(
3512            hover.contains(
3513                "`simple = { limit = 20, period = 60 }`: 20 calls per key every 60 seconds"
3514            ),
3515            "{hover}"
3516        );
3517        assert!(hover.contains("approximate"), "{hover}");
3518    }
3519
3520    #[test]
3521    fn hover_on_cache_annotation_describes_it() {
3522        // Offset on the `cache` name token.
3523        let offset = CACHE_SVC.find("cache").unwrap() + 1;
3524        let hover = describe_handler_annotation_at(CACHE_SVC, offset).expect("hovers @cache");
3525        assert!(hover.contains("`@cache`"), "names the annotation: {hover}");
3526        assert!(hover.contains("maxAge"), "documents maxAge: {hover}");
3527        assert!(hover.contains("scope"), "documents scope: {hover}");
3528        assert!(hover.contains("immutable"), "documents immutable: {hover}");
3529        assert!(
3530            hover.contains("Emits `Cache-Control: public, max-age=300`."),
3531            "shows the emitted header: {hover}"
3532        );
3533        // Off the annotation (on the `Ok` body) — no annotation hover.
3534        let ok_offset = CACHE_SVC.find("Ok(").unwrap() + 1;
3535        assert!(describe_handler_annotation_at(CACHE_SVC, ok_offset).is_none());
3536    }
3537
3538    #[test]
3539    fn hover_on_immutable_cache_shows_the_directive() {
3540        let svc = "context api\nservice api from http {\n  @cache(maxAge: 365.days, scope: public, immutable: true)\n  on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor {\n    Ok(\"y\")\n  }\n}\n";
3541        let hover = describe_handler_annotation_at(svc, svc.find("cache").unwrap() + 1)
3542            .expect("hovers @cache");
3543        assert!(
3544            hover.contains("Emits `Cache-Control: public, max-age=31536000, immutable`."),
3545            "{hover}"
3546        );
3547    }
3548
3549    #[test]
3550    fn hover_on_raw_annotation_shows_content_type_and_effective_cap() {
3551        let svc = "context api\nservice api from http {\n  limits { maxBody: 1000 }\n  @raw(contentType: \"image/png\")\n  on POST(\"/a\") (body: Bytes) -> Effect[HttpResult[String]] by v: Visitor {\n    Ok(\"y\")\n  }\n  @raw(contentType: \"*/*\")\n  @limit(maxBody: 50)\n  on POST(\"/b\") (body: Bytes) -> Effect[HttpResult[String]] by v: Visitor {\n    Ok(\"y\")\n  }\n}\n";
3552        let first =
3553            describe_handler_annotation_at(svc, svc.find("raw").unwrap() + 1).expect("hovers @raw");
3554        assert!(first.contains("`image/png`"), "names the type: {first}");
3555        assert!(first.contains("1000 bytes"), "service cap: {first}");
3556        let second = describe_handler_annotation_at(svc, svc.rfind("raw").unwrap() + 1)
3557            .expect("hovers @raw");
3558        assert!(second.contains("any"), "wildcard: {second}");
3559        assert!(second.contains("50 bytes"), "the route's own cap: {second}");
3560    }
3561
3562    #[test]
3563    fn annotation_token_spans_cover_name_and_labels() {
3564        let spans = handler_annotation_token_spans(CACHE_SVC);
3565        // `@cache` + the two argument labels `maxAge`/`scope`.
3566        assert_eq!(spans.len(), 3, "{spans:?}");
3567        let texts: Vec<&str> = spans.iter().map(|s| &CACHE_SVC[s.start..s.end]).collect();
3568        assert_eq!(texts, ["@cache", "maxAge", "scope"]);
3569        // A service with no annotations yields nothing.
3570        let plain = "context api\nservice api from http {\n  on GET(\"/x\") () -> Effect[HttpResult[String]] by v: Visitor { Ok(\"y\") }\n}\n";
3571        assert!(handler_annotation_token_spans(plain).is_empty());
3572    }
3573}