Skip to main content

bynk_lsp/
hover.rs

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