1use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use serde::Serialize;
11
12use crate::engine::{self, Defect, Symbol};
13use crate::registry::{self, Grammar};
14use ignore::WalkBuilder;
15
16pub fn read(path: &Path) -> Result<Vec<u8>> {
18 std::fs::read(path).with_context(|| format!("reading {}", path.display()))
19}
20
21pub fn rel(path: &Path) -> String {
23 std::env::current_dir()
24 .ok()
25 .zip(path.canonicalize().ok())
26 .and_then(|(cwd, p)| p.strip_prefix(&cwd).ok().map(|r| r.display().to_string()))
27 .unwrap_or_else(|| path.display().to_string())
28}
29
30fn is_generated_decl(path: &Path) -> bool {
39 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
40 return false;
41 };
42 name.ends_with(".d.ts") || name.ends_with(".d.cts") || name.ends_with(".d.mts")
43}
44
45fn for_each_source(dir: &Path, mut f: impl FnMut(&Grammar, &str, &[u8]) -> Result<()>) -> Result<()> {
49 for entry in WalkBuilder::new(dir).build() {
50 let entry = match entry {
51 Ok(e) => e,
52 Err(_) => continue,
53 };
54 let path = entry.path();
55 if !path.is_file() || !registry::is_source(path) || is_generated_decl(path) {
56 continue;
57 }
58 let grammar = registry::for_path(path)?;
59 let src = read(path)?;
60 f(&grammar, &rel(path), &src)?;
61 }
62 Ok(())
63}
64
65fn kind_matches(sym_kind: &str, filter: &str) -> bool {
72 sym_kind == filter
73 || (matches!(filter, "struct" | "union" | "record") && sym_kind == "class")
74}
75
76fn name_matches(sym_name: &str, query_lc: &str, contains: bool) -> bool {
83 let sym_lc = sym_name.to_lowercase();
84 if contains {
85 sym_lc.contains(query_lc)
86 } else {
87 sym_lc == query_lc
88 }
89}
90
91pub fn outline(file: &Path, kind: Option<&str>) -> Result<Vec<Symbol>> {
101 let grammar = registry::for_path(file)?;
102 let src = read(file)?;
103 let mut syms = engine::extract(&grammar, &rel(file), &src)?;
104 syms.retain(|s| s.is_definition && kind.is_none_or(|k| kind_matches(&s.kind, k)));
105 Ok(syms)
106}
107
108pub fn project(syms: &[Symbol], detail: u8) -> serde_json::Value {
112 use serde_json::{Map, Value};
113 if detail >= 2 {
114 return serde_json::to_value(syms).unwrap_or(Value::Null);
115 }
116 let arr = syms
117 .iter()
118 .map(|s| {
119 let mut m = Map::new();
120 if detail >= 1 {
121 m.insert("id".into(), s.id.clone().into());
122 }
123 m.insert("kind".into(), s.kind.clone().into());
124 m.insert("name".into(), s.name.clone().into());
125 if let Some(p) = &s.parent {
126 m.insert("parent".into(), p.clone().into());
127 }
128 m.insert("line".into(), s.line.into());
129 if detail >= 1 {
130 m.insert("col".into(), s.col.into());
131 m.insert("signature".into(), s.signature.clone().into());
132 }
133 Value::Object(m)
134 })
135 .collect();
136 Value::Array(arr)
137}
138
139pub fn symbols(
154 dir: &Path,
155 kind: Option<&str>,
156 name: Option<&str>,
157 refs: bool,
158 name_contains: bool,
159) -> Result<Vec<Symbol>> {
160 let name_lc = name.map(str::to_lowercase);
161 let mut all = Vec::new();
162 for_each_source(dir, |grammar, relpath, src| {
163 for s in engine::extract(grammar, relpath, src)? {
164 if !refs && !s.is_definition {
165 continue;
166 }
167 if kind.is_some_and(|k| !kind_matches(&s.kind, k)) {
168 continue;
169 }
170 if name_lc
171 .as_ref()
172 .is_some_and(|n| !name_matches(&s.name, n, name_contains))
173 {
174 continue;
175 }
176 all.push(s);
177 }
178 Ok(())
179 })?;
180 Ok(all)
181}
182
183#[derive(Debug, Serialize)]
186pub struct SourceResult {
187 pub id: String,
188 pub source: String,
189 #[serde(skip_serializing_if = "Vec::is_empty")]
190 pub other_candidates: Vec<String>,
191}
192
193pub fn source(id_or_file: &str, name: Option<&str>) -> Result<SourceResult> {
205 let (file, want, want_line): (PathBuf, String, Option<usize>) = match name {
206 Some(n) => (PathBuf::from(id_or_file), n.to_string(), None),
207 None => {
208 let rest = id_or_file.split_once(':').map_or(id_or_file, |(_, r)| r);
209 let (path, after) = rest
210 .split_once('#')
211 .context("symbol id must look like <lang>:<path>#<name>@<line>")?;
212 let (name, line) = match after.split_once('@') {
215 Some((n, r)) => (n.to_string(), r.parse::<usize>().ok()),
216 None => (after.to_string(), None),
217 };
218 (PathBuf::from(path), name, line)
219 }
220 };
221
222 let grammar = registry::for_path(&file)?;
223 let src = read(&file)?;
224 let syms = engine::extract(&grammar, &rel(&file), &src)?;
225 let matches: Vec<&Symbol> = syms
226 .iter()
227 .filter(|s| s.is_definition && s.name == want)
228 .collect();
229
230 let chosen = match want_line.and_then(|r| matches.iter().find(|s| s.line == r)) {
233 Some(c) => *c,
234 None => match matches.first() {
235 None => anyhow::bail!("no definition named `{want}` in {}", file.display()),
236 Some(c) => *c,
237 },
238 };
239 Ok(SourceResult {
240 id: chosen.id.clone(),
241 source: engine::slice(&src, chosen).to_string(),
242 other_candidates: matches
243 .iter()
244 .filter(|s| s.id != chosen.id)
245 .map(|s| s.id.clone())
246 .collect(),
247 })
248}
249
250pub fn check(file: &Path) -> Result<Vec<Defect>> {
258 let grammar = registry::for_path(file)?;
259 let src = read(file)?;
260 engine::check(&grammar, &src)
261}
262
263#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)]
269#[serde(rename_all = "lowercase")]
270pub enum CallSource {
271 Structural,
272 Textual,
273}
274
275#[derive(Debug, Serialize)]
277pub struct CallSite {
278 pub file: String,
279 pub line: usize,
281 pub col: usize,
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub in_function: Option<String>,
285 pub text: String,
287 pub source: CallSource,
289}
290
291pub fn callers(dir: &Path, name: &str) -> Result<Vec<CallSite>> {
306 let mut out = Vec::new();
307 for_each_source(dir, |grammar, relpath, src| {
308 let (syms, tree) = engine::extract_with_tree(grammar, relpath, src)?;
311 let root = tree.root_node();
312
313 let structurals: Vec<&Symbol> = syms
319 .iter()
320 .filter(|s| !s.is_definition && s.name == name)
321 .collect();
322 let mut skip_lines: std::collections::HashSet<usize> =
327 std::collections::HashSet::new();
328 for s in &structurals {
329 skip_lines.insert(s.line.saturating_sub(1));
330 }
331 for s in syms.iter().filter(|s| s.is_definition && s.name == name) {
332 skip_lines.insert(s.line.saturating_sub(1));
333 }
334 for s in &structurals {
335 out.push(CallSite {
336 in_function: engine::enclosing_function_at(
337 root,
338 s.start_byte,
339 src,
340 &grammar.profile,
341 ),
342 file: s.file.clone(),
343 line: s.line,
344 col: s.col,
345 text: s.signature.clone(),
346 source: CallSource::Structural,
347 });
348 }
349
350 let src_str = std::str::from_utf8(src).unwrap_or("");
355 for (row, line_text) in src_str.lines().enumerate() {
356 if skip_lines.contains(&row) {
357 continue; }
359 if !has_whole_word(line_text, name) {
360 continue;
361 }
362 let col = line_text.find(name).unwrap_or(0);
364 let byte = line_offset(src, row) + col;
365 out.push(CallSite {
366 in_function: engine::enclosing_function_at(root, byte, src, &grammar.profile),
367 file: relpath.to_string(),
368 line: row + 1,
369 col: col + 1,
370 text: line_text.trim().to_string(),
371 source: CallSource::Textual,
372 });
373 }
374
375 Ok(())
376 })?;
377 Ok(out)
378}
379
380fn line_offset(source: &[u8], row: usize) -> usize {
382 let mut off = 0;
383 for _ in 0..row {
384 match source[off..].iter().position(|&b| b == b'\n') {
385 Some(pos) => off += pos + 1,
386 None => return source.len(),
387 }
388 }
389 off
390}
391
392fn has_whole_word(haystack: &str, needle: &str) -> bool {
397 let needle_bytes = needle.as_bytes();
398 let haystack_bytes = haystack.as_bytes();
399 if needle_bytes.is_empty() {
400 return false;
401 }
402 let mut i = 0;
403 while i + needle_bytes.len() <= haystack_bytes.len() {
404 if &haystack_bytes[i..i + needle_bytes.len()] == needle_bytes {
405 let before_ok = i == 0 || !is_ident_char(haystack_bytes[i - 1]);
406 let after_ok =
407 i + needle_bytes.len() == haystack_bytes.len()
408 || !is_ident_char(haystack_bytes[i + needle_bytes.len()]);
409 if before_ok && after_ok {
410 return true;
411 }
412 }
413 i += 1;
416 }
417 false
418}
419
420fn is_ident_char(b: u8) -> bool {
421 b.is_ascii_alphanumeric() || b == b'_'
422}
423
424pub fn parse_pos(s: &str) -> Result<(PathBuf, usize, usize)> {
429 let parts: Vec<&str> = s.rsplitn(3, ':').collect();
430 match parts.as_slice() {
431 [col, line, file] => {
432 let line: usize = line.parse().map_err(|_| anyhow::anyhow!("bad line in `{s}`"))?;
433 let col: usize = col.parse().map_err(|_| anyhow::anyhow!("bad col in `{s}`"))?;
434 Ok((
435 PathBuf::from(file),
436 line.saturating_sub(1),
437 col.saturating_sub(1),
438 ))
439 }
440 _ => anyhow::bail!("expected file:line:col, got `{s}`"),
441 }
442}
443
444#[derive(Debug, Serialize)]
446pub struct MapEntry {
447 pub id: String,
448 pub kind: String,
449 pub name: String,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub parent: Option<String>,
452 pub row: usize,
453 pub signature: String,
454 #[serde(skip_serializing_if = "Vec::is_empty")]
456 pub references: Vec<String>,
457}
458
459#[derive(Debug, Serialize)]
461pub struct FileMap {
462 pub file: String,
463 pub entries: Vec<MapEntry>,
464}
465
466pub fn map(
472 dir: &Path,
473 kind: Option<&str>,
474 name: Option<&str>,
475 name_contains: bool,
476) -> Result<Vec<FileMap>> {
477 let name_lc = name.map(str::to_lowercase);
478 let mut file_maps = Vec::new();
479 for_each_source(dir, |grammar, relpath, src| {
480 let syms = engine::extract(grammar, relpath, src)?;
481
482 let mut defs: Vec<usize> = syms
486 .iter()
487 .enumerate()
488 .filter(|(_, s)| s.is_definition)
489 .filter(|(_, s)| kind.is_none_or(|k| kind_matches(&s.kind, k)))
490 .filter(|(_, s)| name_lc.as_ref().is_none_or(|n| name_matches(&s.name, n, name_contains)))
491 .map(|(i, _)| i)
492 .collect();
493 defs.sort_by_key(|&i| syms[i].end_byte - syms[i].start_byte);
494
495 let mut ref_map: std::collections::HashMap<usize, Vec<String>> =
497 std::collections::HashMap::new();
498 for s in syms.iter() {
499 if s.is_definition {
500 continue;
501 }
502 for &d in &defs {
503 if s.start_byte >= syms[d].start_byte && s.end_byte <= syms[d].end_byte {
504 ref_map.entry(d).or_default().push(s.name.clone());
505 break; }
507 }
508 }
509
510 for names in ref_map.values_mut() {
512 names.sort();
513 names.dedup();
514 }
515
516 let mut entries: Vec<MapEntry> = defs
518 .iter()
519 .map(|&d| {
520 let s = &syms[d];
521 let mut refs = ref_map.remove(&d).unwrap_or_default();
522 refs.retain(|n| n != &s.name);
524 MapEntry {
525 id: s.id.clone(),
526 kind: s.kind.clone(),
527 name: s.name.clone(),
528 parent: s.parent.clone(),
529 row: s.line,
530 signature: s.signature.clone(),
531 references: refs,
532 }
533 })
534 .collect();
535 entries.sort_by_key(|e| e.row);
536
537 if !entries.is_empty() {
538 file_maps.push(FileMap {
539 file: relpath.to_string(),
540 entries,
541 });
542 }
543 Ok(())
544 })?;
545 file_maps.sort_by(|a, b| a.file.cmp(&b.file));
546 Ok(file_maps)
547}
548
549pub fn definition(dir: &Path, name: &str) -> Result<Vec<Symbol>> {
559 let mut defs = symbols(dir, None, Some(name), false, false)?;
562 defs.retain(|s| s.name == name);
563 Ok(defs)
564}
565
566pub fn definition_at(file: &Path, row: usize, col: usize, dir: &Path) -> Result<(String, Vec<Symbol>)> {
571 let grammar = registry::for_path(file)?;
572 let src = read(file)?;
573 let name = engine::with_tree(&grammar, &src, |root, profile| {
574 engine::identifier_at(root, row, col, &src, profile)
575 })?
576 .with_context(|| format!("no identifier at {}:{row}:{col}", file.display()))?;
577 if let Some(local) = engine::resolve_local_at(&grammar, &rel(file), &src, row, col)? {
583 return Ok((name, vec![local]));
584 }
585 let imported = resolve_import_at(file, &name, dir, &grammar, &src)?;
589 if !imported.is_empty() {
590 return Ok((name, imported));
591 }
592 let defs = definition(dir, &name)?;
593 Ok((name, defs))
594}
595
596fn resolve_import_at(
603 file: &Path,
604 name: &str,
605 dir: &Path,
606 grammar: &Grammar,
607 src: &[u8],
608) -> Result<Vec<Symbol>> {
609 let Some(strategy) = grammar.profile.import_resolution.as_deref() else {
610 return Ok(Vec::new());
611 };
612 let Some(binding) = engine::extract_imports(grammar, src)?
613 .into_iter()
614 .find(|b| b.name == name)
615 else {
616 return Ok(Vec::new());
617 };
618 for cand in import_candidate_paths(strategy, &binding.module, file, dir) {
619 if !cand.is_file() {
620 continue;
621 }
622 let tsrc = read(&cand)?;
623 let trel = rel(&cand);
624 let defs: Vec<Symbol> = engine::extract(grammar, &trel, &tsrc)?
625 .into_iter()
626 .filter(|s| s.is_definition && s.name == binding.source)
627 .collect();
628 if !defs.is_empty() {
629 return Ok(defs);
630 }
631 }
632 Ok(Vec::new())
633}
634
635fn import_candidate_paths(
639 strategy: &str,
640 module: &str,
641 current_file: &Path,
642 dir: &Path,
643) -> Vec<PathBuf> {
644 match strategy {
645 "dotted_package" => {
649 let dots = module.chars().take_while(|&c| c == '.').count();
650 let rest = &module[dots..];
651 let parts: Vec<&str> = rest.split('.').filter(|p| !p.is_empty()).collect();
652 let mut base = if dots == 0 {
653 dir.to_path_buf()
654 } else {
655 let mut b = current_file.parent().unwrap_or(dir).to_path_buf();
656 for _ in 0..dots.saturating_sub(1) {
657 b = b.parent().map(Path::to_path_buf).unwrap_or(b);
658 }
659 b
660 };
661 for p in &parts {
662 base.push(p);
663 }
664 vec![base.with_extension("py"), base.join("__init__.py")]
665 }
666 "relative_path" => {
670 if !module.starts_with('.') {
671 return Vec::new();
672 }
673 let joined = normalize(¤t_file.parent().unwrap_or(dir).join(module));
674 if joined.extension().is_some() {
675 return vec![joined];
676 }
677 vec![
678 joined.with_extension("js"),
679 joined.with_extension("jsx"),
680 joined.join("index.js"),
681 ]
682 }
683 _ => Vec::new(),
684 }
685}
686
687fn normalize(path: &Path) -> PathBuf {
690 use std::path::Component;
691 let mut out = PathBuf::new();
692 for comp in path.components() {
693 match comp {
694 Component::CurDir => {}
695 Component::ParentDir => {
696 out.pop();
697 }
698 other => out.push(other.as_os_str()),
699 }
700 }
701 out
702}
703
704#[cfg(test)]
705mod tests {
706 use super::*;
707
708 const DUP: &str =
710 "fn run() {\n let _first = 1;\n}\n\nfn run() {\n let _second = 2;\n}\n";
711
712 fn write_temp(tag: &str, contents: &str) -> PathBuf {
713 let mut p = std::env::temp_dir();
714 p.push(format!("grove_src_test_{}_{tag}.rs", std::process::id()));
715 std::fs::write(&p, contents).unwrap();
716 p
717 }
718
719 #[test]
720 fn id_line_selects_that_definition() {
721 let path = write_temp("dup_line", DUP);
722
723 let res = source(&format!("rust:{}#run@5", path.display()), None).unwrap();
725 assert!(res.source.contains("_second"), "line 5 must pick the 2nd run, got: {}", res.source);
726 assert!(res.id.ends_with("@5"), "chosen id should be the line-5 def, got {}", res.id);
727
728 let res0 = source(&format!("rust:{}#run@1", path.display()), None).unwrap();
729 assert!(res0.source.contains("_first"), "line 1 must pick the 1st run, got: {}", res0.source);
730
731 std::fs::remove_file(&path).ok();
732 }
733
734 #[test]
735 fn unmatched_line_falls_back_to_first() {
736 let path = write_temp("dup_fallback", DUP);
737 let res = source(&format!("rust:{}#run@99", path.display()), None).unwrap();
738 assert!(res.source.contains("_first"), "unknown line falls back to the first def");
739 std::fs::remove_file(&path).ok();
740 }
741
742 #[test]
743 fn by_name_returns_first_and_lists_other_candidate() {
744 let path = write_temp("dup_name", DUP);
745 let res = source(path.to_str().unwrap(), Some("run")).unwrap();
746 assert!(res.source.contains("_first"));
747 assert_eq!(res.other_candidates.len(), 1, "the 2nd run is the other candidate");
748 std::fs::remove_file(&path).ok();
749 }
750
751 #[test]
752 fn callers_finds_call_sites_via_profile() {
753 let dir = std::env::temp_dir().join(format!("grove_callers_test_{}", std::process::id()));
756 std::fs::create_dir_all(&dir).unwrap();
757 let file = dir.join("lib.rs");
758 std::fs::write(&file, "fn helper() {}\nfn main() {\n helper();\n}\n").unwrap();
759
760 let sites = callers(&dir, "helper").unwrap();
761 assert_eq!(sites.len(), 1, "exactly one call to helper, got {sites:?}");
762 assert_eq!(sites[0].in_function.as_deref(), Some("main"));
763
764 std::fs::remove_dir_all(&dir).ok();
765 }
766
767 #[test]
768 fn callers_parses_each_file_once() {
769 let dir = std::env::temp_dir().join(format!("grove_parse_once_test_{}", std::process::id()));
772 std::fs::create_dir_all(&dir).unwrap();
773 std::fs::write(dir.join("a.rs"), "fn main() {\n helper();\n}\n").unwrap();
776 std::fs::write(dir.join("b.rs"), "fn run() {\n helper();\n}\n").unwrap();
777 std::fs::write(dir.join("c.rs"), "fn unrelated() {}\n").unwrap();
778
779 engine::parse_counter::reset();
780 let sites = callers(&dir, "helper").unwrap();
781 let parses = engine::parse_counter::get();
782
783 assert_eq!(sites.len(), 2, "two call sites, got {sites:?}");
784 assert_eq!(parses, 3, "expected one parse per source file (3), got {parses}");
785
786 std::fs::remove_dir_all(&dir).ok();
787 }
788
789 #[test]
790 fn callers_includes_type_and_impl_references() {
791 let dir = std::env::temp_dir().join(format!("grove_callers_type_test_{}", std::process::id()));
795 std::fs::create_dir_all(&dir).unwrap();
796 std::fs::write(
801 dir.join("lib.rs"),
802 "struct Thing;\nimpl Clone for Thing {}\n",
803 ).unwrap();
804 let sites = callers(&dir, "Clone").unwrap();
805 let structural = sites.iter().filter(|s| s.source == CallSource::Structural).count();
808 assert!(structural >= 1, "should find impl reference to Clone, got {sites:?}");
809 assert!(sites.iter().any(|s| s.in_function.is_none()), "impl is top-level, got {sites:?}");
811 std::fs::remove_dir_all(&dir).ok();
812 }
813
814 #[test]
815 fn callers_textual_fallback_finds_untagged_references() {
816 let dir = std::env::temp_dir().join(format!("grove_callers_textual_test_{}", std::process::id()));
819 std::fs::create_dir_all(&dir).unwrap();
820 std::fs::write(
823 dir.join("lib.rs"),
824 "fn go(s: Scanner) { let x: Scanner = s; }\n",
825 ).unwrap();
826 let sites = callers(&dir, "Scanner").unwrap();
827 let textual: Vec<&CallSite> = sites.iter().filter(|s| s.source == CallSource::Textual).collect();
828 assert!(!textual.is_empty(), "textual fallback should find type-annotation references to Scanner, got {sites:?}");
831 assert_eq!(textual.len(), 1, "one textual line containing Scanner, got {textual:?}");
834 std::fs::remove_dir_all(&dir).ok();
835 }
836
837 #[test]
838 fn callers_excludes_definition_from_textual() {
839 let dir = std::env::temp_dir().join(format!("grove_callers_nodef_test_{}", std::process::id()));
842 std::fs::create_dir_all(&dir).unwrap();
843 std::fs::write(
844 dir.join("lib.rs"),
845 "fn helper() {}\nfn main() { helper(); }\n",
846 ).unwrap();
847 let sites = callers(&dir, "helper").unwrap();
848 assert!(!sites.iter().any(|s| s.line == 1), "definition line should not be in callers results, got {sites:?}");
850 assert!(sites.iter().any(|s| s.line == 2), "call site line should be in callers results, got {sites:?}");
852 std::fs::remove_dir_all(&dir).ok();
853 }
854
855 #[test]
856 fn has_whole_word_finds_identifier_names() {
857 assert!(has_whole_word(" helper()", "helper"));
858 assert!(has_whole_word("fn helper() {}", "helper"));
859 assert!(has_whole_word("s: Scanner", "Scanner"));
860 assert!(has_whole_word("Scanner::new()", "Scanner"));
861 assert!(has_whole_word("use crate::Scanner;", "Scanner"));
862 assert!(!has_whole_word("helper_fn()", "helper"));
864 assert!(!has_whole_word("myhelper()", "helper"));
865 assert!(!has_whole_word("MyScanner", "Scanner"));
866 assert!(!has_whole_word("scanner_new", "Scanner"));
867 assert!(!has_whole_word("anything", ""));
869 assert!(!has_whole_word("ab", "abc"));
871 assert!(has_whole_word("helper", "helper"));
873 }
874
875 #[test]
878 fn parse_pos_parses_file_line_col() {
879 let (file, row, col) = parse_pos("src/lib.rs:12:4").unwrap();
881 assert_eq!(file, PathBuf::from("src/lib.rs"));
882 assert_eq!((row, col), (11, 3));
883 }
884
885 #[test]
886 fn parse_pos_keeps_colons_in_the_path() {
887 let (file, row, col) = parse_pos("a:b/file.rs:3:7").unwrap();
890 assert_eq!(file, PathBuf::from("a:b/file.rs"));
891 assert_eq!((row, col), (2, 6));
892 }
893
894 #[test]
895 fn parse_pos_rejects_bad_shapes() {
896 assert!(parse_pos("no-colons").is_err());
897 assert!(parse_pos("file.rs:notarow:4").unwrap_err().to_string().contains("bad line"));
898 assert!(parse_pos("file.rs:4:notacol").unwrap_err().to_string().contains("bad col"));
899 }
900
901 #[test]
904 fn project_tiers_control_field_density() {
905 let dir = std::env::temp_dir().join(format!("grove_project_test_{}", std::process::id()));
906 std::fs::create_dir_all(&dir).unwrap();
907 let file = dir.join("lib.rs");
908 std::fs::write(&file, "struct S;\nimpl S {\n fn m(&self) {}\n}\n").unwrap();
909 let syms = outline(&file, None).unwrap();
910
911 let terse = project(&syms, 0);
912 let first = &terse.as_array().unwrap()[0];
913 assert!(first.get("id").is_none(), "detail 0 omits id");
914 assert!(first.get("signature").is_none(), "detail 0 omits signature");
915 assert!(first.get("kind").is_some() && first.get("name").is_some());
916
917 let default = project(&syms, 1);
918 let d0 = &default.as_array().unwrap()[0];
919 assert!(d0.get("id").is_some(), "detail 1 adds id");
920 assert!(d0.get("signature").is_some(), "detail 1 adds signature");
921 assert!(d0.get("start_byte").is_none(), "detail 1 drops byte offsets");
922
923 let full = project(&syms, 2);
924 let f0 = &full.as_array().unwrap()[0];
925 assert!(f0.get("start_byte").is_some(), "detail 2 includes byte offsets");
926
927 std::fs::remove_dir_all(&dir).ok();
928 }
929
930 #[test]
933 fn outline_filters_by_kind_and_skips_references() {
934 let dir = std::env::temp_dir().join(format!("grove_outline_test_{}", std::process::id()));
935 std::fs::create_dir_all(&dir).unwrap();
936 let file = dir.join("lib.rs");
937 std::fs::write(&file, "struct S;\nfn f() {\n g();\n}\n").unwrap();
938
939 let all = outline(&file, None).unwrap();
940 assert!(all.iter().all(|s| s.is_definition), "outline yields definitions only");
941 assert!(all.iter().any(|s| s.name == "S"));
942 assert!(all.iter().any(|s| s.name == "f"));
943
944 let classes = outline(&file, Some("class")).unwrap();
946 assert!(classes.iter().all(|s| s.kind == "class"));
947 assert!(classes.iter().any(|s| s.name == "S"));
948 assert!(!classes.iter().any(|s| s.name == "f"), "kind filter excludes fns");
949
950 let structs = outline(&file, Some("struct")).unwrap();
952 assert!(structs.iter().any(|s| s.name == "S"), "struct aliases to class");
953 assert!(!structs.iter().any(|s| s.name == "f"));
954
955 std::fs::remove_dir_all(&dir).ok();
956 }
957
958 #[test]
959 fn is_generated_decl_flags_typescript_declaration_files() {
960 assert!(is_generated_decl(Path::new("src/compiler/scanner.d.ts")));
965 assert!(is_generated_decl(Path::new("tests/baselines/reference/api/typescript.d.ts")));
966 assert!(is_generated_decl(Path::new("declarations/LoaderContext.d.ts")));
967 assert!(is_generated_decl(Path::new("pkg/index.d.cts")));
968 assert!(is_generated_decl(Path::new("pkg/index.d.mts")));
969
970 assert!(!is_generated_decl(Path::new("src/compiler/scanner.ts")));
972 assert!(!is_generated_decl(Path::new("lib/Compiler.js")));
973 assert!(!is_generated_decl(Path::new("types.ts")), "`types.ts` is real source, not `types.d.ts`");
974 assert!(!is_generated_decl(Path::new("README.md")));
975 assert!(!is_generated_decl(Path::new("no_extension")));
976 }
977
978 #[test]
979 fn symbols_skips_generated_declaration_files() {
980 let dir = std::env::temp_dir().join(format!("grove_nodecl_test_{}", std::process::id()));
989 std::fs::create_dir_all(dir.join("declarations")).unwrap();
990 std::fs::write(dir.join("declarations/LoaderContext.d.ts"), "export class LoaderContext {}").unwrap();
991 std::fs::write(dir.join("lib.js"), "class Compiler {}").unwrap();
992
993 let defs = symbols(&dir, None, Some("Compiler"), false, false).unwrap();
994 assert!(defs.iter().any(|s| s.name == "Compiler"), "real source is indexed");
995 assert!(!defs.iter().any(|s| s.name == "LoaderContext"), "generated decl is skipped");
999
1000 std::fs::remove_dir_all(&dir).ok();
1001 }
1002
1003 #[test]
1004 fn kind_matches_exact_and_struct_synonyms() {
1005 assert!(kind_matches("class", "class"));
1006 assert!(kind_matches("class", "struct"), "struct → class");
1007 assert!(kind_matches("class", "union"), "union → class");
1008 assert!(kind_matches("function", "function"));
1009 assert!(!kind_matches("function", "struct"), "synonyms only widen onto class");
1010 assert!(!kind_matches("variable", "class"));
1011 }
1012
1013 #[test]
1014 fn symbols_honors_name_kind_and_refs_filters() {
1015 let dir = std::env::temp_dir().join(format!("grove_symbols_test_{}", std::process::id()));
1016 std::fs::create_dir_all(&dir).unwrap();
1017 std::fs::write(dir.join("lib.rs"), "fn alpha() {}\nfn beta() {\n alpha();\n}\n").unwrap();
1018
1019 let defs = symbols(&dir, None, None, false, false).unwrap();
1021 assert!(defs.iter().all(|s| s.is_definition));
1022
1023 let with_refs = symbols(&dir, None, Some("alpha"), true, false).unwrap();
1025 assert!(with_refs.iter().any(|s| !s.is_definition && s.name == "alpha"));
1026
1027 let named = symbols(&dir, None, Some("ALPHA"), false, false).unwrap();
1030 assert!(named.iter().any(|s| s.name == "alpha"));
1031 assert!(!named.iter().any(|s| s.name == "beta"));
1032 let not_substr = symbols(&dir, None, Some("alp"), false, false).unwrap();
1033 assert!(
1034 not_substr.is_empty(),
1035 "exact mode must not substring-match 'alp' onto 'alpha'"
1036 );
1037
1038 let substr = symbols(&dir, None, Some("alp"), false, true).unwrap();
1040 assert!(substr.iter().any(|s| s.name == "alpha"));
1041
1042 std::fs::remove_dir_all(&dir).ok();
1043 }
1044
1045 #[test]
1046 fn symbols_name_exact_buries_substring_noise_issue_37() {
1047 let dir = std::env::temp_dir().join(format!("grove_symbols_issue37_{}", std::process::id()));
1052 std::fs::create_dir_all(&dir).unwrap();
1053 std::fs::write(
1054 dir.join("lib.rs"),
1055 "fn test_create_batch() {}\nfn update_batch_name() {}\nfn batch() {}\n",
1056 )
1057 .unwrap();
1058
1059 let exact = symbols(&dir, None, Some("batch"), false, false).unwrap();
1061 let exact_names: Vec<&str> = exact.iter().map(|s| s.name.as_str()).collect();
1062 assert_eq!(exact_names, vec!["batch"], "exact --name must not leak substrings");
1063
1064 let substr = symbols(&dir, None, Some("batch"), false, true).unwrap();
1066 assert!(substr.iter().any(|s| s.name == "batch"));
1067 assert!(substr.iter().any(|s| s.name == "test_create_batch"));
1068 assert!(substr.iter().any(|s| s.name == "update_batch_name"));
1069
1070 std::fs::remove_dir_all(&dir).ok();
1071 }
1072
1073 #[test]
1076 fn definition_finds_exact_name() {
1077 let dir = std::env::temp_dir().join(format!("grove_def_test_{}", std::process::id()));
1078 std::fs::create_dir_all(&dir).unwrap();
1079 std::fs::write(dir.join("lib.rs"), "fn target() {}\nfn target_helper() {}\n").unwrap();
1080
1081 let defs = definition(&dir, "target").unwrap();
1082 assert_eq!(defs.len(), 1, "exact match only, not the substring `target_helper`");
1083 assert_eq!(defs[0].name, "target");
1084
1085 std::fs::remove_dir_all(&dir).ok();
1086 }
1087
1088 #[test]
1089 fn definition_at_resolves_use_site_to_def() {
1090 let dir = std::env::temp_dir().join(format!("grove_defat_test_{}", std::process::id()));
1091 std::fs::create_dir_all(&dir).unwrap();
1092 let file = dir.join("lib.rs");
1093 std::fs::write(&file, "fn target() {}\nfn caller() {\n target();\n}\n").unwrap();
1094
1095 let (name, defs) = definition_at(&file, 2, 4, &dir).unwrap();
1096 assert_eq!(name, "target");
1097 assert_eq!(defs.len(), 1);
1098 assert_eq!(defs[0].line, 1, "def is on line 1 (1-based)");
1099
1100 let err = definition_at(&file, 1, 0, &dir).err();
1102 assert!(err.is_none() || err.unwrap().to_string().contains("no identifier"));
1103
1104 std::fs::remove_dir_all(&dir).ok();
1105 }
1106
1107 #[test]
1110 fn source_rejects_malformed_id() {
1111 let err = source("rust:src/lib.rs", None).unwrap_err();
1112 assert!(err.to_string().contains("symbol id must look like"), "got: {err}");
1113 }
1114
1115 #[test]
1116 fn source_errors_when_name_absent() {
1117 let path = write_temp("absent", DUP);
1118 let err = source(path.to_str().unwrap(), Some("does_not_exist")).unwrap_err();
1119 assert!(err.to_string().contains("no definition named"), "got: {err}");
1120 std::fs::remove_file(&path).ok();
1121 }
1122
1123 #[test]
1126 fn map_returns_definitions_with_references() {
1127 let dir = std::env::temp_dir().join(format!("grove_map_test_{}", std::process::id()));
1128 std::fs::create_dir_all(&dir).unwrap();
1129 std::fs::write(
1130 dir.join("lib.rs"),
1131 "fn helper() {}\nfn main() {\n helper();\n}\n",
1132 )
1133 .unwrap();
1134
1135 let maps = map(&dir, None, None, false).unwrap();
1136 assert_eq!(maps.len(), 1, "one file");
1137 let fm = &maps[0];
1138 assert!(fm.file.ends_with("lib.rs"), "file is lib.rs, got {}", fm.file);
1139
1140 assert_eq!(fm.entries.len(), 2, "two definitions");
1142 let helper = fm.entries.iter().find(|e| e.name == "helper").unwrap();
1143 let main_entry = fm.entries.iter().find(|e| e.name == "main").unwrap();
1144
1145 assert!(helper.references.is_empty(), "helper has no outgoing refs, got {:?}", helper.references);
1147
1148 assert_eq!(main_entry.references, vec!["helper"], "main references helper");
1150
1151 std::fs::remove_dir_all(&dir).ok();
1152 }
1153
1154 #[test]
1155 fn map_filters_by_kind_and_name() {
1156 let dir = std::env::temp_dir().join(format!("grove_map_filter_test_{}", std::process::id()));
1157 std::fs::create_dir_all(&dir).unwrap();
1158 std::fs::write(
1159 dir.join("lib.rs"),
1160 "struct S;\nimpl S {\n fn m(&self) {\n helper();\n }\n}\nfn helper() {}\n",
1161 )
1162 .unwrap();
1163
1164 let maps = map(&dir, Some("function"), None, false).unwrap();
1167 let entries = &maps[0].entries;
1168 assert!(entries.iter().all(|e| e.kind == "function"), "all entries are functions, got {:?}", entries);
1169 assert!(entries.iter().any(|e| e.name == "helper"), "helper is a function");
1170 assert!(!entries.iter().any(|e| e.name == "m"), "m is a method, not a function");
1172
1173 let exact = map(&dir, None, Some("helper"), false).unwrap();
1175 let entries = &exact[0].entries;
1176 assert!(entries.iter().any(|e| e.name == "helper"), "exact 'helper' matches");
1177 assert!(!entries.iter().any(|e| e.name == "S"), "S does not match 'helper'");
1178 let substr = map(&dir, None, Some("help"), true).unwrap();
1181 assert!(
1182 substr.iter().flat_map(|fm| fm.entries.iter()).any(|e| e.name == "helper"),
1183 "helper matches 'help' substring"
1184 );
1185 let not_substr = map(&dir, None, Some("help"), false).unwrap();
1186 assert!(
1187 not_substr.iter().flat_map(|fm| fm.entries.iter()).all(|e| e.name != "helper"),
1188 "exact 'help' must not match 'helper'"
1189 );
1190
1191 std::fs::remove_dir_all(&dir).ok();
1192 }
1193
1194 #[test]
1195 fn map_excludes_self_references() {
1196 let dir = std::env::temp_dir().join(format!("grove_map_selfref_test_{}", std::process::id()));
1197 std::fs::create_dir_all(&dir).unwrap();
1198 std::fs::write(
1200 dir.join("lib.rs"),
1201 "fn fib(n: i32) -> i32 {\n fib(n - 1)\n}\n",
1202 )
1203 .unwrap();
1204
1205 let maps = map(&dir, None, None, false).unwrap();
1206 let fib = &maps[0].entries[0];
1207 assert_eq!(fib.name, "fib");
1208 assert!(fib.references.is_empty(), "self-reference is excluded, got {:?}", fib.references);
1209
1210 std::fs::remove_dir_all(&dir).ok();
1211 }
1212
1213 #[test]
1214 fn map_attributes_refs_to_innermost_definition() {
1215 let dir = std::env::temp_dir().join(format!("grove_map_nesting_test_{}", std::process::id()));
1218 std::fs::create_dir_all(&dir).unwrap();
1219 std::fs::write(
1223 dir.join("lib.rs"),
1224 "fn helper() {}\nstruct S;\nimpl S {\n fn m(&self) {\n helper();\n }\n}\n",
1225 )
1226 .unwrap();
1227
1228 let maps = map(&dir, None, None, false).unwrap();
1229 let entries = &maps[0].entries;
1230
1231 let _s_entry = entries.iter().find(|e| e.name == "S").unwrap();
1232 let m_entry = entries.iter().find(|e| e.name == "m").unwrap();
1237 assert!(m_entry.references.contains(&"helper".to_string()),
1238 "m references helper, got {:?}", m_entry.references);
1239
1240 std::fs::remove_dir_all(&dir).ok();
1241 }
1242
1243 #[test]
1244 fn map_across_multiple_files() {
1245 let dir = std::env::temp_dir().join(format!("grove_map_multi_test_{}", std::process::id()));
1246 std::fs::create_dir_all(&dir).unwrap();
1247 std::fs::write(dir.join("a.rs"), "fn alpha() {}\nfn call_beta() {\n beta();\n}\n").unwrap();
1248 std::fs::write(dir.join("b.rs"), "fn beta() {}\nfn call_alpha() {\n alpha();\n}\n").unwrap();
1249
1250 let maps = map(&dir, None, None, false).unwrap();
1251 assert!(maps.len() >= 2, "should have entries from both files, got {} files", maps.len());
1252
1253 let a_map = maps.iter().find(|m| m.file.contains("a.rs")).unwrap();
1255 let call_beta = a_map.entries.iter().find(|e| e.name == "call_beta").unwrap();
1256 assert!(call_beta.references.contains(&"beta".to_string()),
1257 "call_beta references beta, got {:?}", call_beta.references);
1258
1259 std::fs::remove_dir_all(&dir).ok();
1260 }
1261
1262 #[test]
1263 fn import_candidates_dotted_package_absolute() {
1264 let got = import_candidate_paths(
1265 "dotted_package",
1266 "pkg.util",
1267 Path::new("/proj/main.py"),
1268 Path::new("/proj"),
1269 );
1270 assert_eq!(
1271 got,
1272 vec![
1273 PathBuf::from("/proj/pkg/util.py"),
1274 PathBuf::from("/proj/pkg/util/__init__.py"),
1275 ]
1276 );
1277 }
1278
1279 #[test]
1280 fn import_candidates_dotted_package_relative_dots() {
1281 let file = Path::new("/proj/pkg/sibling.py");
1282 assert_eq!(
1284 import_candidate_paths("dotted_package", ".util", file, Path::new("/proj")),
1285 vec![
1286 PathBuf::from("/proj/pkg/util.py"),
1287 PathBuf::from("/proj/pkg/util/__init__.py"),
1288 ]
1289 );
1290 assert_eq!(
1292 import_candidate_paths("dotted_package", "..util", file, Path::new("/proj"))[0],
1293 PathBuf::from("/proj/util.py")
1294 );
1295 }
1296
1297 #[test]
1298 fn import_candidates_relative_path_js() {
1299 let file = Path::new("/proj/src/app.js");
1300 assert_eq!(
1301 import_candidate_paths("relative_path", "./calc", file, Path::new("/proj")),
1302 vec![
1303 PathBuf::from("/proj/src/calc.js"),
1304 PathBuf::from("/proj/src/calc.jsx"),
1305 PathBuf::from("/proj/src/calc/index.js"),
1306 ]
1307 );
1308 assert_eq!(
1310 import_candidate_paths("relative_path", "../lib/calc", file, Path::new("/proj"))[0],
1311 PathBuf::from("/proj/lib/calc.js")
1312 );
1313 assert!(import_candidate_paths("relative_path", "react", file, Path::new("/proj")).is_empty());
1315 }
1316
1317 #[test]
1318 fn import_candidates_unknown_strategy_is_empty() {
1319 assert!(import_candidate_paths("nope", "x", Path::new("/p/a.py"), Path::new("/p")).is_empty());
1320 }
1321
1322 #[test]
1323 fn normalize_resolves_dot_and_dotdot() {
1324 assert_eq!(normalize(Path::new("/a/b/../util")), PathBuf::from("/a/util"));
1325 assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
1326 }
1327}