Skip to main content

bynk_lsp/
index_queries.rs

1//! v0.25 (ADR 0053): pure queries over the project binding index.
2//!
3//! Everything here is a pure function over [`ProjectIndex`] + analysed
4//! snapshot texts — the unit-testable core behind `textDocument/references`
5//! and `rename`/`prepareRename`. Transport-side handlers in `main.rs` only
6//! convert positions and package results.
7//!
8//! Rename is validated two ways, both correct-by-construction:
9//! 1. **Collisions** — apply the edits to an overlay, re-run
10//!    `diagnose_project`, refuse if a new diagnostic appears
11//!    ([`no_new_diagnostics`]).
12//! 2. **Capture/escape** — re-analysis alone misses silent re-binding
13//!    (declared fns shadow fn-typed locals in call position), so the
14//!    re-built index must equal the pre-index *modulo the rename*
15//!    ([`index_unchanged_modulo_rename`]).
16
17use std::collections::{BTreeMap, HashMap};
18use std::path::{Path, PathBuf};
19
20use bynk_check::checker::{Ty, TyId, Types};
21use bynk_check::index::{ProjectIndex, SiteRef, SymbolKey, SymbolKind};
22use bynk_syntax::ast::BaseType;
23use bynk_syntax::span::Span;
24
25/// Definition first, then references — the `references` surface.
26pub fn sites_for<'a>(
27    index: &'a ProjectIndex,
28    path: &Path,
29    offset: usize,
30    include_declaration: bool,
31) -> Option<Vec<&'a SiteRef>> {
32    let (key, _) = index.symbol_at(path, offset)?;
33    let mut sites = index.sites(key);
34    if !include_declaration && !sites.is_empty() {
35        sites.remove(0); // definition is always first.
36    }
37    Some(sites)
38}
39
40/// The definition site for the symbol at the cursor (an index-backed,
41/// binding-correct go-to-definition).
42pub fn definition_at<'a>(
43    index: &'a ProjectIndex,
44    path: &Path,
45    offset: usize,
46) -> Option<(&'a SymbolKey, &'a SiteRef)> {
47    let (key, _) = index.symbol_at(path, offset)?;
48    let entry = index.symbols.get(key)?;
49    Some((key, entry.def.as_ref()?))
50}
51
52/// `prepareRename`: the renameable range under the cursor, or `None` for
53/// out-of-scope symbols (locals, unit names) —
54/// the request is refused rather than falling through to a partial rename.
55pub fn prepare_rename<'a>(
56    index: &'a ProjectIndex,
57    path: &Path,
58    offset: usize,
59) -> Option<(&'a SymbolKey, &'a SiteRef)> {
60    index.symbol_at(path, offset)
61}
62
63/// v0.26 rider (ADR 0055): `workspace/symbol` — every index definition whose
64/// name contains the query, case-insensitive (an empty query lists all),
65/// sorted by (name, unit) for a stable order.
66pub fn workspace_symbols<'a>(
67    index: &'a ProjectIndex,
68    query: &str,
69) -> Vec<(&'a SymbolKey, &'a SiteRef)> {
70    let q = query.to_lowercase();
71    let mut out: Vec<_> = index
72        .symbols
73        .iter()
74        .filter(|(k, _)| q.is_empty() || k.name.to_lowercase().contains(&q))
75        .filter_map(|(k, e)| e.def.as_ref().map(|d| (k, d)))
76        .collect();
77    out.sort_by(|a, b| (&a.0.name, &a.0.unit).cmp(&(&b.0.name, &b.0.unit)));
78    out
79}
80
81/// v0.33 (ADR 0066): `codeLens` — one reference-count lens per top-level
82/// definition in `path`, as `(def site, reference sites)`. The count is
83/// `refs.len()`; the reference sites feed the `showReferences` action. Sorted
84/// by definition position (a stable, top-to-bottom lens order).
85pub fn code_lenses<'a>(index: &'a ProjectIndex, path: &Path) -> Vec<(&'a SiteRef, &'a [SiteRef])> {
86    let mut out: Vec<(&SiteRef, &[SiteRef])> = index
87        .symbols
88        .values()
89        .filter_map(|e| {
90            let def = e.def.as_ref()?;
91            (def.path == path).then_some((def, e.refs.as_slice()))
92        })
93        .collect();
94    out.sort_by_key(|(def, _)| (def.span.start, def.span.end));
95    out
96}
97
98/// v0.127 (editor-currency slice 6): a provider-count lens per `capability`
99/// definition in `path`, as `(capability def, provider def sites)`. The count is
100/// `providers.len()`; the provider sites feed the same `showReferences` peek the
101/// reference lens uses. Reuses the `implementations` relation (the `impls_of`
102/// edge behind go-to-implementation), so a capability with no provider yields no
103/// lens. Sorted by definition position, alongside the reference lenses.
104pub fn capability_provider_lenses<'a>(
105    index: &'a ProjectIndex,
106    path: &Path,
107) -> Vec<(&'a SiteRef, Vec<&'a SiteRef>)> {
108    let mut out: Vec<(&SiteRef, Vec<&SiteRef>)> = index
109        .symbols
110        .iter()
111        .filter(|(key, _)| key.kind == SymbolKind::Capability)
112        .filter_map(|(key, entry)| {
113            let def = entry.def.as_ref()?;
114            if def.path != path {
115                return None;
116            }
117            let providers = implementations(index, key);
118            (!providers.is_empty()).then_some((def, providers))
119        })
120        .collect();
121    out.sort_by_key(|(def, _)| (def.span.start, def.span.end));
122    out
123}
124
125/// v0.129 (#259): a refinement-family lens per refined/opaque `Type` definition
126/// in `path`, as `(type def, base, family def sites)` — every refined/opaque type
127/// (and plain alias) declared over the same builtin `base`, across the project.
128/// The family includes the type itself, so a lens is emitted only for a family of
129/// **≥ 2** (a lone refinement has nothing to navigate to — no lens, mirroring
130/// `capability_provider_lenses`). Sorted by definition position, alongside the
131/// reference lenses.
132pub fn refinement_family_lenses<'a>(
133    index: &'a ProjectIndex,
134    path: &Path,
135) -> Vec<(&'a SiteRef, BaseType, Vec<&'a SiteRef>)> {
136    let mut out: Vec<(&SiteRef, BaseType, Vec<&SiteRef>)> = index
137        .symbols
138        .iter()
139        .filter(|(key, _)| key.kind == SymbolKind::Type)
140        .filter_map(|(key, entry)| {
141            let def = entry.def.as_ref()?;
142            if def.path != path {
143                return None;
144            }
145            let base = index.refined_base(key)?;
146            let mut family: Vec<&SiteRef> = index
147                .refinements_over(base)
148                .filter_map(|e| index.symbols.get(&e.ty)?.def.as_ref())
149                .collect();
150            family.sort_by_key(|d| (d.path.clone(), d.span.start, d.span.end));
151            family.dedup();
152            (family.len() >= 2).then_some((def, base, family))
153        })
154        .collect();
155    out.sort_by_key(|(def, _, _)| (def.span.start, def.span.end));
156    out
157}
158
159/// v0.34 (ADR 0067): one end of a call-hierarchy relation — the related
160/// symbol (`key` + its definition site) and the call sites linking it to the
161/// queried symbol. For incoming calls `key` is a caller and `sites` are where
162/// it calls the queried symbol; for outgoing, `key` is a callee and `sites`
163/// are where the queried symbol calls it. The sites double as the LSP
164/// `fromRanges` (identical for both directions).
165pub struct CallRelation<'a> {
166    pub key: &'a SymbolKey,
167    pub def: &'a SiteRef,
168    pub sites: Vec<&'a SiteRef>,
169}
170
171/// v0.34 (ADR 0067): `prepareCallHierarchy` — the symbol under the cursor and
172/// its definition site (the goto-def resolution; an item is anchored on the
173/// definition). `None` for out-of-scope positions.
174pub fn prepare_call_hierarchy<'a>(
175    index: &'a ProjectIndex,
176    path: &Path,
177    offset: usize,
178) -> Option<(&'a SymbolKey, &'a SiteRef)> {
179    definition_at(index, path, offset)
180}
181
182/// Group `edges` by the key returned by `pick`, attach each grouped symbol's
183/// definition, and collect the call sites — the shared core of incoming and
184/// outgoing calls. Groups with no indexed definition are dropped (defensive;
185/// every call-edge endpoint is an index symbol by construction). Groups are
186/// ordered by definition position for a stable, top-to-bottom listing.
187fn group_calls<'a>(
188    index: &'a ProjectIndex,
189    edges: impl Iterator<Item = &'a bynk_check::index::CallEdge>,
190    pick: impl Fn(&'a bynk_check::index::CallEdge) -> &'a SymbolKey,
191) -> Vec<CallRelation<'a>> {
192    let mut by_key: BTreeMap<&SymbolKey, Vec<&SiteRef>> = BTreeMap::new();
193    for edge in edges {
194        by_key.entry(pick(edge)).or_default().push(&edge.site);
195    }
196    let mut out: Vec<CallRelation<'a>> = by_key
197        .into_iter()
198        .filter_map(|(key, mut sites)| {
199            let def = index.symbols.get(key)?.def.as_ref()?;
200            sites.sort();
201            Some(CallRelation { key, def, sites })
202        })
203        .collect();
204    out.sort_by_key(|r| (r.def.path.clone(), r.def.span.start, r.def.span.end));
205    out
206}
207
208/// v0.34 (ADR 0067): `callHierarchy/incomingCalls` — the callers of `key`,
209/// each with the call sites at which it calls `key`.
210pub fn incoming_calls<'a>(index: &'a ProjectIndex, key: &SymbolKey) -> Vec<CallRelation<'a>> {
211    group_calls(index, index.calls_into(key), |e| &e.caller)
212}
213
214/// v0.34 (ADR 0067): `callHierarchy/outgoingCalls` — what `key` calls, each
215/// with the call sites within `key` at which the callee is called.
216pub fn outgoing_calls<'a>(index: &'a ProjectIndex, key: &SymbolKey) -> Vec<CallRelation<'a>> {
217    group_calls(index, index.calls_from(key), |e| &e.callee)
218}
219
220/// v0.35 (ADR 0068): `textDocument/implementation` — the definition sites of
221/// every provider implementing the capability `key`, sorted by definition
222/// position. Empty for a non-capability or unknown key (the request then falls
223/// through; goto-def still serves the reverse, provider → capability).
224pub fn implementations<'a>(index: &'a ProjectIndex, key: &SymbolKey) -> Vec<&'a SiteRef> {
225    let mut defs: Vec<&SiteRef> = index
226        .impls_of(key)
227        .filter_map(|e| index.symbols.get(&e.provider)?.def.as_ref())
228        .collect();
229    defs.sort_by_key(|d| (d.path.clone(), d.span.start, d.span.end));
230    defs.dedup();
231    defs
232}
233
234/// Slice 6: `textDocument/typeDefinition` — the definition site(s) of the type
235/// named `name` (a `Type` symbol). The checker's `Ty::Named.name` and the index
236/// both use bare names, so this is a bare-name match; a name shared across units
237/// yields several locations (the LSP-conventional resolution — the client lets
238/// the user choose). Sorted by definition position.
239pub fn type_definitions_named<'a>(index: &'a ProjectIndex, name: &str) -> Vec<&'a SiteRef> {
240    let mut defs: Vec<&SiteRef> = index
241        .symbols
242        .iter()
243        .filter(|(k, _)| k.kind == SymbolKind::Type && k.name == name)
244        .filter_map(|(_, e)| e.def.as_ref())
245        .collect();
246    defs.sort_by_key(|d| (d.path.clone(), d.span.start, d.span.end));
247    defs.dedup();
248    defs
249}
250
251/// #848: the flat compound-name member kinds (`"Owner.member"`, built by
252/// `bynk-emit`'s `assemble_index`). A dotted doc-link name matches these
253/// verbatim — never split into owner/member.
254const DOC_LINK_MEMBER_KINDS: &[SymbolKind] = &[
255    SymbolKind::Field,
256    SymbolKind::Method,
257    SymbolKind::CapabilityOp,
258    SymbolKind::Handler,
259];
260
261/// #848: the top-level kinds a *bare* doc-link name matches.
262const DOC_LINK_BARE_KINDS: &[SymbolKind] = &[
263    SymbolKind::Type,
264    SymbolKind::Fn,
265    SymbolKind::Capability,
266    SymbolKind::Service,
267    SymbolKind::Agent,
268    SymbolKind::Provider,
269    SymbolKind::Actor,
270    SymbolKind::Asset,
271    SymbolKind::Embed,
272];
273
274/// #848: resolve one intra-doc-link candidate name (from
275/// `bynk_ide::symbols::scan_doc_link_candidates`) against `owner_unit`'s
276/// doc-link scope order in `doc_scope` — the unit itself, then its `uses`
277/// targets, then its `consumes` targets (mirrors
278/// `bynk_check::index::IndexBuilder::qualify_with`'s bare-name
279/// qualification).
280///
281/// A `name` containing `.` matches the flat compound member kinds
282/// (`DOC_LINK_MEMBER_KINDS`, private below) verbatim; otherwise it matches the
283/// bare top-level kinds (`DOC_LINK_BARE_KINDS`). The **first** scope-order unit
284/// with any match decides the answer: more than one candidate there is
285/// unresolved (no `kind@` disambiguation in this increment — convention-only
286/// resolution) rather than a guess, even if a later unit in scope order would
287/// answer unambiguously. Synthetic (first-party) units are never in
288/// `index.symbols` (their defs are dropped at assembly), so a name that only
289/// matches through one — e.g. `Clock.now` — naturally comes back `None`,
290/// exactly like any other unresolved name.
291pub fn resolve_doc_link<'a>(
292    index: &'a ProjectIndex,
293    doc_scope: &HashMap<String, Vec<String>>,
294    owner_unit: &str,
295    name: &str,
296) -> Option<&'a SiteRef> {
297    let kinds: &[SymbolKind] = if name.contains('.') {
298        DOC_LINK_MEMBER_KINDS
299    } else {
300        DOC_LINK_BARE_KINDS
301    };
302    let scope = doc_scope.get(owner_unit)?;
303    for unit in scope {
304        let mut hit: Option<&SiteRef> = None;
305        let mut count = 0usize;
306        for &kind in kinds {
307            let key = SymbolKey {
308                unit: unit.clone(),
309                kind,
310                name: name.to_string(),
311            };
312            if let Some(def) = index.symbols.get(&key).and_then(|e| e.def.as_ref()) {
313                count += 1;
314                hit = Some(def);
315            }
316        }
317        match count {
318            0 => continue,
319            1 => return hit,
320            _ => return None, // ambiguous in the first matching unit — never guess
321        }
322    }
323    None
324}
325
326/// The user-declared type a value's type points at, for go-to-type-definition:
327/// a `Named` directly, or the element of a single-parameter container
328/// (`Option`/`Effect`/`List`/`HttpResult`) unwrapped to it. Built-in, function,
329/// actor, and two-parameter (`Result`/`Map`) types have no single
330/// type-declaration target and yield `None`.
331pub fn named_type_target(ty: TyId, tys: &Types) -> Option<String> {
332    match &*tys.get(ty) {
333        Ty::Named { name, .. } => Some(name.clone()),
334        Ty::Option(t) | Ty::Effect(t) | Ty::List(t) | Ty::HttpResult(t) => {
335            named_type_target(*t, tys)
336        }
337        _ => None,
338    }
339}
340
341/// v0.26 rider (ADR 0055): `documentHighlight` — the symbol-at-cursor's
342/// occurrences within that same file (the `references` query, file-scoped).
343/// The index does not distinguish read from write references, so the LSP
344/// layer omits the highlight `kind`.
345pub fn document_highlights<'a>(
346    index: &'a ProjectIndex,
347    path: &Path,
348    offset: usize,
349) -> Option<Vec<&'a SiteRef>> {
350    let sites = sites_for(index, path, offset, true)?;
351    Some(sites.into_iter().filter(|s| s.path == path).collect())
352}
353
354/// A planned rename: every name-segment edit, grouped per file, spans
355/// ascending. The definition site is edited along with every reference.
356#[derive(Debug, Clone)]
357pub struct RenamePlan {
358    pub key: SymbolKey,
359    pub new_name: String,
360    pub edits: BTreeMap<PathBuf, Vec<Span>>,
361}
362
363/// Build the rename plan for the symbol at the cursor. Errors are
364/// human-readable strings surfaced as LSP request failures.
365pub fn plan_rename(
366    index: &ProjectIndex,
367    path: &Path,
368    offset: usize,
369    new_name: &str,
370) -> Result<RenamePlan, String> {
371    validate_new_name(new_name)?;
372    let (key, _) = index.symbol_at(path, offset).ok_or_else(|| {
373        "no renameable symbol at the cursor — types, fns, methods, record fields, \
374         capability ops, capabilities, services, agents and providers rename; \
375         local bindings and unit names are not yet supported"
376            .to_string()
377    })?;
378    if key_segment(&key.name) == new_name {
379        return Err(format!("`{new_name}` is already the symbol's name"));
380    }
381    let mut edits: BTreeMap<PathBuf, Vec<Span>> = BTreeMap::new();
382    for site in index.sites(key) {
383        edits.entry(site.path.clone()).or_default().push(site.span);
384    }
385    for spans in edits.values_mut() {
386        spans.sort();
387        spans.dedup();
388    }
389    Ok(RenamePlan {
390        key: key.clone(),
391        new_name: new_name.to_string(),
392        edits,
393    })
394}
395
396/// A new name must lex as exactly one identifier (keywords lex as their own
397/// token kinds, so they fail this check).
398pub fn validate_new_name(name: &str) -> Result<(), String> {
399    let err = || format!("`{name}` is not a valid Bynk identifier");
400    let tokens = bynk_syntax::lexer::tokenize(name).map_err(|_| err())?;
401    match tokens.as_slice() {
402        [t] if matches!(t.kind, bynk_syntax::lexer::TokenKind::Ident)
403            && t.span.start == 0
404            && t.span.end == name.len() =>
405        {
406            Ok(())
407        }
408        _ => Err(err()),
409    }
410}
411
412/// Apply one file's edits (spans ascending) to its snapshot text.
413pub fn apply_edits(text: &str, spans: &[Span], new_name: &str) -> String {
414    let mut out = String::with_capacity(text.len());
415    let mut last = 0;
416    for s in spans {
417        out.push_str(&text[last..s.start]);
418        out.push_str(new_name);
419        last = s.end;
420    }
421    out.push_str(&text[last..]);
422    out
423}
424
425/// The post-edit position of a pre-edit site — rename edits shift spans
426/// within edited files. An edited span maps to the new name's span.
427pub fn remap_site(site: &SiteRef, plan: &RenamePlan) -> SiteRef {
428    let Some(spans) = plan.edits.get(&site.path) else {
429        return site.clone();
430    };
431    // The edit replaces the member segment only (`"m"` of `"Type.m"`), so the
432    // length delta is against that segment, not the whole compound key name.
433    let delta = plan.new_name.len() as isize - key_segment(&plan.key.name).len() as isize;
434    let shift: isize = spans.iter().filter(|s| s.end <= site.span.start).count() as isize * delta;
435    let start = (site.span.start as isize + shift) as usize;
436    let end = if spans.binary_search(&site.span).is_ok() {
437        start + plan.new_name.len()
438    } else {
439        (site.span.end as isize + shift) as usize
440    };
441    SiteRef {
442        path: site.path.clone(),
443        span: Span::new_in(site.span.file, start, end),
444    }
445}
446
447/// Validator (2): the re-built index must equal the pre-index modulo the
448/// rename — every other symbol's reference set identical (after remapping
449/// shifted spans), the renamed symbol's sites exactly the edited ones.
450/// Catches silent re-binding (capture/escape) that produces no diagnostic.
451pub fn index_unchanged_modulo_rename(
452    pre: &ProjectIndex,
453    post: &ProjectIndex,
454    plan: &RenamePlan,
455) -> bool {
456    // v0.36 (ADR 0069): a member key carries a compound name (`"Type.method"`),
457    // but the edit replaces only the member segment — so the post-rename key is
458    // the prefix plus the new segment, not the bare new name.
459    let target = renamed_key_name(&plan.key.name, &plan.new_name);
460    pre.equals_modulo_rename(post, &plan.key, &target, |s| remap_site(s, plan))
461}
462
463/// The post-rename value of a (possibly compound) key name: for a member key
464/// `"Type.method"`, replace the segment after the last `.`; for a bare name,
465/// the new name as-is.
466fn renamed_key_name(old: &str, new_segment: &str) -> String {
467    match old.rfind('.') {
468        Some(i) => format!("{}.{new_segment}", &old[..i]),
469        None => new_segment.to_string(),
470    }
471}
472
473/// The member segment of a (possibly compound) key name — the text the rename
474/// actually edits (every site span covers exactly this).
475fn key_segment(name: &str) -> &str {
476    name.rsplit('.').next().unwrap_or(name)
477}
478
479/// Validator (1): refuse when the edited project carries a diagnostic the
480/// original did not — compared as per-(file, category) counts, robust to
481/// span shifts. Removals are tolerated; additions refuse.
482pub fn no_new_diagnostics(
483    pre: &[(PathBuf, String)],
484    post: &[(PathBuf, String)],
485) -> Result<(), String> {
486    let mut budget: HashMap<(&Path, &str), isize> = HashMap::new();
487    for (p, c) in pre {
488        *budget.entry((p.as_path(), c.as_str())).or_default() += 1;
489    }
490    for (p, c) in post {
491        let n = budget.entry((p.as_path(), c.as_str())).or_default();
492        *n -= 1;
493        if *n < 0 {
494            return Err(format!(
495                "rename would introduce `{c}` in {} — refused",
496                p.display()
497            ));
498        }
499    }
500    Ok(())
501}
502
503// -- v0.28 (ADR 0057): semantic tokens --
504
505/// The frozen semantic-tokens legend. **Array order is the wire encoding**
506/// (clients index into these arrays): entries are append-only, never
507/// reordered — pinned by the legend-stability test. Token types: standard
508/// where faithful (`type`, `function`), custom for the Bynk-distinctive
509/// kinds (`capability`, `service`, `agent`, `provider`).
510pub fn semantic_tokens_legend() -> tower_lsp::lsp_types::SemanticTokensLegend {
511    use tower_lsp::lsp_types::{SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend};
512    SemanticTokensLegend {
513        token_types: vec![
514            SemanticTokenType::TYPE,
515            SemanticTokenType::FUNCTION,
516            SemanticTokenType::new("capability"),
517            SemanticTokenType::new("service"),
518            SemanticTokenType::new("agent"),
519            SemanticTokenType::new("provider"),
520            // v0.31 (ADR 0064): local bindings + params. Standard LSP type —
521            // VS Code themes it by default, no extension declaration needed.
522            SemanticTokenType::VARIABLE,
523            // v0.36 (ADR 0069): instance methods. Appended (never reordered) so
524            // existing legend indices are unchanged. Standard LSP type.
525            SemanticTokenType::METHOD,
526            // v0.36 (ADR 0069, slice 2): record fields. Appended. Standard LSP
527            // type. (Capability ops reuse `method` — they're operation calls.)
528            SemanticTokenType::PROPERTY,
529            // v0.45: actor declarations. Appended at index 9 (never reordered).
530            // Custom type — the VS Code extension declares it in package.json.
531            SemanticTokenType::new("actor"),
532            // v0.140 (ADR 0163): handler-position annotations (`@cache` and its
533            // argument labels). Appended at index 10. Standard LSP type — VS Code
534            // themes `decorator` by default.
535            SemanticTokenType::DECORATOR,
536            // message-bundles slice 1: `messages <tag> { ... }` bundles.
537            // Appended at index 11 (never reordered). Custom type — the VS
538            // Code extension declares it in package.json.
539            SemanticTokenType::new("messages"),
540            // #1888: a `--|` doc line. Appended at index 12 (never
541            // reordered); standard LSP type, with the `documentation`
542            // modifier. #2203: no longer emitted (one-line docs, like `---`
543            // blocks, are left to the editor grammars), but the legend is
544            // append-only, so the entry stays.
545            SemanticTokenType::COMMENT,
546            // #1883: `asset` names (static files). Appended at index 13
547            // (never reordered). Custom type — the VS Code extension declares
548            // it in package.json, as a `variable` subtype.
549            SemanticTokenType::new("asset"),
550            // #2387: `embed` names (embedded text). Appended at index 14
551            // (never reordered); a custom type the VS Code extension declares.
552            SemanticTokenType::new("embed"),
553        ],
554        token_modifiers: vec![
555            SemanticTokenModifier::DECLARATION,
556            SemanticTokenModifier::new("refined"),
557            SemanticTokenModifier::new("opaque"),
558            SemanticTokenModifier::new("platformNative"),
559            // #1888: on a `--|` doc line's `comment` token. Appended at bit 4.
560            // #2203: no longer emitted; kept, as the legend is append-only.
561            SemanticTokenModifier::DOCUMENTATION,
562        ],
563    }
564}
565
566/// Legend indices/bits — must mirror [`semantic_tokens_legend`]'s order.
567fn token_type_index(kind: SymbolKind) -> u32 {
568    match kind {
569        SymbolKind::Type => 0,
570        SymbolKind::Fn => 1,
571        SymbolKind::Capability => 2,
572        SymbolKind::Service => 3,
573        SymbolKind::Agent => 4,
574        SymbolKind::Provider => 5,
575        // 6 is `variable` (locals; TOK_LOCAL); methods append at 7.
576        SymbolKind::Method => 7,
577        // v0.36 slice 2: ops reuse `method` (7); fields append `property` at 8.
578        SymbolKind::CapabilityOp => 7,
579        SymbolKind::Field => 8,
580        // v0.45: actors append `actor` at 9.
581        SymbolKind::Actor => 9,
582        // #304: agent handlers reuse `method` (7), same as capability ops.
583        SymbolKind::Handler => 7,
584        // message-bundles slice 1: `messages` bundles append `messages` at 11.
585        SymbolKind::Messages => 11,
586        // #1883: asset names append `asset` at 13.
587        SymbolKind::Asset => 13,
588        // #2387: embed names append `embed` at 14.
589        SymbolKind::Embed => 14,
590    }
591}
592
593/// Legend index of the `variable` token type (locals; ADR 0064).
594const TOK_LOCAL: u32 = 6;
595
596/// Legend index of the `decorator` token type (handler annotations; ADR 0163).
597const TOK_DECORATOR: u32 = 10;
598
599const MOD_DECLARATION: u32 = 1 << 0;
600const MOD_REFINED: u32 = 1 << 1;
601const MOD_OPAQUE: u32 = 1 << 2;
602const MOD_PLATFORM_NATIVE: u32 = 1 << 3;
603
604fn modifier_bits(m: bynk_check::index::SymbolModifiers) -> u32 {
605    (if m.refined { MOD_REFINED } else { 0 })
606        | (if m.opaque { MOD_OPAQUE } else { 0 })
607        | (if m.platform_native {
608            MOD_PLATFORM_NATIVE
609        } else {
610            0
611        })
612}
613
614/// Semantic tokens for `path`, delta-encoded over the frozen legend —
615/// a pure read of the cached index's two sources: `symbols` (user
616/// defs+refs; a def site carries `declaration`) and `foreign_refs`
617/// (first-party references). `range` (byte offsets into `text`, the
618/// analysed snapshot) filters to overlapping tokens for the `…/range`
619/// request; `None` is the full document.
620pub fn semantic_tokens(
621    index: &ProjectIndex,
622    local_tokens: &[(Span, bool)],
623    decorator_tokens: &[Span],
624    path: &Path,
625    text: &str,
626    range: Option<Span>,
627) -> Vec<tower_lsp::lsp_types::SemanticToken> {
628    let in_scope = |span: Span| {
629        span.end <= text.len() && range.is_none_or(|r| span.end > r.start && span.start < r.end)
630    };
631    let mut raw: Vec<(Span, u32, u32)> = Vec::new();
632    for (key, entry) in &index.symbols {
633        let ty = token_type_index(key.kind);
634        let mods = modifier_bits(entry.modifiers);
635        if let Some(def) = &entry.def
636            && def.path == path
637            && in_scope(def.span)
638        {
639            raw.push((def.span, ty, mods | MOD_DECLARATION));
640        }
641        for site in &entry.refs {
642            if site.path == path && in_scope(site.span) {
643                raw.push((site.span, ty, mods));
644            }
645        }
646    }
647    for fr in &index.foreign_refs {
648        if fr.site.path == path && in_scope(fr.site.span) {
649            raw.push((
650                fr.site.span,
651                token_type_index(fr.kind),
652                modifier_bits(fr.modifiers),
653            ));
654        }
655    }
656    // v0.31 (ADR 0064): local bindings + their uses (precomputed by the caller
657    // via `locals_nav`, so this stays free of that dependency for the
658    // `#[path]`-include tests). Disjoint from the index tokens — locals are
659    // never top-level symbols — so they merge into the same sorted stream.
660    for &(span, is_decl) in local_tokens {
661        if in_scope(span) {
662            raw.push((span, TOK_LOCAL, if is_decl { MOD_DECLARATION } else { 0 }));
663        }
664    }
665    // v0.140 (ADR 0163): handler-annotation name + argument-label spans (precomputed
666    // by the caller from the parsed unit, keeping this a parse-free index read).
667    // Disjoint from the index/local tokens — annotations are neither symbols nor
668    // locals — so they merge into the same sorted stream.
669    for &span in decorator_tokens {
670        if in_scope(span) {
671            raw.push((span, TOK_DECORATOR, 0));
672        }
673    }
674    // Name segments never overlap (the index invariant), so a position
675    // sort fully determines the protocol's relative encoding.
676    raw.sort_by_key(|(span, _, _)| (span.start, span.end));
677    let mut data = Vec::with_capacity(raw.len());
678    // One line index for the whole snapshot: a `semanticTokens/full` request
679    // emits a position per token, so scanning from byte 0 each time is O(n²)
680    // (#732). Build it once and binary-search per token instead.
681    let positions = crate::position::PositionMap::new(text);
682    let (mut prev_line, mut prev_start) = (0u32, 0u32);
683    for (span, token_type, token_modifiers_bitset) in raw {
684        let pos = positions.position(span.start);
685        let delta_line = pos.line - prev_line;
686        let delta_start = if delta_line == 0 {
687            pos.character - prev_start
688        } else {
689            pos.character
690        };
691        data.push(tower_lsp::lsp_types::SemanticToken {
692            delta_line,
693            delta_start,
694            // The protocol counts in the negotiated encoding (UTF-16, as
695            // positions are) — not bytes.
696            length: text[span.range()].encode_utf16().count() as u32,
697            token_type,
698            token_modifiers_bitset,
699        });
700        prev_line = pos.line;
701        prev_start = pos.character;
702    }
703    data
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709    use bynk_check::index::{SymbolEntry, SymbolKind};
710
711    fn site(path: &str, start: usize, end: usize) -> SiteRef {
712        SiteRef {
713            path: PathBuf::from(path),
714            span: Span::new(start, end),
715        }
716    }
717
718    fn key(unit: &str, kind: SymbolKind, name: &str) -> SymbolKey {
719        SymbolKey {
720            unit: unit.into(),
721            kind,
722            name: name.into(),
723        }
724    }
725
726    fn index_with(entries: Vec<(SymbolKey, SiteRef, Vec<SiteRef>)>) -> ProjectIndex {
727        let mut index = ProjectIndex::default();
728        for (k, def, refs) in entries {
729            index.symbols.insert(
730                k,
731                SymbolEntry {
732                    def: Some(def),
733                    refs,
734                    ..Default::default()
735                },
736            );
737        }
738        index
739    }
740
741    #[test]
742    fn new_name_validation() {
743        assert!(validate_new_name("Money2").is_ok());
744        assert!(validate_new_name("snake_case").is_ok());
745        assert!(validate_new_name("fn").is_err(), "keyword");
746        assert!(validate_new_name("two words").is_err());
747        assert!(validate_new_name("1abc").is_err());
748        assert!(validate_new_name("a.b").is_err());
749        assert!(validate_new_name("").is_err());
750    }
751
752    #[test]
753    fn apply_and_remap_agree() {
754        // text: "fn helper(x: Int) -> Int { helper(x) }"
755        //        3..9 def                  27..33 ref
756        let text = "fn helper(x: Int) -> Int { helper(x) }";
757        let k = key("demo.a", SymbolKind::Fn, "helper");
758        let index = index_with(vec![(
759            k.clone(),
760            site("a.bynk", 3, 9),
761            vec![site("a.bynk", 27, 33)],
762        )]);
763        let plan = plan_rename(&index, Path::new("a.bynk"), 4, "do_it").unwrap();
764        let edited = apply_edits(text, &plan.edits[Path::new("a.bynk")], "do_it");
765        assert_eq!(edited, "fn do_it(x: Int) -> Int { do_it(x) }");
766
767        // Remap maps both old sites onto the new spellings.
768        for (old, expected) in [
769            (site("a.bynk", 3, 9), "do_it"),
770            (site("a.bynk", 27, 33), "do_it"),
771        ] {
772            let new = remap_site(&old, &plan);
773            assert_eq!(&edited[new.span.range()], expected);
774        }
775        // An unedited later site shifts by the accumulated delta.
776        let unrelated = site("a.bynk", 34, 35); // `x` argument
777        let new = remap_site(&unrelated, &plan);
778        assert_eq!(&edited[new.span.range()], "x");
779    }
780
781    #[test]
782    fn references_listing_orders_definition_first() {
783        let k = key("demo.a", SymbolKind::Type, "Money");
784        let index = index_with(vec![(
785            k,
786            site("a.bynk", 5, 10),
787            vec![site("b.bynk", 1, 6), site("a.bynk", 20, 25)],
788        )]);
789        let all = sites_for(&index, Path::new("b.bynk"), 3, true).unwrap();
790        assert_eq!(all.len(), 3);
791        assert_eq!(all[0].path, PathBuf::from("a.bynk"));
792        assert_eq!(all[0].span, Span::new(5, 10));
793        let without_decl = sites_for(&index, Path::new("b.bynk"), 3, false).unwrap();
794        assert_eq!(without_decl.len(), 2);
795    }
796
797    #[test]
798    fn rename_refuses_unindexed_positions_and_same_name() {
799        let k = key("demo.a", SymbolKind::Fn, "helper");
800        let index = index_with(vec![(k, site("a.bynk", 3, 9), vec![])]);
801        assert!(plan_rename(&index, Path::new("a.bynk"), 100, "x").is_err());
802        assert!(plan_rename(&index, Path::new("a.bynk"), 4, "helper").is_err());
803    }
804
805    #[test]
806    fn index_equality_detects_escape() {
807        // Pre: `helper` has no references; some other symbol unchanged.
808        let helper = key("demo.a", SymbolKind::Fn, "helper");
809        let money = key("demo.a", SymbolKind::Type, "Money");
810        let pre = index_with(vec![
811            (helper.clone(), site("a.bynk", 3, 9), vec![]),
812            (money.clone(), site("a.bynk", 50, 55), vec![]),
813        ]);
814        let plan = plan_rename(&pre, Path::new("a.bynk"), 4, "shadow").unwrap();
815
816        // Post (honest): def renamed in place, still no refs.
817        let honest = index_with(vec![
818            (
819                key("demo.a", SymbolKind::Fn, "shadow"),
820                site("a.bynk", 3, 9),
821                vec![],
822            ),
823            (money.clone(), site("a.bynk", 50, 55), vec![]),
824        ]);
825        assert!(index_unchanged_modulo_rename(&pre, &honest, &plan));
826
827        // Post (escape): a site that used to bind elsewhere now resolves to
828        // the renamed symbol — an extra reference appears.
829        let escape = index_with(vec![
830            (
831                key("demo.a", SymbolKind::Fn, "shadow"),
832                site("a.bynk", 3, 9),
833                vec![site("a.bynk", 70, 76)],
834            ),
835            (money, site("a.bynk", 50, 55), vec![]),
836        ]);
837        assert!(!index_unchanged_modulo_rename(&pre, &escape, &plan));
838    }
839
840    #[test]
841    fn method_rename_edits_the_member_segment_and_remaps_the_compound_key() {
842        // v0.36: a method key is compound (`"Counter.bump"`), but the edit
843        // touches only the `bump` segment — so the plan's new name is the bare
844        // segment, the post key is `"Counter.increment"`, and the span delta is
845        // against the segment length (4), not the compound length (12).
846        let bump = key("demo.a", SymbolKind::Method, "Counter.bump");
847        let other = key("demo.a", SymbolKind::Type, "Counter");
848        let pre = index_with(vec![
849            (other.clone(), site("a.bynk", 0, 5), vec![]),
850            // def `bump` at 11..15, one call at 40..44.
851            (
852                bump.clone(),
853                site("a.bynk", 11, 15),
854                vec![site("a.bynk", 40, 44)],
855            ),
856        ]);
857
858        // Cursor on the def segment; rename to a longer name.
859        let plan = plan_rename(&pre, Path::new("a.bynk"), 12, "increment").unwrap();
860        assert_eq!(plan.key.name, "Counter.bump");
861        assert_eq!(plan.new_name, "increment");
862        // Renaming to the same segment is refused (segment-aware, not key-aware).
863        assert!(plan_rename(&pre, Path::new("a.bynk"), 12, "bump").is_err());
864
865        // Honest post: the compound key becomes `Counter.increment`; the def
866        // grows in place (11..20) and the call shifts by +5 (45..54).
867        let post = index_with(vec![
868            (other, site("a.bynk", 0, 5), vec![]),
869            (
870                key("demo.a", SymbolKind::Method, "Counter.increment"),
871                site("a.bynk", 11, 20),
872                vec![site("a.bynk", 45, 54)],
873            ),
874        ]);
875        assert!(
876            index_unchanged_modulo_rename(&pre, &post, &plan),
877            "compound key remaps to Counter.increment and segment-based delta lines the spans up"
878        );
879    }
880
881    #[test]
882    fn workspace_symbols_filters_and_orders() {
883        let index = index_with(vec![
884            (
885                key("demo.a", SymbolKind::Type, "Money"),
886                site("a.bynk", 5, 10),
887                vec![],
888            ),
889            (
890                key("demo.b", SymbolKind::Fn, "moneyMaker"),
891                site("b.bynk", 3, 13),
892                vec![],
893            ),
894            (
895                key("demo.a", SymbolKind::Fn, "helper"),
896                site("a.bynk", 40, 46),
897                vec![],
898            ),
899        ]);
900        // Case-insensitive substring match.
901        let hits = workspace_symbols(&index, "money");
902        assert_eq!(
903            hits.iter()
904                .map(|(k, _)| k.name.as_str())
905                .collect::<Vec<_>>(),
906            vec!["Money", "moneyMaker"]
907        );
908        // Empty query lists everything, (name, unit)-ordered.
909        assert_eq!(workspace_symbols(&index, "").len(), 3);
910        assert!(workspace_symbols(&index, "nothing").is_empty());
911    }
912
913    #[test]
914    fn document_highlights_are_file_scoped() {
915        let k = key("demo.a", SymbolKind::Type, "Money");
916        let index = index_with(vec![(
917            k,
918            site("a.bynk", 5, 10),
919            vec![site("b.bynk", 1, 6), site("a.bynk", 20, 25)],
920        )]);
921        // From a.bynk: the definition + the in-file reference, not b.bynk's.
922        let highlights = document_highlights(&index, Path::new("a.bynk"), 7).unwrap();
923        assert_eq!(highlights.len(), 2);
924        assert!(highlights.iter().all(|s| s.path == Path::new("a.bynk")));
925        // No symbol at the cursor → None.
926        assert!(document_highlights(&index, Path::new("a.bynk"), 100).is_none());
927    }
928
929    #[test]
930    fn diagnostic_budget_allows_removals_refuses_additions() {
931        let pre = vec![
932            (PathBuf::from("a.bynk"), "bynk.x".to_string()),
933            (PathBuf::from("a.bynk"), "bynk.x".to_string()),
934        ];
935        let same = pre.clone();
936        assert!(no_new_diagnostics(&pre, &same).is_ok());
937        assert!(no_new_diagnostics(&pre, &pre[..1]).is_ok());
938        let mut more = pre.clone();
939        more.push((PathBuf::from("b.bynk"), "bynk.resolve.duplicate_fn".into()));
940        assert!(no_new_diagnostics(&pre, &more).is_err());
941    }
942
943    // -- v0.28 (ADR 0057): semantic tokens --
944
945    /// The legend's array order IS the wire encoding: this test freezes it.
946    /// New entries APPEND — a failure here means a silent recolour of every
947    /// client; never fix it by reordering.
948    #[test]
949    fn legend_is_frozen() {
950        let legend = semantic_tokens_legend();
951        let types: Vec<&str> = legend.token_types.iter().map(|t| t.as_str()).collect();
952        assert_eq!(
953            types,
954            [
955                "type",
956                "function",
957                "capability",
958                "service",
959                "agent",
960                "provider",
961                "variable",  // v0.31 (ADR 0064): locals — appended, never reordered
962                "method",    // v0.36 (ADR 0069): instance methods — appended
963                "property",  // v0.36 (ADR 0069, slice 2): record fields — appended
964                "actor",     // v0.45: actor declarations — appended
965                "decorator", // v0.140 (ADR 0163): handler annotations — appended
966                "messages",  // message-bundles slice 1 (#859): messages bundles — appended
967                "comment",   // #1888: `--|` doc lines — appended
968                "asset",     // #1883: static asset names — appended
969                "embed",     // #2387: embedded-text names — appended
970            ]
971        );
972        let modifiers: Vec<&str> = legend.token_modifiers.iter().map(|m| m.as_str()).collect();
973        assert_eq!(
974            modifiers,
975            [
976                "declaration",
977                "refined",
978                "opaque",
979                "platformNative",
980                "documentation"
981            ]
982        );
983    }
984
985    /// #2203: a one-line doc gets no semantic token, as a `---` block gets
986    /// none, so the editor grammar's Markdown highlighting shows through; the
987    /// legend keeps its `comment` type and `documentation` modifier.
988    #[test]
989    fn doc_lines_get_no_semantic_token() {
990        let text = "commons m\n\n  --- A doc.\n// a comment\ntype T = Int\n";
991        let tokens = semantic_tokens(
992            &ProjectIndex::default(),
993            &[],
994            &[],
995            Path::new("a.bynk"),
996            text,
997            None,
998        );
999        assert!(tokens.is_empty(), "{tokens:?}");
1000        let legend = semantic_tokens_legend();
1001        assert_eq!(legend.token_types[12].as_str(), "comment");
1002        assert_eq!(legend.token_modifiers[4].as_str(), "documentation");
1003    }
1004
1005    #[test]
1006    fn code_lenses_count_references_per_definition_in_the_file() {
1007        let index = index_with(vec![
1008            // `foo` defined in a.bynk with two references.
1009            (
1010                key("u", SymbolKind::Fn, "foo"),
1011                site("a.bynk", 3, 6),
1012                vec![site("a.bynk", 20, 23), site("b.bynk", 4, 7)],
1013            ),
1014            // `Bar` defined in a.bynk with no references (a 0-ref lens).
1015            (
1016                key("u", SymbolKind::Type, "Bar"),
1017                site("a.bynk", 40, 43),
1018                vec![],
1019            ),
1020            // `qux` defined in another file — no lens for a.bynk.
1021            (
1022                key("u", SymbolKind::Fn, "qux"),
1023                site("b.bynk", 0, 3),
1024                vec![],
1025            ),
1026        ]);
1027        let lenses = code_lenses(&index, Path::new("a.bynk"));
1028        assert_eq!(lenses.len(), 2, "two a.bynk defs get lenses");
1029        // Sorted by def position: foo (3..6) before Bar (40..43).
1030        assert_eq!((lenses[0].0.span.start, lenses[0].1.len()), (3, 2));
1031        assert_eq!((lenses[1].0.span.start, lenses[1].1.len()), (40, 0));
1032        assert!(code_lenses(&index, Path::new("c.bynk")).is_empty());
1033    }
1034
1035    #[test]
1036    fn call_hierarchy_groups_incoming_and_outgoing_by_symbol() {
1037        use bynk_check::index::CallEdge;
1038        // `a` and `b` both call `c`; `a` calls `c` twice. So `c`'s incoming
1039        // groups by caller (a with two sites, b with one), and `a`'s outgoing
1040        // is the single callee `c`.
1041        let mut index = index_with(vec![
1042            (key("u", SymbolKind::Fn, "a"), site("f.bynk", 3, 4), vec![]),
1043            (
1044                key("u", SymbolKind::Fn, "b"),
1045                site("f.bynk", 40, 41),
1046                vec![],
1047            ),
1048            (
1049                key("u", SymbolKind::Fn, "c"),
1050                site("f.bynk", 80, 81),
1051                vec![],
1052            ),
1053        ]);
1054        let edge = |caller: &str, cs: usize, ce: usize| CallEdge {
1055            caller: key("u", SymbolKind::Fn, caller),
1056            callee: key("u", SymbolKind::Fn, "c"),
1057            site: site("f.bynk", cs, ce),
1058        };
1059        index.calls = vec![edge("a", 10, 11), edge("a", 20, 21), edge("b", 50, 51)];
1060
1061        let into_c = incoming_calls(&index, &key("u", SymbolKind::Fn, "c"));
1062        // Sorted by caller def position: a (3) before b (40).
1063        assert_eq!(into_c.len(), 2);
1064        assert_eq!(
1065            (into_c[0].key.name.as_str(), into_c[0].sites.len()),
1066            ("a", 2)
1067        );
1068        assert_eq!(
1069            (into_c[1].key.name.as_str(), into_c[1].sites.len()),
1070            ("b", 1)
1071        );
1072
1073        let from_a = outgoing_calls(&index, &key("u", SymbolKind::Fn, "a"));
1074        assert_eq!(from_a.len(), 1);
1075        assert_eq!(
1076            (from_a[0].key.name.as_str(), from_a[0].sites.len()),
1077            ("c", 2)
1078        );
1079
1080        // `c` calls nothing; an unknown key yields nothing.
1081        assert!(outgoing_calls(&index, &key("u", SymbolKind::Fn, "c")).is_empty());
1082        assert!(incoming_calls(&index, &key("u", SymbolKind::Fn, "ghost")).is_empty());
1083    }
1084
1085    #[test]
1086    fn implementations_lists_provider_defs_for_a_capability() {
1087        use bynk_check::index::ImplEdge;
1088        // `Cap` is provided by `P1` and `P2`; `Other` (a capability) has none.
1089        let mut index = index_with(vec![
1090            (
1091                key("u", SymbolKind::Capability, "Cap"),
1092                site("a.bynk", 10, 13),
1093                vec![],
1094            ),
1095            (
1096                key("u", SymbolKind::Provider, "P1"),
1097                site("a.bynk", 50, 52),
1098                vec![],
1099            ),
1100            (
1101                key("u", SymbolKind::Provider, "P2"),
1102                site("b.bynk", 5, 7),
1103                vec![],
1104            ),
1105            (
1106                key("u", SymbolKind::Capability, "Other"),
1107                site("a.bynk", 80, 85),
1108                vec![],
1109            ),
1110        ]);
1111        let edge = |provider: &str, file: &str, s: usize, e: usize| ImplEdge {
1112            capability: key("u", SymbolKind::Capability, "Cap"),
1113            provider: key("u", SymbolKind::Provider, provider),
1114            site: site(file, s, e),
1115        };
1116        index.impls = vec![edge("P1", "a.bynk", 30, 33), edge("P2", "b.bynk", 20, 23)];
1117
1118        // Provider defs, sorted by position: P1 (a.bynk:50) before P2 (b.bynk:5).
1119        let impls = implementations(&index, &key("u", SymbolKind::Capability, "Cap"));
1120        assert_eq!(impls.len(), 2);
1121        assert_eq!(
1122            (&impls[0].path, impls[0].span.start),
1123            (&PathBuf::from("a.bynk"), 50)
1124        );
1125        assert_eq!(
1126            (&impls[1].path, impls[1].span.start),
1127            (&PathBuf::from("b.bynk"), 5)
1128        );
1129
1130        // A capability with no providers, and an unknown key, yield nothing.
1131        assert!(implementations(&index, &key("u", SymbolKind::Capability, "Other")).is_empty());
1132        assert!(implementations(&index, &key("u", SymbolKind::Capability, "Ghost")).is_empty());
1133    }
1134
1135    #[test]
1136    fn capability_provider_lenses_pair_a_capability_with_its_providers() {
1137        use bynk_check::index::ImplEdge;
1138        // `Cap` (defined in a.bynk) is provided by `P1`/`P2`; `Other` (a.bynk)
1139        // has no provider — so no lens; `Elsewhere` lives in b.bynk — no a.bynk lens.
1140        let mut index = index_with(vec![
1141            (
1142                key("u", SymbolKind::Capability, "Cap"),
1143                site("a.bynk", 10, 13),
1144                vec![],
1145            ),
1146            (
1147                key("u", SymbolKind::Provider, "P1"),
1148                site("a.bynk", 50, 52),
1149                vec![],
1150            ),
1151            (
1152                key("u", SymbolKind::Provider, "P2"),
1153                site("b.bynk", 5, 7),
1154                vec![],
1155            ),
1156            (
1157                key("u", SymbolKind::Capability, "Other"),
1158                site("a.bynk", 80, 85),
1159                vec![],
1160            ),
1161            (
1162                key("u", SymbolKind::Capability, "Elsewhere"),
1163                site("b.bynk", 90, 99),
1164                vec![],
1165            ),
1166        ]);
1167        index.impls = vec![
1168            ImplEdge {
1169                capability: key("u", SymbolKind::Capability, "Cap"),
1170                provider: key("u", SymbolKind::Provider, "P1"),
1171                site: site("a.bynk", 30, 33),
1172            },
1173            ImplEdge {
1174                capability: key("u", SymbolKind::Capability, "Cap"),
1175                provider: key("u", SymbolKind::Provider, "P2"),
1176                site: site("b.bynk", 20, 23),
1177            },
1178        ];
1179
1180        let lenses = capability_provider_lenses(&index, Path::new("a.bynk"));
1181        // Only `Cap` qualifies: `Other` has no providers, `Elsewhere` is off-file.
1182        assert_eq!(lenses.len(), 1);
1183        assert_eq!(lenses[0].0.span.start, 10);
1184        assert_eq!(lenses[0].1.len(), 2, "Cap has two providers");
1185        // Off-file query and a provider-less file yield nothing.
1186        assert!(capability_provider_lenses(&index, Path::new("c.bynk")).is_empty());
1187    }
1188
1189    #[test]
1190    fn refinement_family_lenses_group_refined_types_by_builtin_base() {
1191        use bynk_check::index::RefineEdge;
1192        // Email/UserId (a.bynk) + Slug (b.bynk) refine String — a family of 3;
1193        // Age refines Int alone (no family); Order is a record (no base).
1194        let mut index = index_with(vec![
1195            (
1196                key("u", SymbolKind::Type, "Email"),
1197                site("a.bynk", 10, 15),
1198                vec![],
1199            ),
1200            (
1201                key("u", SymbolKind::Type, "UserId"),
1202                site("a.bynk", 40, 46),
1203                vec![],
1204            ),
1205            (
1206                key("u", SymbolKind::Type, "Slug"),
1207                site("b.bynk", 5, 9),
1208                vec![],
1209            ),
1210            (
1211                key("u", SymbolKind::Type, "Age"),
1212                site("a.bynk", 70, 73),
1213                vec![],
1214            ),
1215            (
1216                key("u", SymbolKind::Type, "Order"),
1217                site("a.bynk", 90, 95),
1218                vec![],
1219            ),
1220        ]);
1221        let refine = |name: &str, base: BaseType| RefineEdge {
1222            base,
1223            ty: key("u", SymbolKind::Type, name),
1224        };
1225        index.refinements = vec![
1226            refine("Email", BaseType::String),
1227            refine("UserId", BaseType::String),
1228            refine("Slug", BaseType::String),
1229            refine("Age", BaseType::Int),
1230        ];
1231
1232        let lenses = refinement_family_lenses(&index, Path::new("a.bynk"));
1233        // Email + UserId qualify (the String family has 3 members, ≥ 2). `Age` is
1234        // alone over Int — no lens. `Order` has no base. `Slug` is off-file.
1235        assert_eq!(lenses.len(), 2);
1236        // Sorted by def position: Email (10) before UserId (40).
1237        assert_eq!(
1238            (lenses[0].0.span.start, lenses[0].1),
1239            (10, BaseType::String)
1240        );
1241        assert_eq!(lenses[0].2.len(), 3, "the String family spans 3 types");
1242        assert_eq!(lenses[1].0.span.start, 40);
1243        // Off-file query yields nothing.
1244        assert!(refinement_family_lenses(&index, Path::new("c.bynk")).is_empty());
1245    }
1246
1247    #[test]
1248    fn type_definitions_named_collects_type_defs_by_bare_name() {
1249        // Two units each declare an `Order` type; a fn shares the name.
1250        let index = index_with(vec![
1251            (
1252                key("a", SymbolKind::Type, "Order"),
1253                site("a.bynk", 10, 15),
1254                vec![],
1255            ),
1256            (
1257                key("b", SymbolKind::Type, "Order"),
1258                site("b.bynk", 4, 9),
1259                vec![],
1260            ),
1261            (
1262                key("a", SymbolKind::Fn, "Order"),
1263                site("a.bynk", 40, 45),
1264                vec![],
1265            ),
1266        ]);
1267        // Both `Type` defs (not the fn), sorted by position.
1268        let defs = type_definitions_named(&index, "Order");
1269        assert_eq!(defs.len(), 2);
1270        assert_eq!(
1271            (&defs[0].path, defs[0].span.start),
1272            (&PathBuf::from("a.bynk"), 10)
1273        );
1274        assert_eq!(
1275            (&defs[1].path, defs[1].span.start),
1276            (&PathBuf::from("b.bynk"), 4)
1277        );
1278        // An unknown type name yields nothing.
1279        assert!(type_definitions_named(&index, "Nope").is_empty());
1280    }
1281
1282    fn doc_scope_with(entries: Vec<(&str, Vec<&str>)>) -> HashMap<String, Vec<String>> {
1283        entries
1284            .into_iter()
1285            .map(|(unit, scope)| {
1286                (
1287                    unit.to_string(),
1288                    scope.into_iter().map(String::from).collect(),
1289                )
1290            })
1291            .collect()
1292    }
1293
1294    #[test]
1295    fn resolve_doc_link_finds_a_bare_local_hit() {
1296        let index = index_with(vec![(
1297            key("ratelimit", SymbolKind::Agent, "Limiter"),
1298            site("ratelimit.bynk", 10, 17),
1299            vec![],
1300        )]);
1301        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit"])]);
1302        let def = resolve_doc_link(&index, &scope, "ratelimit", "Limiter").unwrap();
1303        assert_eq!(def.path, PathBuf::from("ratelimit.bynk"));
1304    }
1305
1306    #[test]
1307    fn resolve_doc_link_finds_a_dotted_local_hit() {
1308        let index = index_with(vec![(
1309            key("ratelimit", SymbolKind::Field, "RateView.remaining"),
1310            site("ratelimit.bynk", 20, 29),
1311            vec![],
1312        )]);
1313        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit"])]);
1314        let def = resolve_doc_link(&index, &scope, "ratelimit", "RateView.remaining").unwrap();
1315        assert_eq!(def.path, PathBuf::from("ratelimit.bynk"));
1316    }
1317
1318    #[test]
1319    fn resolve_doc_link_follows_the_uses_chain() {
1320        // `decide` isn't declared in `ratelimit`, only in `window`, which
1321        // `ratelimit` `uses` — the scope order (built the same way
1322        // `doc_scope` is assembled) puts `window` after the local unit.
1323        let index = index_with(vec![(
1324            key("window", SymbolKind::Fn, "decide"),
1325            site("window.bynk", 5, 11),
1326            vec![],
1327        )]);
1328        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit", "window"])]);
1329        let def = resolve_doc_link(&index, &scope, "ratelimit", "decide").unwrap();
1330        assert_eq!(def.path, PathBuf::from("window.bynk"));
1331    }
1332
1333    #[test]
1334    fn resolve_doc_link_finds_an_asset_through_uses() {
1335        // #1883: an asset is a bare top-level name like a fn.
1336        let index = index_with(vec![(
1337            key("page", SymbolKind::Asset, "style"),
1338            site("page.bynk", 5, 10),
1339            vec![],
1340        )]);
1341        let scope = doc_scope_with(vec![("board", vec!["board", "page"])]);
1342        let def = resolve_doc_link(&index, &scope, "board", "style").unwrap();
1343        assert_eq!(def.path, PathBuf::from("page.bynk"));
1344    }
1345
1346    #[test]
1347    fn resolve_doc_link_finds_an_embed_through_uses() {
1348        // #2387: an embed is a bare top-level name like an asset.
1349        let index = index_with(vec![(
1350            key("page", SymbolKind::Embed, "shell"),
1351            site("page.bynk", 5, 10),
1352            vec![],
1353        )]);
1354        let scope = doc_scope_with(vec![("board", vec!["board", "page"])]);
1355        let def = resolve_doc_link(&index, &scope, "board", "shell").unwrap();
1356        assert_eq!(def.path, PathBuf::from("page.bynk"));
1357    }
1358
1359    #[test]
1360    fn resolve_doc_link_follows_the_consumes_chain_after_uses() {
1361        let index = index_with(vec![(
1362            key("platform", SymbolKind::Capability, "Clock"),
1363            site("platform.bynk", 0, 5),
1364            vec![],
1365        )]);
1366        // `uses` targets come before `consumes` targets in scope order; a name
1367        // only found via `consumes` still resolves, just later in the search.
1368        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit", "window", "platform"])]);
1369        let def = resolve_doc_link(&index, &scope, "ratelimit", "Clock").unwrap();
1370        assert_eq!(def.path, PathBuf::from("platform.bynk"));
1371    }
1372
1373    #[test]
1374    fn resolve_doc_link_is_none_when_nothing_in_scope_matches() {
1375        let index = index_with(vec![(
1376            key("ratelimit", SymbolKind::Agent, "Limiter"),
1377            site("ratelimit.bynk", 10, 17),
1378            vec![],
1379        )]);
1380        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit"])]);
1381        assert!(resolve_doc_link(&index, &scope, "ratelimit", "Nope").is_none());
1382    }
1383
1384    #[test]
1385    fn resolve_doc_link_is_none_for_a_synthetic_unit_never_in_scope() {
1386        // A synthetic (first-party) unit's symbols never make it into
1387        // `index.symbols` at all (their defs are dropped at assembly), so a
1388        // name that only exists through one comes back unresolved even
1389        // though `bynk` is a `consumes` target in scope.
1390        let index = ProjectIndex::default();
1391        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit", "bynk"])]);
1392        assert!(resolve_doc_link(&index, &scope, "ratelimit", "Clock.now").is_none());
1393    }
1394
1395    #[test]
1396    fn resolve_doc_link_is_none_when_the_owner_unit_has_no_scope_entry() {
1397        let index = ProjectIndex::default();
1398        let scope = doc_scope_with(vec![]);
1399        assert!(resolve_doc_link(&index, &scope, "ratelimit", "Limiter").is_none());
1400    }
1401
1402    #[test]
1403    fn resolve_doc_link_never_guesses_an_ambiguous_first_matching_unit() {
1404        // Two different candidate kinds share the bare name `Order` in the
1405        // *first* scope-order unit — even though a later unit in scope order
1406        // would resolve unambiguously, the ambiguity in the first matching
1407        // unit wins: unresolved, never a guess.
1408        let index = index_with(vec![
1409            (
1410                key("ratelimit", SymbolKind::Type, "Order"),
1411                site("ratelimit.bynk", 10, 15),
1412                vec![],
1413            ),
1414            (
1415                key("ratelimit", SymbolKind::Fn, "Order"),
1416                site("ratelimit.bynk", 40, 45),
1417                vec![],
1418            ),
1419            (
1420                key("window", SymbolKind::Type, "Order"),
1421                site("window.bynk", 0, 5),
1422                vec![],
1423            ),
1424        ]);
1425        let scope = doc_scope_with(vec![("ratelimit", vec!["ratelimit", "window"])]);
1426        assert!(resolve_doc_link(&index, &scope, "ratelimit", "Order").is_none());
1427    }
1428
1429    #[test]
1430    fn named_type_target_unwraps_single_param_containers() {
1431        use bynk_check::checker::{NamedKind, Types};
1432        use bynk_syntax::ast::BaseType;
1433        let tys = &Types::new();
1434        let order = || {
1435            tys.intern(Ty::Named {
1436                name: "Order".into(),
1437                kind: NamedKind::Record,
1438                args: Vec::new(),
1439            })
1440        };
1441        let target = |t| named_type_target(t, tys);
1442        assert_eq!(target(order()).as_deref(), Some("Order"));
1443        assert_eq!(
1444            target(tys.intern(Ty::Option(order()))).as_deref(),
1445            Some("Order")
1446        );
1447        // Nested single-param containers unwrap all the way.
1448        let effect = tys.intern(Ty::Effect(order()));
1449        assert_eq!(
1450            target(tys.intern(Ty::List(effect))).as_deref(),
1451            Some("Order")
1452        );
1453        // Built-in, two-parameter, and unit types have no single target.
1454        assert_eq!(target(tys.intern(Ty::Base(BaseType::Int))), None);
1455        assert_eq!(target(tys.intern(Ty::Result(order(), order()))), None);
1456        assert_eq!(target(tys.intern(Ty::Unit)), None);
1457    }
1458
1459    #[test]
1460    fn tokens_are_delta_encoded_with_modifier_bitsets() {
1461        // text:  line 0: "type Age = Int"   (def `Age` at 5..8, refined)
1462        //        line 1: "fn f(a: Age) ..." (ref `Age` at 23..26)
1463        let text = "type Age = Int\nfn f(a: Age) -> Age {}\n";
1464        let mut index = index_with(vec![(
1465            key("shop", SymbolKind::Type, "Age"),
1466            site("a.bynk", 5, 8),
1467            vec![site("a.bynk", 23, 26), site("a.bynk", 31, 34)],
1468        )]);
1469        index
1470            .symbols
1471            .get_mut(&key("shop", SymbolKind::Type, "Age"))
1472            .unwrap()
1473            .modifiers = bynk_check::index::SymbolModifiers {
1474            refined: true,
1475            ..Default::default()
1476        };
1477        let tokens = semantic_tokens(&index, &[], &[], Path::new("a.bynk"), text, None);
1478        assert_eq!(tokens.len(), 3);
1479        // Def: line 0 char 5, length 3, type `type` (0), declaration|refined.
1480        assert_eq!(
1481            (
1482                tokens[0].delta_line,
1483                tokens[0].delta_start,
1484                tokens[0].length
1485            ),
1486            (0, 5, 3)
1487        );
1488        assert_eq!(tokens[0].token_type, 0);
1489        assert_eq!(tokens[0].token_modifiers_bitset, 0b0011);
1490        // First ref: next line, char 8 (absolute — line changed), refined only.
1491        assert_eq!(
1492            (
1493                tokens[1].delta_line,
1494                tokens[1].delta_start,
1495                tokens[1].length
1496            ),
1497            (1, 8, 3)
1498        );
1499        assert_eq!(tokens[1].token_modifiers_bitset, 0b0010);
1500        // Second ref: same line, char delta from the previous token's start.
1501        assert_eq!(
1502            (
1503                tokens[2].delta_line,
1504                tokens[2].delta_start,
1505                tokens[2].length
1506            ),
1507            (0, 8, 3)
1508        );
1509    }
1510
1511    #[test]
1512    fn foreign_refs_emit_tokens_and_range_filters() {
1513        let text = "given Kv {\n  Kv.get(k)\n}\n";
1514        let mut index = ProjectIndex::default();
1515        index.foreign_refs.push(bynk_check::index::ForeignRef {
1516            site: site("a.bynk", 6, 8),
1517            kind: SymbolKind::Capability,
1518            modifiers: bynk_check::index::SymbolModifiers {
1519                platform_native: true,
1520                ..Default::default()
1521            },
1522        });
1523        index.foreign_refs.push(bynk_check::index::ForeignRef {
1524            site: site("a.bynk", 13, 15),
1525            kind: SymbolKind::Capability,
1526            modifiers: bynk_check::index::SymbolModifiers {
1527                platform_native: true,
1528                ..Default::default()
1529            },
1530        });
1531        let all = semantic_tokens(&index, &[], &[], Path::new("a.bynk"), text, None);
1532        assert_eq!(all.len(), 2);
1533        assert_eq!(all[0].token_type, 2); // capability
1534        assert_eq!(all[0].token_modifiers_bitset, 0b1000); // platformNative
1535        // Range covering only line 0 keeps only the first token.
1536        let ranged = semantic_tokens(
1537            &index,
1538            &[],
1539            &[],
1540            Path::new("a.bynk"),
1541            text,
1542            Some(Span::new(0, 10)),
1543        );
1544        assert_eq!(ranged.len(), 1);
1545        // Other files and empty indexes yield nothing.
1546        assert!(semantic_tokens(&index, &[], &[], Path::new("b.bynk"), text, None).is_empty());
1547        assert!(
1548            semantic_tokens(
1549                &ProjectIndex::default(),
1550                &[],
1551                &[],
1552                Path::new("a.bynk"),
1553                text,
1554                None
1555            )
1556            .is_empty()
1557        );
1558    }
1559}