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/// What kind of local binding this is — the distinction hover renders as its
23/// `let`/`param` prefix (v0.122, editor-currency slice 1). Every
24/// [`LocalsSink::record`] site knows its kind statically; this is a threaded
25/// argument, not inferred.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LocalKind {
28 /// A `let =` / `let <-` binding.
29 Let,
30 /// A parameter — fn / handler / op / lambda, or an actor `by`-binder.
31 Param,
32}
33
34/// One local binding: its name, the binding-name span (the def site), its
35/// [`kind`](LocalKind), its rendered type (Bynk surface syntax, as hints
36/// render — no `Ty` on the surface), and the source range over which it is in
37/// scope.
38#[derive(Debug, Clone)]
39pub struct LocalBinding {
40 pub name: String,
41 pub def_span: Span,
42 pub kind: LocalKind,
43 pub ty: String,
44 pub scope: Span,
45}
46
47/// Project-relative source path → that file's local bindings, in source order.
48pub type FileLocals = HashMap<PathBuf, Vec<LocalBinding>>;
49
50/// Records local bindings per file. A fresh sink records nothing until
51/// [`enter_file`](Self::enter_file) attributes it.
52#[derive(Debug, Default)]
53pub struct LocalsSink {
54 files: FileLocals,
55 file: Option<PathBuf>,
56 /// Set for synthetic (toolchain-injected) files only.
57 muted: bool,
58}
59
60impl LocalsSink {
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 /// Enter a per-file recording context.
66 pub fn enter_file(&mut self, file: &Path, muted: bool) {
67 self.file = Some(file.to_path_buf());
68 self.muted = muted;
69 }
70
71 /// Record a binding `name` defined at `def_span`, of the given `kind` and
72 /// rendered type `ty`, in scope over `scope`. Dropped when muted or before
73 /// any `enter_file`.
74 pub fn record(
75 &mut self,
76 name: String,
77 def_span: Span,
78 kind: LocalKind,
79 ty: String,
80 scope: Span,
81 ) {
82 if self.muted {
83 return;
84 }
85 let Some(file) = &self.file else {
86 return;
87 };
88 self.files
89 .entry(file.clone())
90 .or_default()
91 .push(LocalBinding {
92 name,
93 def_span,
94 kind,
95 ty,
96 scope,
97 });
98 }
99
100 /// Drain the recorded bindings, each file's entries ordered by def span.
101 pub fn take_files(&mut self) -> FileLocals {
102 let mut files = std::mem::take(&mut self.files);
103 for locals in files.values_mut() {
104 locals.sort_by_key(|b| (b.def_span.start, b.def_span.end));
105 }
106 files
107 }
108}
109
110/// The local bindings in scope at `offset`, deduplicated by name with the
111/// **innermost/latest** definition winning (shadowing) — the completion and
112/// navigation query.
113pub fn locals_at(entries: &[LocalBinding], offset: usize) -> Vec<&LocalBinding> {
114 let mut by_name: HashMap<&str, &LocalBinding> = HashMap::new();
115 for b in entries {
116 if b.scope.start <= offset && offset <= b.scope.end {
117 // Later def (larger start) shadows an earlier same-name binding.
118 by_name
119 .entry(&b.name)
120 .and_modify(|cur| {
121 if b.def_span.start >= cur.def_span.start {
122 *cur = b;
123 }
124 })
125 .or_insert(b);
126 }
127 }
128 let mut out: Vec<&LocalBinding> = by_name.into_values().collect();
129 out.sort_by_key(|b| (b.def_span.start, b.def_span.end));
130 out
131}
132
133/// The single binding whose **def-name** span covers `offset` — the
134/// definition-site query (for references/rename on a local's declaration).
135pub fn binding_at_def(entries: &[LocalBinding], offset: usize) -> Option<&LocalBinding> {
136 entries
137 .iter()
138 .find(|b| b.def_span.start <= offset && offset <= b.def_span.end)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 fn b(name: &str, def: usize, scope: (usize, usize)) -> LocalBinding {
146 LocalBinding {
147 name: name.to_string(),
148 def_span: Span::new(def, def + name.len()),
149 kind: LocalKind::Let,
150 ty: "Int".to_string(),
151 scope: Span::new(scope.0, scope.1),
152 }
153 }
154
155 #[test]
156 fn locals_at_filters_by_scope_and_resolves_shadowing() {
157 let entries = vec![
158 b("x", 0, (3, 50)), // outer x
159 b("y", 10, (13, 30)), // y, narrower scope
160 b("x", 20, (23, 50)), // inner x shadows the outer
161 ];
162 // Before y's scope: just the outer x.
163 assert_eq!(names(&locals_at(&entries, 5)), vec!["x"]);
164 // Inside y's scope, before the inner x: x (outer) + y.
165 assert_eq!(names(&locals_at(&entries, 15)), vec!["x", "y"]);
166 // After the inner x and past y's scope: one x (the inner shadows).
167 let at = locals_at(&entries, 40);
168 assert_eq!(names(&at), vec!["x"]);
169 assert_eq!(at[0].def_span.start, 20, "latest x wins");
170 }
171
172 #[test]
173 fn binding_at_def_finds_the_declaration_under_the_cursor() {
174 let entries = vec![b("total", 4, (12, 40))];
175 assert!(binding_at_def(&entries, 6).is_some()); // on the name
176 assert!(binding_at_def(&entries, 20).is_none()); // a use site, not the def
177 }
178
179 fn names(bs: &[&LocalBinding]) -> Vec<String> {
180 bs.iter().map(|b| b.name.clone()).collect()
181 }
182}