Skip to main content

bynkc_lsp/
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 bynk_check::locals::{LocalBinding, binding_at_def, locals_at};
12use bynk_syntax::lexer::{self, TokenKind};
13use bynk_syntax::span::Span;
14
15/// The identifier-token name covering `offset`, if any.
16fn ident_at(text: &str, offset: usize) -> Option<(&str, Span)> {
17    let toks = lexer::tokenize(text).ok()?;
18    toks.into_iter()
19        .find(|t| t.kind == TokenKind::Ident && t.span.start <= offset && offset <= t.span.end)
20        .map(|t| (&text[t.span.start..t.span.end], t.span))
21}
22
23/// The binding the cursor refers to — whether it sits on the definition name
24/// or on a use — within `locals` (a file's bindings).
25fn target_at<'a>(
26    locals: &'a [LocalBinding],
27    text: &str,
28    offset: usize,
29) -> Option<&'a LocalBinding> {
30    let (name, _) = ident_at(text, offset)?;
31    binding_at_def(locals, offset)
32        .filter(|b| b.name == name)
33        .or_else(|| {
34            locals_at(locals, offset)
35                .into_iter()
36                .find(|b| b.name == name)
37        })
38}
39
40/// All sites of the local under the cursor — its definition first, then every
41/// use that resolves to it (shadowing-safe). `None` when the cursor is not on
42/// a local.
43pub fn local_sites_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Vec<Span>> {
44    let target = target_at(locals, text, offset)?;
45    let toks = lexer::tokenize(text).ok()?;
46    let mut sites = vec![target.def_span];
47    for t in &toks {
48        if t.kind != TokenKind::Ident || text[t.span.start..t.span.end] != target.name {
49            continue;
50        }
51        if t.span == target.def_span {
52            continue; // the definition, already added
53        }
54        // A binding's own def token is not a use of anything.
55        if locals.iter().any(|b| b.def_span == t.span) {
56            continue;
57        }
58        if t.span.start < target.scope.start || t.span.end > target.scope.end {
59            continue; // outside the binding's scope
60        }
61        // Does this use resolve to `target` (not a shadowing inner binding)?
62        let resolves = locals_at(locals, t.span.start)
63            .into_iter()
64            .find(|b| b.name == target.name)
65            .map(|b| b.def_span);
66        if resolves == Some(target.def_span) {
67            sites.push(t.span);
68        }
69    }
70    Some(sites)
71}
72
73/// The definition site of the local under the cursor, if any.
74pub fn local_definition_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Span> {
75    target_at(locals, text, offset).map(|b| b.def_span)
76}
77
78/// Every local-binding occurrence in the file — `(span, is_definition)` — for
79/// semantic-token colouring. A token is a definition if it sits on a binding's
80/// def span, else a use if it resolves to a local in scope at that point.
81pub fn local_token_sites(locals: &[LocalBinding], text: &str) -> Vec<(Span, bool)> {
82    let Ok(toks) = lexer::tokenize(text) else {
83        return Vec::new();
84    };
85    let mut out = Vec::new();
86    for t in &toks {
87        if t.kind != TokenKind::Ident {
88            continue;
89        }
90        let name = &text[t.span.start..t.span.end];
91        if locals.iter().any(|b| b.def_span == t.span) {
92            out.push((t.span, true)); // a binding's def
93        } else if locals_at(locals, t.span.start)
94            .into_iter()
95            .any(|b| b.name == name)
96        {
97            out.push((t.span, false)); // a use that resolves to a local
98        }
99    }
100    out
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    // `fn f(n: Int) -> Int { let x = n  <uses> }` laid out so offsets are easy.
108    fn bindings() -> Vec<LocalBinding> {
109        // text: see `TEXT`; n: param scope over body, x: let scope after its stmt.
110        vec![
111            LocalBinding {
112                name: "n".into(),
113                def_span: Span { start: 5, end: 6 },
114                ty: "Int".into(),
115                scope: Span { start: 20, end: 60 },
116            },
117            LocalBinding {
118                name: "x".into(),
119                def_span: Span { start: 26, end: 27 },
120                ty: "Int".into(),
121                scope: Span { start: 34, end: 60 },
122            },
123        ]
124    }
125
126    const TEXT: &str = "fn f(n: Int) -> Int { let x = n\n  x + x\n}";
127    //                   0         1         2         3
128    //                   0123456789012345678901234567890123456789
129
130    #[test]
131    fn sites_for_a_use_collect_def_plus_uses() {
132        let locals = bindings();
133        // Cursor on the first `x` use (offset 36, in `  x + x`).
134        let x_use = TEXT.match_indices('x').nth(1).unwrap().0; // first use of x
135        let sites = local_sites_at(&locals, TEXT, x_use).expect("on a local");
136        assert!(
137            sites.contains(&Span { start: 26, end: 27 }),
138            "includes def: {sites:?}"
139        );
140        assert!(sites.len() >= 2, "def + at least one use: {sites:?}");
141    }
142
143    #[test]
144    fn definition_resolves_from_a_use() {
145        let locals = bindings();
146        let n_use = TEXT.rfind('n').unwrap(); // the `n` in `let x = n`
147        assert_eq!(
148            local_definition_at(&locals, TEXT, n_use),
149            Some(Span { start: 5, end: 6 })
150        );
151    }
152
153    #[test]
154    fn not_on_a_local_yields_none() {
155        let locals = bindings();
156        assert!(local_sites_at(&locals, TEXT, 0).is_none()); // on `fn`
157    }
158
159    #[test]
160    fn token_sites_mark_definitions_and_uses() {
161        let sites = local_token_sites(&bindings(), TEXT);
162        assert!(
163            sites.iter().any(|(_, decl)| *decl),
164            "has a definition token"
165        );
166        assert!(sites.iter().any(|(_, decl)| !*decl), "has a use token");
167        // The `x` def is a declaration token.
168        assert!(
169            sites.contains(&(Span { start: 26, end: 27 }, true)),
170            "x def is a declaration: {sites:?}"
171        );
172    }
173
174    // End-to-end against real checker output — the lexer's token spans must
175    // line up with the checker's recorded def spans.
176    #[test]
177    fn resolves_a_real_local_from_diagnose_project() {
178        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
179            .join("../bynkc/tests/fixtures/inlay/clean/src");
180        let r = bynk_ide::diagnose_project(&root, &std::collections::HashMap::new());
181        let file = r
182            .files
183            .iter()
184            .find(|f| f.source_path.to_string_lossy().ends_with("util.bynk"))
185            .expect("util.bynk analysed");
186        let text = &file.text;
187        let locals = r
188            .locals
189            .iter()
190            .find(|(p, _)| p.to_string_lossy().ends_with("util.bynk"))
191            .map(|(_, l)| l.clone())
192            .expect("util.bynk locals");
193
194        // `let total = …` then `total` — cursor on the use resolves to def + use.
195        let use_off = text.rfind("total").expect("total use");
196        let sites = local_sites_at(&locals, text, use_off).expect("on a local");
197        assert!(sites.len() >= 2, "def + use: {sites:?}");
198        // The definition is first and is the `let total` name.
199        let def = text.find("total").expect("total def");
200        assert_eq!(sites[0].start, def, "def first");
201    }
202}