1use 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
16fn 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
36fn 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
53pub fn local_sites_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Vec<Span>> {
57 let target = target_at(locals, text, offset)?;
58 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; }
68 if locals.iter().any(|b| b.def_span == t.span) {
70 continue;
71 }
72 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; }
80 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
92pub 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
97pub 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
112pub fn local_token_sites(locals: &[LocalBinding], text: &str) -> Vec<(Span, bool)> {
116 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)); } 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 out.push((t.span, false)); }
138 }
139 out
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 fn bindings() -> Vec<LocalBinding> {
148 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 #[test]
172 fn sites_for_a_use_collect_def_plus_uses() {
173 let locals = bindings();
174 let x_use = TEXT.match_indices('x').nth(1).unwrap().0; 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(); 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()); }
199
200 #[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 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 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 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 assert!(
249 sites.contains(&(Span::new(26, 27), true)),
250 "x def is a declaration: {sites:?}"
251 );
252 }
253
254 #[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 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 let def = text.find("total").expect("total def");
280 assert_eq!(sites[0].start, def, "def first");
281 }
282
283 #[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 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 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 assert!(describe_local_at(&locals, text, text.find("fn").unwrap()).is_none());
318 }
319
320 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 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 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 let in_hole = nth_offset(&text, "name", 1) + 1; 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); 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}