bynk_check/locals.rs
1//! v0.31 (ADR 0064): the local-binding sink.
2//!
3//! Records each local binding — `let`/`let <-`, lambda/fn/handler parameters,
4//! and match-arm pattern bindings — with its **lexical scope range**, so the
5//! LSP can offer/navigate locals (the recurring deferral: the v0.25 index, the
6//! v0.27 hints, the v0.28 tokens, and v0.30.2 completion all stop at top-level
7//! symbols for want of a scope-at-offset query).
8//!
9//! Mirrors [`HintSink`](crate::hints::HintSink): a `&mut` sink threaded through
10//! the checker, recording at the binding sites as types are computed — so it
11//! survives a transient error at the sites the checker still reaches, and (like
12//! hints) it is not part of the `Ok(TypedCommons)` payload. Scope ranges are
13//! taken from the enclosing block/body/arm span the checker already has at each
14//! binding site, not re-derived — so nesting and shadowing are the checker's
15//! own (tested) scoping, resolved in [`locals_at`]. Only synthetic files are
16//! muted (locals serve completion/navigation in test files too).
17
18use bynk_syntax::span::Span;
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22/// One local binding: its name, the binding-name span (the def site), its
23/// rendered type (Bynk surface syntax, as hints render — no `Ty` on the
24/// surface), and the source range over which it is in scope.
25#[derive(Debug, Clone)]
26pub struct LocalBinding {
27 pub name: String,
28 pub def_span: Span,
29 pub ty: String,
30 pub scope: Span,
31}
32
33/// Project-relative source path → that file's local bindings, in source order.
34pub type FileLocals = HashMap<PathBuf, Vec<LocalBinding>>;
35
36/// Records local bindings per file. A fresh sink records nothing until
37/// [`enter_file`](Self::enter_file) attributes it.
38#[derive(Debug, Default)]
39pub struct LocalsSink {
40 files: FileLocals,
41 file: Option<PathBuf>,
42 /// Set for synthetic (toolchain-injected) files only.
43 muted: bool,
44}
45
46impl LocalsSink {
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 /// Enter a per-file recording context.
52 pub fn enter_file(&mut self, file: &Path, muted: bool) {
53 self.file = Some(file.to_path_buf());
54 self.muted = muted;
55 }
56
57 /// Record a binding `name` defined at `def_span`, of rendered type `ty`,
58 /// in scope over `scope`. Dropped when muted or before any `enter_file`.
59 pub fn record(&mut self, name: String, def_span: Span, ty: String, scope: Span) {
60 if self.muted {
61 return;
62 }
63 let Some(file) = &self.file else {
64 return;
65 };
66 self.files
67 .entry(file.clone())
68 .or_default()
69 .push(LocalBinding {
70 name,
71 def_span,
72 ty,
73 scope,
74 });
75 }
76
77 /// Drain the recorded bindings, each file's entries ordered by def span.
78 pub fn take_files(&mut self) -> FileLocals {
79 let mut files = std::mem::take(&mut self.files);
80 for locals in files.values_mut() {
81 locals.sort_by_key(|b| (b.def_span.start, b.def_span.end));
82 }
83 files
84 }
85}
86
87/// The local bindings in scope at `offset`, deduplicated by name with the
88/// **innermost/latest** definition winning (shadowing) — the completion and
89/// navigation query.
90pub fn locals_at(entries: &[LocalBinding], offset: usize) -> Vec<&LocalBinding> {
91 let mut by_name: HashMap<&str, &LocalBinding> = HashMap::new();
92 for b in entries {
93 if b.scope.start <= offset && offset <= b.scope.end {
94 // Later def (larger start) shadows an earlier same-name binding.
95 by_name
96 .entry(&b.name)
97 .and_modify(|cur| {
98 if b.def_span.start >= cur.def_span.start {
99 *cur = b;
100 }
101 })
102 .or_insert(b);
103 }
104 }
105 let mut out: Vec<&LocalBinding> = by_name.into_values().collect();
106 out.sort_by_key(|b| (b.def_span.start, b.def_span.end));
107 out
108}
109
110/// The single binding whose **def-name** span covers `offset` — the
111/// definition-site query (for references/rename on a local's declaration).
112pub fn binding_at_def(entries: &[LocalBinding], offset: usize) -> Option<&LocalBinding> {
113 entries
114 .iter()
115 .find(|b| b.def_span.start <= offset && offset <= b.def_span.end)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn b(name: &str, def: usize, scope: (usize, usize)) -> LocalBinding {
123 LocalBinding {
124 name: name.to_string(),
125 def_span: Span {
126 start: def,
127 end: def + name.len(),
128 },
129 ty: "Int".to_string(),
130 scope: Span {
131 start: scope.0,
132 end: scope.1,
133 },
134 }
135 }
136
137 #[test]
138 fn locals_at_filters_by_scope_and_resolves_shadowing() {
139 let entries = vec![
140 b("x", 0, (3, 50)), // outer x
141 b("y", 10, (13, 30)), // y, narrower scope
142 b("x", 20, (23, 50)), // inner x shadows the outer
143 ];
144 // Before y's scope: just the outer x.
145 assert_eq!(names(&locals_at(&entries, 5)), vec!["x"]);
146 // Inside y's scope, before the inner x: x (outer) + y.
147 assert_eq!(names(&locals_at(&entries, 15)), vec!["x", "y"]);
148 // After the inner x and past y's scope: one x (the inner shadows).
149 let at = locals_at(&entries, 40);
150 assert_eq!(names(&at), vec!["x"]);
151 assert_eq!(at[0].def_span.start, 20, "latest x wins");
152 }
153
154 #[test]
155 fn binding_at_def_finds_the_declaration_under_the_cursor() {
156 let entries = vec![b("total", 4, (12, 40))];
157 assert!(binding_at_def(&entries, 6).is_some()); // on the name
158 assert!(binding_at_def(&entries, 20).is_none()); // a use site, not the def
159 }
160
161 fn names(bs: &[&LocalBinding]) -> Vec<String> {
162 bs.iter().map(|b| b.name.clone()).collect()
163 }
164}