bynk_check/index.rs
1//! v0.25: the project-wide binding index (ADR 0053).
2//!
3//! [`RefSink`] collects use→def edges at the resolution sites themselves —
4//! the resolver's reference walk, the checker's capability/service call
5//! dispatch, and the project driver's clause wiring — mirroring v0.24's
6//! `ErrorSink` collection-point pattern. The project pass then qualifies
7//! bare names per unit and assembles a [`ProjectIndex`]: every in-scope
8//! symbol's definition site plus all of its reference sites, binding-correct
9//! (never name-matched).
10//!
11//! In-scope symbol kinds this increment: top-level types, free `fn`s,
12//! capabilities, services, agents, and providers. Instance methods, record
13//! fields, capability op names, and local bindings are deferred (no edges
14//! are recorded for them).
15
16use std::collections::{HashMap, HashSet};
17use std::path::{Path, PathBuf};
18
19use bynk_syntax::span::Span;
20
21/// The kind half of a symbol's structural key.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub enum SymbolKind {
24 Type,
25 Fn,
26 Capability,
27 Service,
28 Agent,
29 Provider,
30 /// v0.36 (ADR 0069): an instance method, keyed by the compound name
31 /// `"Type.method"` in the type's defining unit. The first parent-scoped
32 /// index kind (see the v0.36 members slice).
33 Method,
34 /// v0.36 (ADR 0069, slice 2): a record field, keyed by `"Type.field"`.
35 Field,
36 /// v0.36 (ADR 0069, slice 2): a capability operation, keyed by `"Cap.op"`.
37 CapabilityOp,
38 /// v0.45: an actor declaration — a boundary contract consumed by a
39 /// handler's `by` clause.
40 Actor,
41}
42
43impl SymbolKind {
44 pub fn display(self) -> &'static str {
45 match self {
46 SymbolKind::Type => "type",
47 SymbolKind::Fn => "fn",
48 SymbolKind::Capability => "capability",
49 SymbolKind::Service => "service",
50 SymbolKind::Agent => "agent",
51 SymbolKind::Provider => "provider",
52 SymbolKind::Method => "method",
53 SymbolKind::Field => "field",
54 SymbolKind::CapabilityOp => "operation",
55 SymbolKind::Actor => "actor",
56 }
57 }
58}
59
60/// Structural symbol identity (no `DefId` plumbing): the defining unit's
61/// qualified name, the declaration kind, and the declared name. Top-level
62/// names are unique within a unit, so the key is unambiguous.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
64pub struct SymbolKey {
65 pub unit: String,
66 pub kind: SymbolKind,
67 pub name: String,
68}
69
70/// One recorded use→def edge, in collection-point context.
71///
72/// `unit: None` means the name resolved through the recording namespace's
73/// merged tables (local declarations + `uses` imports) and is qualified at
74/// assembly; `Some` means the resolution site already knew the defining
75/// unit (cross-context capability/service references, flattened caps).
76#[derive(Debug, Clone)]
77pub struct RefEdge {
78 /// The name-segment span (for dotted `B.Cap`, just `Cap`).
79 pub span: Span,
80 pub kind: SymbolKind,
81 pub name: String,
82 pub unit: Option<String>,
83 /// Project-relative file the span is an offset into (collection point).
84 pub file: PathBuf,
85 /// The unit whose merged namespace resolves a bare (`unit: None`) name.
86 /// For test/integration files this is the *target* unit.
87 pub namespace: Option<String>,
88 /// Display name of the enclosing top-level declaration, when known
89 /// (`"f"`, `"T.m"`, a service/provider name). Used at assembly to
90 /// re-attribute spans to the file that declares the owner — sibling-file
91 /// methods and unit-level handler tables are processed under a different
92 /// file than the one their spans index into.
93 pub owner: Option<String>,
94 /// v0.35 (ADR 0068): set only on the `Cap` of a `provides Cap = Provider`
95 /// clause (never on a `given Cap` dependency). With `owner` the provider,
96 /// this marks a capability→provider implementation edge — distinguishing
97 /// the provided capability from the provider's own `given` deps, which are
98 /// also capability refs owned by the same provider.
99 pub provides: bool,
100}
101
102/// Collection-point sink for use→def edges (the `ErrorSink` analogue).
103/// The pipeline sets the ambient file/namespace before each per-file phase;
104/// resolution sites only supply the span and target. A sink left in its
105/// default state (no file) discards edges — the single-file entry points
106/// resolve without recording.
107#[derive(Debug, Default)]
108pub struct RefSink {
109 pub edges: Vec<RefEdge>,
110 /// Synthetic namespaces (integration-test harness roots) → their `uses`
111 /// resolution order, merged with the project's `uses` table at assembly.
112 pub extra_uses: HashMap<String, Vec<String>>,
113 file: Option<PathBuf>,
114 namespace: Option<String>,
115 owner: Option<String>,
116 /// Set while processing synthetic (toolchain-injected) files: edges are
117 /// discarded — first-party units are not user-editable and out of index.
118 muted: bool,
119}
120
121impl RefSink {
122 pub fn new() -> Self {
123 Self::default()
124 }
125
126 /// Declare a synthetic namespace's `uses` resolution order (integration
127 /// harness roots are not project units, so the project's `uses` table
128 /// has no entry for them).
129 pub fn declare_namespace(&mut self, namespace: &str, uses: Vec<String>) {
130 self.extra_uses.insert(namespace.to_string(), uses);
131 }
132
133 /// Enter a per-file recording context. `namespace` is the unit whose
134 /// merged tables resolve bare names in this file (the file's own unit,
135 /// or a test file's target unit).
136 pub fn enter_file(&mut self, file: &Path, namespace: &str, muted: bool) {
137 self.file = Some(file.to_path_buf());
138 self.namespace = Some(namespace.to_string());
139 self.owner = None;
140 self.muted = muted;
141 }
142
143 /// Set the enclosing top-level declaration for subsequent edges.
144 pub fn set_owner(&mut self, owner: impl Into<String>) {
145 self.owner = Some(owner.into());
146 }
147
148 pub fn clear_owner(&mut self) {
149 self.owner = None;
150 }
151
152 /// Record an edge whose defining unit is found at assembly.
153 pub fn record(&mut self, span: Span, kind: SymbolKind, name: &str) {
154 self.push(span, kind, name, None, false);
155 }
156
157 /// Record an edge whose defining unit the resolution site already knows.
158 pub fn record_in_unit(&mut self, span: Span, kind: SymbolKind, name: &str, unit: &str) {
159 self.push(span, kind, name, Some(unit.to_string()), false);
160 }
161
162 /// v0.35 (ADR 0068): record the `Cap` of a `provides Cap = Provider` clause
163 /// — a capability reference also flagged as an implementation edge (the
164 /// owner is the provider). `unit` is `Some` for a cross-context provided
165 /// capability, `None` when it resolves at assembly.
166 pub fn record_provides(&mut self, span: Span, name: &str, unit: Option<&str>) {
167 self.push(
168 span,
169 SymbolKind::Capability,
170 name,
171 unit.map(str::to_string),
172 true,
173 );
174 }
175
176 fn push(
177 &mut self,
178 span: Span,
179 kind: SymbolKind,
180 name: &str,
181 unit: Option<String>,
182 provides: bool,
183 ) {
184 if self.muted {
185 return;
186 }
187 let Some(file) = &self.file else {
188 return; // single-file mode: no recording context.
189 };
190 self.edges.push(RefEdge {
191 span,
192 kind,
193 name: name.to_string(),
194 unit,
195 file: file.clone(),
196 namespace: self.namespace.clone(),
197 owner: self.owner.clone(),
198 provides,
199 });
200 }
201}
202
203/// One occurrence of a symbol: the file (project-relative) and the
204/// name-segment span within that file's analysed snapshot.
205#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
206pub struct SiteRef {
207 pub path: PathBuf,
208 pub span: Span,
209}
210
211/// v0.28 (ADR 0057): the Bynk-specific semantic-token modifiers recorded on
212/// a symbol at assemble time. `refined` only when a refinement is present —
213/// `type Age = Int` parses as `Refined { refinement: None }` and is a plain
214/// alias, carrying neither; `opaque` is orthogonal, so `opaque B where …`
215/// carries both. `platform_native` when the declaring unit is a platform
216/// adapter (`firstparty::platform_of` is `Some`).
217#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
218pub struct SymbolModifiers {
219 pub refined: bool,
220 pub opaque: bool,
221 pub platform_native: bool,
222}
223
224/// A symbol's definition site plus every reference site.
225#[derive(Debug, Clone, Default)]
226pub struct SymbolEntry {
227 /// The declaration's name span. `None` only transiently during assembly;
228 /// symbols without a located definition are dropped from the index.
229 pub def: Option<SiteRef>,
230 /// Sorted, deduplicated. Does not include the definition site.
231 pub refs: Vec<SiteRef>,
232 /// v0.28 (ADR 0057): semantic-token modifiers, set from the declaration.
233 pub modifiers: SymbolModifiers,
234}
235
236/// v0.34 (ADR 0067): one resolved caller→callee call edge — a `Fn` reference
237/// (`callee`) occurring inside a known top-level declaration (`caller`), at
238/// `site` (the callee-name span, in the caller's file). The backing data for
239/// call hierarchy: incoming calls group edges by `callee`, outgoing by
240/// `caller`. v0.36 (ADR 0069): `Fn` and `Method` callees/callers; op-call and
241/// agent-dispatch edges are still absent (those callees aren't index symbols —
242/// the remaining deferred index kinds).
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct CallEdge {
245 pub caller: SymbolKey,
246 pub callee: SymbolKey,
247 pub site: SiteRef,
248}
249
250/// v0.35 (ADR 0068): one capability→provider implementation edge — a `provides
251/// Cap = P` clause records a `Capability` reference (`capability`) whose
252/// enclosing owner is the provider (`provider`), at `site` (the capability-name
253/// span in the `provides` clause). The backing data for implementation
254/// navigation: `implementation` on a capability returns its providers' defs.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct ImplEdge {
257 pub capability: SymbolKey,
258 pub provider: SymbolKey,
259 pub site: SiteRef,
260}
261
262/// v0.28 (ADR 0057): one reference to a first-party (`bynk.*`) symbol.
263/// Tokens-only: first-party defs point at synthetic files not on disk, so
264/// these sites are **never** read by definition/rename/workspace-symbol —
265/// the v0.25 exclusion of synthetic units from `symbols` stands untouched.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct ForeignRef {
268 pub site: SiteRef,
269 pub kind: SymbolKind,
270 pub modifiers: SymbolModifiers,
271}
272
273/// The project-wide binding index: every in-scope symbol's definition and
274/// references, keyed structurally. Built by the v0.24 project pass in
275/// analyse mode; empty in build mode.
276#[derive(Debug, Clone, Default)]
277pub struct ProjectIndex {
278 pub symbols: HashMap<SymbolKey, SymbolEntry>,
279 /// v0.28 (ADR 0057): references to first-party symbols, sorted by
280 /// (path, span), deduplicated — read only by the semantic-tokens
281 /// producer (see [`ForeignRef`]).
282 pub foreign_refs: Vec<ForeignRef>,
283 /// v0.34 (ADR 0067): caller→callee call edges (`Fn` callees only), sorted
284 /// by (caller, callee, site). The call-hierarchy graph (see [`CallEdge`]).
285 pub calls: Vec<CallEdge>,
286 /// v0.35 (ADR 0068): capability→provider implementation edges, sorted by
287 /// (capability, provider, site). The implementation-nav graph (see
288 /// [`ImplEdge`]).
289 pub impls: Vec<ImplEdge>,
290}
291
292impl ProjectIndex {
293 /// The symbol whose definition or reference name-segment contains
294 /// `offset` within `path`. Spans are half-open; name segments never
295 /// overlap, so the first hit is the only hit.
296 pub fn symbol_at(&self, path: &Path, offset: usize) -> Option<(&SymbolKey, &SiteRef)> {
297 for (key, entry) in &self.symbols {
298 if let Some(def) = &entry.def
299 && def.path == path
300 && def.span.range().contains(&offset)
301 {
302 return Some((key, def));
303 }
304 for site in &entry.refs {
305 if site.path == path && site.span.range().contains(&offset) {
306 return Some((key, site));
307 }
308 }
309 }
310 None
311 }
312
313 /// Definition + references for `key`, definition first.
314 pub fn sites(&self, key: &SymbolKey) -> Vec<&SiteRef> {
315 let Some(entry) = self.symbols.get(key) else {
316 return Vec::new();
317 };
318 entry.def.iter().chain(entry.refs.iter()).collect()
319 }
320
321 /// v0.34 (ADR 0067): call edges whose callee is `key` — its callers.
322 pub fn calls_into<'a>(&'a self, key: &SymbolKey) -> impl Iterator<Item = &'a CallEdge> {
323 let key = key.clone();
324 self.calls.iter().filter(move |e| e.callee == key)
325 }
326
327 /// v0.34 (ADR 0067): call edges whose caller is `key` — what it calls.
328 pub fn calls_from<'a>(&'a self, key: &SymbolKey) -> impl Iterator<Item = &'a CallEdge> {
329 let key = key.clone();
330 self.calls.iter().filter(move |e| e.caller == key)
331 }
332
333 /// v0.35 (ADR 0068): impl edges whose capability is `key` — its providers.
334 pub fn impls_of<'a>(&'a self, key: &SymbolKey) -> impl Iterator<Item = &'a ImplEdge> {
335 let key = key.clone();
336 self.impls.iter().filter(move |e| e.capability == key)
337 }
338
339 /// Structural equality after mapping `self`'s sites through `remap`
340 /// and renaming `from` to `to_name` — the rename capture/escape
341 /// validator. `remap` converts a pre-edit site to its post-edit
342 /// position (rename edits shift spans within edited files).
343 pub fn equals_modulo_rename(
344 &self,
345 post: &ProjectIndex,
346 from: &SymbolKey,
347 to_name: &str,
348 mut remap: impl FnMut(&SiteRef) -> SiteRef,
349 ) -> bool {
350 if self.symbols.len() != post.symbols.len() {
351 return false;
352 }
353 for (key, entry) in &self.symbols {
354 let expect_key = if key == from {
355 SymbolKey {
356 unit: key.unit.clone(),
357 kind: key.kind,
358 name: to_name.to_string(),
359 }
360 } else {
361 key.clone()
362 };
363 let Some(post_entry) = post.symbols.get(&expect_key) else {
364 return false;
365 };
366 let expect_def = entry.def.as_ref().map(&mut remap);
367 if expect_def != post_entry.def {
368 return false;
369 }
370 let mut expect_refs: Vec<SiteRef> = entry.refs.iter().map(&mut remap).collect();
371 expect_refs.sort();
372 let mut post_refs = post_entry.refs.clone();
373 post_refs.sort();
374 if expect_refs != post_refs {
375 return false;
376 }
377 }
378 true
379 }
380}
381
382/// Assembles the index from per-file declaration walks plus the sink's
383/// edges. Built by the project pass, which alone knows unit membership,
384/// `uses` targets, and which file declares each top-level item.
385#[derive(Debug, Default)]
386pub struct IndexBuilder {
387 /// (unit, kind, name) → definition site + modifiers.
388 defs: HashMap<SymbolKey, (SiteRef, SymbolModifiers)>,
389 /// v0.28 (ADR 0057): first-party (`bynk.*`) symbols — kind + modifiers
390 /// only, no usable def site (synthetic files are not on disk). Edges
391 /// qualifying here route into [`ProjectIndex::foreign_refs`].
392 first_party_defs: HashMap<SymbolKey, SymbolModifiers>,
393 /// (unit, owner display name) → declaring file, for span re-attribution.
394 /// Includes methods (`"T.m"`), which are not index symbols.
395 owner_files: HashMap<(String, String), PathBuf>,
396 /// v0.34 (ADR 0067): (unit, owner display name) → the owner's symbol key,
397 /// for resolving a call edge's caller. Only index symbols (every
398 /// `add_def`); method owners (`add_owner`) are absent, so their call edges
399 /// are not recorded — same boundary as the deferred index kinds.
400 owner_keys: HashMap<(String, String), SymbolKey>,
401 /// unit → `uses` targets, resolution order.
402 uses: HashMap<String, Vec<String>>,
403 /// unit → `consumes` targets — bare names can also resolve to a consumed
404 /// unit's exported types (the consumer's merged table layers them after
405 /// `uses` imports).
406 consumes: HashMap<String, Vec<String>>,
407}
408
409impl IndexBuilder {
410 pub fn add_def(
411 &mut self,
412 unit: &str,
413 kind: SymbolKind,
414 name: &str,
415 site: SiteRef,
416 modifiers: SymbolModifiers,
417 ) {
418 self.owner_files
419 .insert((unit.to_string(), name.to_string()), site.path.clone());
420 let key = SymbolKey {
421 unit: unit.to_string(),
422 kind,
423 name: name.to_string(),
424 };
425 self.owner_keys
426 .insert((unit.to_string(), name.to_string()), key.clone());
427 self.defs.insert(key, (site, modifiers));
428 }
429
430 /// v0.28 (ADR 0057): register a first-party symbol for the second
431 /// qualification pass — kind + modifiers only, no def site.
432 pub fn add_first_party_def(
433 &mut self,
434 unit: &str,
435 kind: SymbolKind,
436 name: &str,
437 modifiers: SymbolModifiers,
438 ) {
439 self.first_party_defs.insert(
440 SymbolKey {
441 unit: unit.to_string(),
442 kind,
443 name: name.to_string(),
444 },
445 modifiers,
446 );
447 }
448
449 /// Register a non-symbol owner (a method) for attribution only.
450 pub fn add_owner(&mut self, unit: &str, owner: &str, path: &Path) {
451 self.owner_files
452 .insert((unit.to_string(), owner.to_string()), path.to_path_buf());
453 }
454
455 pub fn set_uses(&mut self, uses: HashMap<String, Vec<String>>) {
456 self.uses = uses;
457 }
458
459 pub fn set_consumes(&mut self, consumes: HashMap<String, Vec<String>>) {
460 self.consumes = consumes;
461 }
462
463 /// Qualify, attribute, dedupe, and assemble.
464 pub fn build(self, edges: Vec<RefEdge>) -> ProjectIndex {
465 let mut index = ProjectIndex::default();
466 for (key, (def, modifiers)) in &self.defs {
467 index.symbols.insert(
468 key.clone(),
469 SymbolEntry {
470 def: Some(def.clone()),
471 refs: Vec::new(),
472 modifiers: *modifiers,
473 },
474 );
475 }
476 let mut seen: HashSet<(PathBuf, Span, SymbolKey)> = HashSet::new();
477 let mut foreign_seen: HashSet<(PathBuf, Span, SymbolKind)> = HashSet::new();
478 let mut calls: Vec<CallEdge> = Vec::new();
479 let mut impls: Vec<ImplEdge> = Vec::new();
480 for edge in edges {
481 // Re-attribute to the owner's declaring file when the owner
482 // lives in a different file than the collection point: sibling-
483 // file methods and unit-level handler tables are processed under
484 // a file other than the one their spans index into. The owner is
485 // declared in the *namespace* unit (the unit being processed).
486 let path = edge
487 .owner
488 .as_ref()
489 .zip(edge.namespace.as_ref())
490 .and_then(|(o, ns)| self.owner_files.get(&(ns.clone(), o.clone())))
491 .cloned()
492 .unwrap_or_else(|| edge.file.clone());
493 let Some(key) = self.qualify(&edge) else {
494 // v0.28 (ADR 0057): second pass — a positive match against
495 // the first-party defs routes into the tokens-only side
496 // table; genuinely unresolved targets stay dropped.
497 if let Some(key) =
498 self.qualify_with(&edge, |k| self.first_party_defs.contains_key(k))
499 && foreign_seen.insert((path.clone(), edge.span, key.kind))
500 {
501 index.foreign_refs.push(ForeignRef {
502 site: SiteRef {
503 path,
504 span: edge.span,
505 },
506 kind: key.kind,
507 modifiers: self.first_party_defs[&key],
508 });
509 }
510 continue;
511 };
512 let entry = index.symbols.entry(key.clone()).or_default();
513 let Some(def) = &entry.def else {
514 continue;
515 };
516 let site = SiteRef {
517 path,
518 span: edge.span,
519 };
520 // The definition's own name span is not also a reference.
521 if site == *def {
522 continue;
523 }
524 if seen.insert((site.path.clone(), site.span, key.clone())) {
525 // v0.34 (ADR 0067): a `Fn` call inside a known top-level owner
526 // is a call edge. The caller resolves via `owner_keys` exactly
527 // as the file re-attribution above resolves `owner_files`.
528 // v0.36 (ADR 0069): methods are call targets too, now that they
529 // are `add_def`'d index symbols (and callers, since `add_def`
530 // populates `owner_keys` for `"T.m"` owners).
531 if matches!(key.kind, SymbolKind::Fn | SymbolKind::Method)
532 && let Some(caller) = edge
533 .owner
534 .as_ref()
535 .zip(edge.namespace.as_ref())
536 .and_then(|(o, ns)| self.owner_keys.get(&(ns.clone(), o.clone())))
537 {
538 calls.push(CallEdge {
539 caller: caller.clone(),
540 callee: key.clone(),
541 site: site.clone(),
542 });
543 }
544 // v0.35 (ADR 0068): a `provides Cap = Provider` clause — a
545 // provides-flagged `Capability` ref whose owner is the provider.
546 // The flag distinguishes it from the provider's `given` deps,
547 // which are also capability refs owned by the same provider.
548 if edge.provides
549 && let Some(provider) = edge
550 .owner
551 .as_ref()
552 .zip(edge.namespace.as_ref())
553 .and_then(|(o, ns)| self.owner_keys.get(&(ns.clone(), o.clone())))
554 && provider.kind == SymbolKind::Provider
555 {
556 impls.push(ImplEdge {
557 capability: key.clone(),
558 provider: provider.clone(),
559 site: site.clone(),
560 });
561 }
562 entry.refs.push(site);
563 }
564 }
565 for entry in index.symbols.values_mut() {
566 entry.refs.sort();
567 }
568 index.symbols.retain(|_, e| e.def.is_some());
569 index.foreign_refs.sort_by(|a, b| a.site.cmp(&b.site));
570 calls.sort_by(|a, b| (&a.caller, &a.callee, &a.site).cmp(&(&b.caller, &b.callee, &b.site)));
571 index.calls = calls;
572 impls.sort_by(|a, b| {
573 (&a.capability, &a.provider, &a.site).cmp(&(&b.capability, &b.provider, &b.site))
574 });
575 index.impls = impls;
576 index
577 }
578
579 fn qualify(&self, edge: &RefEdge) -> Option<SymbolKey> {
580 self.qualify_with(edge, |k| self.defs.contains_key(k))
581 }
582
583 /// The merged-table qualification against an arbitrary def set: a
584 /// site-known unit is looked up directly; a bare name layers local
585 /// first, then `uses` imports, then consumed units' exported types —
586 /// first hit wins, matching the pipeline's `or_insert` merge priority.
587 fn qualify_with(&self, edge: &RefEdge, has: impl Fn(&SymbolKey) -> bool) -> Option<SymbolKey> {
588 if let Some(unit) = &edge.unit {
589 let key = SymbolKey {
590 unit: unit.clone(),
591 kind: edge.kind,
592 name: edge.name.clone(),
593 };
594 return has(&key).then_some(key);
595 }
596 let ns = edge.namespace.as_ref()?;
597 let local = SymbolKey {
598 unit: ns.clone(),
599 kind: edge.kind,
600 name: edge.name.clone(),
601 };
602 if has(&local) {
603 return Some(local);
604 }
605 for target in self
606 .uses
607 .get(ns)
608 .into_iter()
609 .flatten()
610 .chain(self.consumes.get(ns).into_iter().flatten())
611 {
612 let imported = SymbolKey {
613 unit: target.clone(),
614 kind: edge.kind,
615 name: edge.name.clone(),
616 };
617 if has(&imported) {
618 return Some(imported);
619 }
620 }
621 None
622 }
623}