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 same_range(a: Span, b: Span) -> bool {
21 a.start == b.start && a.end == b.end
22}
23
24fn 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
44fn 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
61pub fn local_sites_at(locals: &[LocalBinding], text: &str, offset: usize) -> Option<Vec<Span>> {
65 let target = target_at(locals, text, offset)?;
66 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; }
76 if locals.iter().any(|b| same_range(b.def_span, t.span)) {
78 continue;
79 }
80 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; }
88 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
100pub 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
105pub 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
120pub fn local_token_sites(locals: &[LocalBinding], text: &str) -> Vec<(Span, bool)> {
124 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)); } 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 out.push((t.span, false)); }
146 }
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn bindings() -> Vec<LocalBinding> {
156 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 #[test]
180 fn sites_for_a_use_collect_def_plus_uses() {
181 let locals = bindings();
182 let x_use = TEXT.match_indices('x').nth(1).unwrap().0; 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(); 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()); }
207
208 #[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 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 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 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 assert!(
257 sites.contains(&(Span::new(26, 27), true)),
258 "x def is a declaration: {sites:?}"
259 );
260 }
261
262 #[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 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 let def = text.find("total").expect("total def");
288 assert_eq!(sites[0].start, def, "def first");
289 }
290
291 #[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 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 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 assert!(describe_local_at(&locals, text, text.find("fn").unwrap()).is_none());
326 }
327
328 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 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 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 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 let in_hole = nth_offset(&text, "name", 1) + 1; 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); 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 const STEPS_SRC: &str =
425 "commons demo.steps\n\nfn total(r: Int) -> Int {\n let plain = r\n plain\n}\n";
426
427 #[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 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 assert_eq!(sites.len(), 4, "two defs + two uses: {sites:?}");
462 }
463
464 #[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}