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