Skip to main content

bynk_ide/
locals_nav.rs

1//! v0.31 (ADR 0064): locals navigation — resolve the local binding under the
2//! cursor and all its sites (its definition plus every use that resolves to
3//! it), for `references`, go-to-`definition`, and `documentHighlight`.
4//!
5//! Slice 1 records bindings with scope ranges (not use sites); the use sites
6//! are recovered here by lexing the file and keeping the identifier tokens of
7//! the binding's name within its scope that resolve back to it (so a shadowing
8//! inner binding's uses — and every binding's *def* token — are excluded).
9//! Pure over the analysed snapshot, like `index_queries`.
10
11use crate::symbols::is_dot_preceded;
12use bynk_check::locals::{LocalBinding, LocalKind, binding_at_def, locals_at};
13use bynk_syntax::lexer::{self, TokenKind};
14use bynk_syntax::span::Span;
15
16/// The identifier-token name covering `offset`, if any. Interpolation holes are
17/// expanded (issue #473), so a cursor inside `"… \(name) …"` resolves to the
18/// hole's `name` identifier rather than the opaque `InterpStr` token.
19///
20/// Finding #63: a field-access member name (`items` in `p.items`) is lexically
21/// indistinguishable from a bare identifier — excluded via `is_dot_preceded`
22/// (the same guard `symbols.rs` uses for state/store-op token matching, #596)
23/// so the cursor sitting on a field name never resolves to a same-named local.
24fn ident_at(text: &str, offset: usize) -> Option<(&str, Span)> {
25    let toks = lexer::tokenize_expanding_holes(text).ok()?;
26    toks.into_iter()
27        .find(|t| {
28            t.kind == TokenKind::Ident
29                && t.span.start <= offset
30                && offset <= t.span.end
31                && !is_dot_preceded(text, t.span.start)
32        })
33        .map(|t| (&text[t.span.start..t.span.end], t.span))
34}
35
36/// The binding the cursor refers to — whether it sits on the definition name
37/// or on a use — within `locals` (a file's bindings).
38fn target_at<'a>(
39    locals: &'a [LocalBinding],
40    text: &str,
41    offset: usize,
42) -> Option<&'a LocalBinding> {
43    let (name, _) = ident_at(text, offset)?;
44    binding_at_def(locals, offset)
45        .filter(|b| b.name == name)
46        .or_else(|| {
47            locals_at(locals, offset)
48                .into_iter()
49                .find(|b| b.name == name)
50        })
51}
52
53/// All sites of the local under the cursor — its definition first, then every
54/// use that resolves to it (shadowing-safe). `None` when the cursor is not on
55/// a local.
56pub fn local_sites_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Vec<Span>> {
57    let target = target_at(locals, text, offset)?;
58    // Hole-aware (issue #473): use sites inside `\(…)` holes count too.
59    let toks = lexer::tokenize_expanding_holes(text).ok()?;
60    let mut sites = vec![target.def_span];
61    for t in &toks {
62        if t.kind != TokenKind::Ident || text[t.span.start..t.span.end] != target.name {
63            continue;
64        }
65        if t.span == target.def_span {
66            continue; // the definition, already added
67        }
68        // A binding's own def token is not a use of anything.
69        if locals.iter().any(|b| b.def_span == t.span) {
70            continue;
71        }
72        // Finding #63: a field-access member name (`r.total`) is not a use of
73        // a same-named local, even when one is in scope.
74        if is_dot_preceded(text, t.span.start) {
75            continue;
76        }
77        if t.span.start < target.scope.start || t.span.end > target.scope.end {
78            continue; // outside the binding's scope
79        }
80        // Does this use resolve to `target` (not a shadowing inner binding)?
81        let resolves = locals_at(locals, t.span.start)
82            .into_iter()
83            .find(|b| b.name == target.name)
84            .map(|b| b.def_span);
85        if resolves == Some(target.def_span) {
86            sites.push(t.span);
87        }
88    }
89    Some(sites)
90}
91
92/// The definition site of the local under the cursor, if any.
93pub fn local_definition_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Span> {
94    target_at(locals, text, offset).map(|b| b.def_span)
95}
96
97/// v0.122 (editor-currency slice 1): a hover summary for the local binding /
98/// parameter under the cursor — `let x: <ty>` / `param n: <ty>`, rendered from
99/// the checker's captured `LocalBinding` (its `ty` is already the surface
100/// `type_ref` form, matching inlay hints and signature help). `None` when the
101/// cursor is not on a local. Reuses the same `target_at` resolution as
102/// go-to-definition / references, so hover cannot disagree with them.
103pub fn describe_local_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<String> {
104    let b = target_at(locals, text, offset)?;
105    let keyword = match b.kind {
106        LocalKind::Let => "let",
107        LocalKind::Param => "param",
108    };
109    Some(format!("```bynk\n{keyword} {}: {}\n```", b.name, b.ty))
110}
111
112/// Every local-binding occurrence in the file — `(span, is_definition)` — for
113/// semantic-token colouring. A token is a definition if it sits on a binding's
114/// def span, else a use if it resolves to a local in scope at that point.
115pub fn local_token_sites(locals: &[LocalBinding], text: &str) -> Vec<(Span, bool)> {
116    // Hole-aware (issue #473): locals used inside `\(…)` holes colour too.
117    let Ok(toks) = lexer::tokenize_expanding_holes(text) else {
118        return Vec::new();
119    };
120    let mut out = Vec::new();
121    for t in &toks {
122        if t.kind != TokenKind::Ident {
123            continue;
124        }
125        let name = &text[t.span.start..t.span.end];
126        if locals.iter().any(|b| b.def_span == t.span) {
127            out.push((t.span, true)); // a binding's def
128        } else if !is_dot_preceded(text, t.span.start)
129            && locals_at(locals, t.span.start)
130                .into_iter()
131                .any(|b| b.name == name)
132        {
133            // Finding #63: a field-access member name is excluded — a def
134            // token is never dot-preceded (it's a fresh binding, not an
135            // access), so that branch above is unaffected by this guard.
136            out.push((t.span, false)); // a use that resolves to a local
137        }
138    }
139    out
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // `fn f(n: Int) -> Int { let x = n  <uses> }` laid out so offsets are easy.
147    fn bindings() -> Vec<LocalBinding> {
148        // text: see `TEXT`; n: param scope over body, x: let scope after its stmt.
149        vec![
150            LocalBinding {
151                name: "n".into(),
152                def_span: Span::new(5, 6),
153                kind: LocalKind::Param,
154                ty: "Int".into(),
155                scope: Span::new(20, 60),
156            },
157            LocalBinding {
158                name: "x".into(),
159                def_span: Span::new(26, 27),
160                kind: LocalKind::Let,
161                ty: "Int".into(),
162                scope: Span::new(34, 60),
163            },
164        ]
165    }
166
167    const TEXT: &str = "fn f(n: Int) -> Int { let x = n\n  x + x\n}";
168    //                   0         1         2         3
169    //                   0123456789012345678901234567890123456789
170
171    #[test]
172    fn sites_for_a_use_collect_def_plus_uses() {
173        let locals = bindings();
174        // Cursor on the first `x` use (offset 36, in `  x + x`).
175        let x_use = TEXT.match_indices('x').nth(1).unwrap().0; // first use of x
176        let sites = local_sites_at(&locals, TEXT, x_use).expect("on a local");
177        assert!(
178            sites.contains(&Span::new(26, 27)),
179            "includes def: {sites:?}"
180        );
181        assert!(sites.len() >= 2, "def + at least one use: {sites:?}");
182    }
183
184    #[test]
185    fn definition_resolves_from_a_use() {
186        let locals = bindings();
187        let n_use = TEXT.rfind('n').unwrap(); // the `n` in `let x = n`
188        assert_eq!(
189            local_definition_at(&locals, TEXT, n_use),
190            Some(Span::new(5, 6))
191        );
192    }
193
194    #[test]
195    fn not_on_a_local_yields_none() {
196        let locals = bindings();
197        assert!(local_sites_at(&locals, TEXT, 0).is_none()); // on `fn`
198    }
199
200    /// Finding #63: `total` is both a local *and*, unrelated, a record field
201    /// name — the `r.total` field access must never be mistaken for a use of
202    /// the local, in any of the three query surfaces this module exposes.
203    #[test]
204    fn field_access_is_not_mistaken_for_a_local_use() {
205        const TEXT: &str = "fn f(r: Rec) -> Int {\n  let total = 1\n  r.total\n}";
206        let total_let = TEXT.find("total").unwrap();
207        let field_total = TEXT.rfind("total").unwrap();
208        assert_ne!(field_total, total_let, "def and field access are distinct");
209        let locals = vec![LocalBinding {
210            name: "total".into(),
211            def_span: Span::new(total_let, total_let + "total".len()),
212            kind: LocalKind::Let,
213            ty: "Int".into(),
214            scope: Span::new(total_let, TEXT.len()),
215        }];
216        let field_span = Span::new(field_total, field_total + "total".len());
217
218        // Cursor on the field access resolves to nothing — not the local.
219        assert!(
220            local_definition_at(&locals, TEXT, field_total).is_none(),
221            "a field-access member name must not resolve to the same-named local"
222        );
223
224        // The local's own use-site list must not include the field access.
225        let sites = local_sites_at(&locals, TEXT, total_let).expect("on the local's own def");
226        assert!(
227            !sites.contains(&field_span),
228            "field access wrongly counted as a use: {sites:?}"
229        );
230
231        // Semantic-token colouring must not mark the field access as a use.
232        let token_sites = local_token_sites(&locals, TEXT);
233        assert!(
234            !token_sites.contains(&(field_span, false)),
235            "field access wrongly coloured as a local use: {token_sites:?}"
236        );
237    }
238
239    #[test]
240    fn token_sites_mark_definitions_and_uses() {
241        let sites = local_token_sites(&bindings(), TEXT);
242        assert!(
243            sites.iter().any(|(_, decl)| *decl),
244            "has a definition token"
245        );
246        assert!(sites.iter().any(|(_, decl)| !*decl), "has a use token");
247        // The `x` def is a declaration token.
248        assert!(
249            sites.contains(&(Span::new(26, 27), true)),
250            "x def is a declaration: {sites:?}"
251        );
252    }
253
254    // End-to-end against real checker output — the lexer's token spans must
255    // line up with the checker's recorded def spans.
256    #[test]
257    fn resolves_a_real_local_from_diagnose_project() {
258        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
259            .join("../bynkc/tests/fixtures/inlay/clean/src");
260        let r = crate::testkit::diagnose_project(&root);
261        let file = r
262            .files
263            .iter()
264            .find(|f| f.source_path.to_string_lossy().ends_with("util.bynk"))
265            .expect("util.bynk analysed");
266        let text = &file.text;
267        let locals = r
268            .locals
269            .iter()
270            .find(|(p, _)| p.to_string_lossy().ends_with("util.bynk"))
271            .map(|(_, l)| l.clone())
272            .expect("util.bynk locals");
273
274        // `let total = …` then `total` — cursor on the use resolves to def + use.
275        let use_off = text.rfind("total").expect("total use");
276        let sites = local_sites_at(&locals, text, use_off).expect("on a local");
277        assert!(sites.len() >= 2, "def + use: {sites:?}");
278        // The definition is first and is the `let total` name.
279        let def = text.find("total").expect("total def");
280        assert_eq!(sites[0].start, def, "def first");
281    }
282
283    // v0.122 (slice 1): hover renders a local's `let`/`param` prefix and its
284    // inferred type, from the same real checker output.
285    #[test]
286    fn describe_local_renders_kind_and_type() {
287        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
288            .join("../bynkc/tests/fixtures/inlay/clean/src");
289        let r = crate::testkit::diagnose_project(&root);
290        let file = r
291            .files
292            .iter()
293            .find(|f| f.source_path.to_string_lossy().ends_with("util.bynk"))
294            .expect("util.bynk analysed");
295        let text = &file.text;
296        let locals = r
297            .locals
298            .iter()
299            .find(|(p, _)| p.to_string_lossy().ends_with("util.bynk"))
300            .map(|(_, l)| l.clone())
301            .expect("util.bynk locals");
302
303        // A `let` binding — `let total = xs.fold(…)` → `let total: Int`.
304        let total = text.find("total").expect("total def");
305        assert_eq!(
306            describe_local_at(&locals, text, total).as_deref(),
307            Some("```bynk\nlet total: Int\n```")
308        );
309        // A parameter — `fn sum(xs: List[Int])` → `param xs: List[Int]`. Use the
310        // def site so we land on the parameter, not a shadowing local.
311        let xs_param = text.find("xs: List[Int]").expect("xs param");
312        assert_eq!(
313            describe_local_at(&locals, text, xs_param).as_deref(),
314            Some("```bynk\nparam xs: List[Int]\n```")
315        );
316        // Not on a local (the `fn` keyword) → nothing.
317        assert!(describe_local_at(&locals, text, text.find("fn").unwrap()).is_none());
318    }
319
320    // Issue #473: a parameter used *inside* an interpolation hole
321    // (`"… \(name) …"`) must resolve the same as anywhere else. Drives the real
322    // hover / go-to-definition / references resolution against live checker
323    // output — the position→symbol step that was previously blind to holes
324    // because the file lexes the string to one opaque `InterpStr` token.
325
326    // `greet`'s param `name` is used only inside a `\(shout(name))` hole.
327    const HOLE_SRC: &str = "\
328commons demo.text
329
330fn shout(s: String) -> String {
331  s
332}
333
334fn greet(name: String) -> String {
335  \"Hi, \\(shout(name))!\"
336}
337";
338
339    /// `(text, locals)` for `demo/text.bynk` after a real project analysis.
340    fn analyse_hole_fixture(test_name: &str) -> (String, Vec<LocalBinding>) {
341        let root = std::env::temp_dir().join(format!(
342            "bynk-locals-hole-{test_name}-{}",
343            std::process::id()
344        ));
345        let _ = std::fs::remove_dir_all(&root);
346        let file = root.join("demo/text.bynk");
347        std::fs::create_dir_all(file.parent().unwrap()).expect("create dirs");
348        std::fs::write(&file, HOLE_SRC).expect("write fixture");
349        let root = root.canonicalize().unwrap_or(root);
350
351        let r = crate::testkit::diagnose_project(&root);
352        let text = r
353            .files
354            .iter()
355            .find(|f| f.source_path.to_string_lossy().ends_with("text.bynk"))
356            .expect("text.bynk analysed")
357            .text
358            .clone();
359        let locals = r
360            .locals
361            .iter()
362            .find(|(p, _)| p.to_string_lossy().ends_with("text.bynk"))
363            .map(|(_, l)| l.clone())
364            .expect("text.bynk locals");
365        (text, locals)
366    }
367
368    /// The byte offset of the `n`th occurrence of `needle` in `text`.
369    fn nth_offset(text: &str, needle: &str, n: usize) -> usize {
370        text.match_indices(needle).nth(n).expect("occurrence").0
371    }
372
373    #[test]
374    fn hover_describes_a_param_inside_a_hole() {
375        let (text, locals) = analyse_hole_fixture("hover");
376        // 2nd `name`: the use inside `\(shout(name))` (1st is the declaration).
377        let in_hole = nth_offset(&text, "name", 1) + 1; // mid-identifier
378        assert_eq!(
379            describe_local_at(&locals, &text, in_hole).as_deref(),
380            Some("```bynk\nparam name: String\n```"),
381            "hover inside the hole renders the param summary"
382        );
383    }
384
385    #[test]
386    fn definition_of_a_param_resolves_from_inside_a_hole() {
387        let (text, locals) = analyse_hole_fixture("def");
388        let in_hole = nth_offset(&text, "name", 1) + 1;
389        let def = local_definition_at(&locals, &text, in_hole).expect("resolves to a def");
390        let decl = nth_offset(&text, "name", 0); // the parameter declaration
391        assert_eq!(def.start, decl, "def points at the `name` parameter");
392    }
393
394    #[test]
395    fn references_include_a_param_use_inside_a_hole() {
396        let (text, locals) = analyse_hole_fixture("refs");
397        let decl = nth_offset(&text, "name", 0);
398        let sites = local_sites_at(&locals, &text, decl).expect("on the param");
399        let in_hole = nth_offset(&text, "name", 1);
400        assert!(
401            sites.iter().any(|s| s.start <= in_hole && in_hole < s.end),
402            "references include the in-hole use at {in_hole}; got {sites:?}"
403        );
404    }
405}