use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
use crate::engine::{self, Defect, Symbol};
use crate::registry::{self, Grammar};
use ignore::WalkBuilder;
pub fn read(path: &Path) -> Result<Vec<u8>> {
std::fs::read(path).with_context(|| format!("reading {}", path.display()))
}
pub fn rel(path: &Path) -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| path.canonicalize().ok().map(|p| (cwd, p)))
.and_then(|(cwd, p)| p.strip_prefix(&cwd).ok().map(|r| r.display().to_string()))
.unwrap_or_else(|| path.display().to_string())
}
fn is_generated_decl(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
name.ends_with(".d.ts") || name.ends_with(".d.cts") || name.ends_with(".d.mts")
}
fn for_each_source(dir: &Path, mut f: impl FnMut(&Grammar, &str, &[u8]) -> Result<()>) -> Result<()> {
for entry in WalkBuilder::new(dir).build() {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if !path.is_file() || !registry::is_source(path) || is_generated_decl(path) {
continue;
}
let grammar = registry::for_path(path)?;
let src = read(path)?;
f(&grammar, &rel(path), &src)?;
}
Ok(())
}
fn kind_matches(sym_kind: &str, filter: &str) -> bool {
sym_kind == filter
|| (matches!(filter, "struct" | "union" | "record") && sym_kind == "class")
}
fn name_matches(sym_name: &str, query_lc: &str, contains: bool) -> bool {
let sym_lc = sym_name.to_lowercase();
if contains {
sym_lc.contains(query_lc)
} else {
sym_lc == query_lc
}
}
pub fn outline(file: &Path, kind: Option<&str>) -> Result<Vec<Symbol>> {
let grammar = registry::for_path(file)?;
let src = read(file)?;
let mut syms = engine::extract(&grammar, &rel(file), &src)?;
syms.retain(|s| s.is_definition && kind.is_none_or(|k| kind_matches(&s.kind, k)));
Ok(syms)
}
pub fn project(syms: &[Symbol], detail: u8) -> serde_json::Value {
use serde_json::{Map, Value};
if detail >= 2 {
return serde_json::to_value(syms).unwrap_or(Value::Null);
}
let arr = syms
.iter()
.map(|s| {
let mut m = Map::new();
if detail >= 1 {
m.insert("id".into(), s.id.clone().into());
}
m.insert("kind".into(), s.kind.clone().into());
m.insert("name".into(), s.name.clone().into());
if let Some(p) = &s.parent {
m.insert("parent".into(), p.clone().into());
}
m.insert("line".into(), s.line.into());
if detail >= 1 {
m.insert("col".into(), s.col.into());
m.insert("signature".into(), s.signature.clone().into());
}
Value::Object(m)
})
.collect();
Value::Array(arr)
}
pub fn symbols(
dir: &Path,
kind: Option<&str>,
name: Option<&str>,
refs: bool,
name_contains: bool,
) -> Result<Vec<Symbol>> {
let name_lc = name.map(str::to_lowercase);
let mut all = Vec::new();
for_each_source(dir, |grammar, relpath, src| {
for s in engine::extract(grammar, relpath, src)? {
if !refs && !s.is_definition {
continue;
}
if kind.is_some_and(|k| !kind_matches(&s.kind, k)) {
continue;
}
if name_lc
.as_ref()
.is_some_and(|n| !name_matches(&s.name, n, name_contains))
{
continue;
}
all.push(s);
}
Ok(())
})?;
Ok(all)
}
#[derive(Debug, Serialize)]
pub struct SourceResult {
pub id: String,
pub source: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub other_candidates: Vec<String>,
}
pub fn source(id_or_file: &str, name: Option<&str>) -> Result<SourceResult> {
let (file, want, want_line): (PathBuf, String, Option<usize>) = match name {
Some(n) => (PathBuf::from(id_or_file), n.to_string(), None),
None => {
let rest = id_or_file.split_once(':').map_or(id_or_file, |(_, r)| r);
let (path, after) = rest
.split_once('#')
.context("symbol id must look like <lang>:<path>#<name>@<line>")?;
let (name, line) = match after.split_once('@') {
Some((n, r)) => (n.to_string(), r.parse::<usize>().ok()),
None => (after.to_string(), None),
};
(PathBuf::from(path), name, line)
}
};
let grammar = registry::for_path(&file)?;
let src = read(&file)?;
let syms = engine::extract(&grammar, &rel(&file), &src)?;
let matches: Vec<&Symbol> = syms
.iter()
.filter(|s| s.is_definition && s.name == want)
.collect();
let chosen = match want_line.and_then(|r| matches.iter().find(|s| s.line == r)) {
Some(c) => *c,
None => match matches.first() {
None => anyhow::bail!("no definition named `{want}` in {}", file.display()),
Some(c) => *c,
},
};
Ok(SourceResult {
id: chosen.id.clone(),
source: engine::slice(&src, chosen).to_string(),
other_candidates: matches
.iter()
.filter(|s| s.id != chosen.id)
.map(|s| s.id.clone())
.collect(),
})
}
pub fn check(file: &Path) -> Result<Vec<Defect>> {
let grammar = registry::for_path(file)?;
let src = read(file)?;
engine::check(&grammar, &src)
}
#[derive(Debug, Serialize)]
pub struct CallSite {
pub file: String,
pub line: usize,
pub col: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub in_function: Option<String>,
pub text: String,
pub source: String,
}
pub fn callers(dir: &Path, name: &str) -> Result<Vec<CallSite>> {
let mut out = Vec::new();
for_each_source(dir, |grammar, relpath, src| {
let (syms, tree) = engine::extract_with_tree(grammar, relpath, src)?;
let root = tree.root_node();
let structurals: Vec<&Symbol> = syms
.iter()
.filter(|s| !s.is_definition && s.name == name)
.collect();
let mut skip_lines: std::collections::HashSet<usize> =
std::collections::HashSet::new();
for s in &structurals {
skip_lines.insert(s.line.saturating_sub(1));
}
for s in syms.iter().filter(|s| s.is_definition && s.name == name) {
skip_lines.insert(s.line.saturating_sub(1));
}
for s in &structurals {
out.push(CallSite {
in_function: engine::enclosing_function_at(
root,
s.start_byte,
src,
&grammar.profile,
),
file: s.file.clone(),
line: s.line,
col: s.col,
text: s.signature.clone(),
source: "structural".to_string(),
});
}
let src_str = std::str::from_utf8(src).unwrap_or("");
for (row, line_text) in src_str.lines().enumerate() {
if skip_lines.contains(&row) {
continue; }
if !has_whole_word(line_text, name) {
continue;
}
let col = line_text.find(name).unwrap_or(0);
let byte = line_offset(src, row) + col;
out.push(CallSite {
in_function: engine::enclosing_function_at(root, byte, src, &grammar.profile),
file: relpath.to_string(),
line: row + 1,
col: col + 1,
text: line_text.trim().to_string(),
source: "textual".to_string(),
});
}
Ok(())
})?;
Ok(out)
}
fn line_offset(source: &[u8], row: usize) -> usize {
let mut off = 0;
for _ in 0..row {
match source[off..].iter().position(|&b| b == b'\n') {
Some(pos) => off += pos + 1,
None => return source.len(),
}
}
off
}
fn has_whole_word(haystack: &str, needle: &str) -> bool {
let needle_bytes = needle.as_bytes();
let haystack_bytes = haystack.as_bytes();
if needle_bytes.is_empty() {
return false;
}
let mut i = 0;
while i + needle_bytes.len() <= haystack_bytes.len() {
if &haystack_bytes[i..i + needle_bytes.len()] == needle_bytes {
let before_ok = i == 0 || !is_ident_char(haystack_bytes[i - 1]);
let after_ok =
i + needle_bytes.len() == haystack_bytes.len()
|| !is_ident_char(haystack_bytes[i + needle_bytes.len()]);
if before_ok && after_ok {
return true;
}
}
i += 1;
}
false
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
pub fn parse_pos(s: &str) -> Result<(PathBuf, usize, usize)> {
let parts: Vec<&str> = s.rsplitn(3, ':').collect();
match parts.as_slice() {
[col, line, file] => {
let line: usize = line.parse().map_err(|_| anyhow::anyhow!("bad line in `{s}`"))?;
let col: usize = col.parse().map_err(|_| anyhow::anyhow!("bad col in `{s}`"))?;
Ok((
PathBuf::from(file),
line.saturating_sub(1),
col.saturating_sub(1),
))
}
_ => anyhow::bail!("expected file:line:col, got `{s}`"),
}
}
#[derive(Debug, Serialize)]
pub struct MapEntry {
pub id: String,
pub kind: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub row: usize,
pub signature: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub references: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct FileMap {
pub file: String,
pub entries: Vec<MapEntry>,
}
pub fn map(
dir: &Path,
kind: Option<&str>,
name: Option<&str>,
name_contains: bool,
) -> Result<Vec<FileMap>> {
let name_lc = name.map(str::to_lowercase);
let mut file_maps = Vec::new();
for_each_source(dir, |grammar, relpath, src| {
let syms = engine::extract(grammar, relpath, src)?;
let mut defs: Vec<usize> = syms
.iter()
.enumerate()
.filter(|(_, s)| s.is_definition)
.filter(|(_, s)| kind.is_none_or(|k| kind_matches(&s.kind, k)))
.filter(|(_, s)| name_lc.as_ref().is_none_or(|n| name_matches(&s.name, n, name_contains)))
.map(|(i, _)| i)
.collect();
defs.sort_by_key(|&i| syms[i].end_byte - syms[i].start_byte);
let mut ref_map: std::collections::HashMap<usize, Vec<String>> =
std::collections::HashMap::new();
for s in syms.iter() {
if s.is_definition {
continue;
}
for &d in &defs {
if s.start_byte >= syms[d].start_byte && s.end_byte <= syms[d].end_byte {
ref_map.entry(d).or_default().push(s.name.clone());
break; }
}
}
for names in ref_map.values_mut() {
names.sort();
names.dedup();
}
let mut entries: Vec<MapEntry> = defs
.iter()
.map(|&d| {
let s = &syms[d];
let mut refs = ref_map.remove(&d).unwrap_or_default();
refs.retain(|n| n != &s.name);
MapEntry {
id: s.id.clone(),
kind: s.kind.clone(),
name: s.name.clone(),
parent: s.parent.clone(),
row: s.line,
signature: s.signature.clone(),
references: refs,
}
})
.collect();
entries.sort_by_key(|e| e.row);
if !entries.is_empty() {
file_maps.push(FileMap {
file: relpath.to_string(),
entries,
});
}
Ok(())
})?;
file_maps.sort_by(|a, b| a.file.cmp(&b.file));
Ok(file_maps)
}
pub fn definition(dir: &Path, name: &str) -> Result<Vec<Symbol>> {
let mut defs = symbols(dir, None, Some(name), false, false)?;
defs.retain(|s| s.name == name);
Ok(defs)
}
pub fn definition_at(file: &Path, row: usize, col: usize, dir: &Path) -> Result<(String, Vec<Symbol>)> {
let grammar = registry::for_path(file)?;
let src = read(file)?;
let name = engine::with_tree(&grammar, &src, |root, profile| {
engine::identifier_at(root, row, col, &src, profile)
})?
.with_context(|| format!("no identifier at {}:{row}:{col}", file.display()))?;
if let Some(local) = engine::resolve_local_at(&grammar, &rel(file), &src, row, col)? {
return Ok((name, vec![local]));
}
let imported = resolve_import_at(file, &name, dir, &grammar, &src)?;
if !imported.is_empty() {
return Ok((name, imported));
}
let defs = definition(dir, &name)?;
Ok((name, defs))
}
fn resolve_import_at(
file: &Path,
name: &str,
dir: &Path,
grammar: &Grammar,
src: &[u8],
) -> Result<Vec<Symbol>> {
let Some(strategy) = grammar.profile.import_resolution.as_deref() else {
return Ok(Vec::new());
};
let Some(binding) = engine::extract_imports(grammar, src)?
.into_iter()
.find(|b| b.name == name)
else {
return Ok(Vec::new());
};
for cand in import_candidate_paths(strategy, &binding.module, file, dir) {
if !cand.is_file() {
continue;
}
let tsrc = read(&cand)?;
let trel = rel(&cand);
let defs: Vec<Symbol> = engine::extract(grammar, &trel, &tsrc)?
.into_iter()
.filter(|s| s.is_definition && s.name == binding.source)
.collect();
if !defs.is_empty() {
return Ok(defs);
}
}
Ok(Vec::new())
}
fn import_candidate_paths(
strategy: &str,
module: &str,
current_file: &Path,
dir: &Path,
) -> Vec<PathBuf> {
match strategy {
"dotted_package" => {
let dots = module.chars().take_while(|&c| c == '.').count();
let rest = &module[dots..];
let parts: Vec<&str> = rest.split('.').filter(|p| !p.is_empty()).collect();
let mut base = if dots == 0 {
dir.to_path_buf()
} else {
let mut b = current_file.parent().unwrap_or(dir).to_path_buf();
for _ in 0..dots.saturating_sub(1) {
b = b.parent().map(Path::to_path_buf).unwrap_or(b);
}
b
};
for p in &parts {
base.push(p);
}
vec![base.with_extension("py"), base.join("__init__.py")]
}
"relative_path" => {
if !module.starts_with('.') {
return Vec::new();
}
let joined = normalize(¤t_file.parent().unwrap_or(dir).join(module));
if joined.extension().is_some() {
return vec![joined];
}
vec![
joined.with_extension("js"),
joined.with_extension("jsx"),
joined.join("index.js"),
]
}
_ => Vec::new(),
}
}
fn normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const DUP: &str =
"fn run() {\n let _first = 1;\n}\n\nfn run() {\n let _second = 2;\n}\n";
fn write_temp(tag: &str, contents: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("grove_src_test_{}_{tag}.rs", std::process::id()));
std::fs::write(&p, contents).unwrap();
p
}
#[test]
fn id_line_selects_that_definition() {
let path = write_temp("dup_line", DUP);
let res = source(&format!("rust:{}#run@5", path.display()), None).unwrap();
assert!(res.source.contains("_second"), "line 5 must pick the 2nd run, got: {}", res.source);
assert!(res.id.ends_with("@5"), "chosen id should be the line-5 def, got {}", res.id);
let res0 = source(&format!("rust:{}#run@1", path.display()), None).unwrap();
assert!(res0.source.contains("_first"), "line 1 must pick the 1st run, got: {}", res0.source);
std::fs::remove_file(&path).ok();
}
#[test]
fn unmatched_line_falls_back_to_first() {
let path = write_temp("dup_fallback", DUP);
let res = source(&format!("rust:{}#run@99", path.display()), None).unwrap();
assert!(res.source.contains("_first"), "unknown line falls back to the first def");
std::fs::remove_file(&path).ok();
}
#[test]
fn by_name_returns_first_and_lists_other_candidate() {
let path = write_temp("dup_name", DUP);
let res = source(path.to_str().unwrap(), Some("run")).unwrap();
assert!(res.source.contains("_first"));
assert_eq!(res.other_candidates.len(), 1, "the 2nd run is the other candidate");
std::fs::remove_file(&path).ok();
}
#[test]
fn callers_finds_call_sites_via_profile() {
let dir = std::env::temp_dir().join(format!("grove_callers_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("lib.rs");
std::fs::write(&file, "fn helper() {}\nfn main() {\n helper();\n}\n").unwrap();
let sites = callers(&dir, "helper").unwrap();
assert_eq!(sites.len(), 1, "exactly one call to helper, got {sites:?}");
assert_eq!(sites[0].in_function.as_deref(), Some("main"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn callers_parses_each_file_once() {
let dir = std::env::temp_dir().join(format!("grove_parse_once_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "fn main() {\n helper();\n}\n").unwrap();
std::fs::write(dir.join("b.rs"), "fn run() {\n helper();\n}\n").unwrap();
std::fs::write(dir.join("c.rs"), "fn unrelated() {}\n").unwrap();
engine::parse_counter::reset();
let sites = callers(&dir, "helper").unwrap();
let parses = engine::parse_counter::get();
assert_eq!(sites.len(), 2, "two call sites, got {sites:?}");
assert_eq!(parses, 3, "expected one parse per source file (3), got {parses}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn callers_includes_type_and_impl_references() {
let dir = std::env::temp_dir().join(format!("grove_callers_type_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"struct Thing;\nimpl Clone for Thing {}\n",
).unwrap();
let sites = callers(&dir, "Clone").unwrap();
let structural = sites.iter().filter(|s| s.source == "structural").count();
assert!(structural >= 1, "should find impl reference to Clone, got {sites:?}");
assert!(sites.iter().any(|s| s.in_function.is_none()), "impl is top-level, got {sites:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn callers_textual_fallback_finds_untagged_references() {
let dir = std::env::temp_dir().join(format!("grove_callers_textual_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn go(s: Scanner) { let x: Scanner = s; }\n",
).unwrap();
let sites = callers(&dir, "Scanner").unwrap();
let textual: Vec<&CallSite> = sites.iter().filter(|s| s.source == "textual").collect();
assert!(!textual.is_empty(), "textual fallback should find type-annotation references to Scanner, got {sites:?}");
assert_eq!(textual.len(), 1, "one textual line containing Scanner, got {textual:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn callers_excludes_definition_from_textual() {
let dir = std::env::temp_dir().join(format!("grove_callers_nodef_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn helper() {}\nfn main() { helper(); }\n",
).unwrap();
let sites = callers(&dir, "helper").unwrap();
assert!(!sites.iter().any(|s| s.line == 1), "definition line should not be in callers results, got {sites:?}");
assert!(sites.iter().any(|s| s.line == 2), "call site line should be in callers results, got {sites:?}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn has_whole_word_finds_identifier_names() {
assert!(has_whole_word(" helper()", "helper"));
assert!(has_whole_word("fn helper() {}", "helper"));
assert!(has_whole_word("s: Scanner", "Scanner"));
assert!(has_whole_word("Scanner::new()", "Scanner"));
assert!(has_whole_word("use crate::Scanner;", "Scanner"));
assert!(!has_whole_word("helper_fn()", "helper"));
assert!(!has_whole_word("myhelper()", "helper"));
assert!(!has_whole_word("MyScanner", "Scanner"));
assert!(!has_whole_word("scanner_new", "Scanner"));
assert!(!has_whole_word("anything", ""));
assert!(!has_whole_word("ab", "abc"));
assert!(has_whole_word("helper", "helper"));
}
#[test]
fn parse_pos_parses_file_line_col() {
let (file, row, col) = parse_pos("src/lib.rs:12:4").unwrap();
assert_eq!(file, PathBuf::from("src/lib.rs"));
assert_eq!((row, col), (11, 3));
}
#[test]
fn parse_pos_keeps_colons_in_the_path() {
let (file, row, col) = parse_pos("a:b/file.rs:3:7").unwrap();
assert_eq!(file, PathBuf::from("a:b/file.rs"));
assert_eq!((row, col), (2, 6));
}
#[test]
fn parse_pos_rejects_bad_shapes() {
assert!(parse_pos("no-colons").is_err());
assert!(parse_pos("file.rs:notarow:4").unwrap_err().to_string().contains("bad line"));
assert!(parse_pos("file.rs:4:notacol").unwrap_err().to_string().contains("bad col"));
}
#[test]
fn project_tiers_control_field_density() {
let dir = std::env::temp_dir().join(format!("grove_project_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("lib.rs");
std::fs::write(&file, "struct S;\nimpl S {\n fn m(&self) {}\n}\n").unwrap();
let syms = outline(&file, None).unwrap();
let terse = project(&syms, 0);
let first = &terse.as_array().unwrap()[0];
assert!(first.get("id").is_none(), "detail 0 omits id");
assert!(first.get("signature").is_none(), "detail 0 omits signature");
assert!(first.get("kind").is_some() && first.get("name").is_some());
let default = project(&syms, 1);
let d0 = &default.as_array().unwrap()[0];
assert!(d0.get("id").is_some(), "detail 1 adds id");
assert!(d0.get("signature").is_some(), "detail 1 adds signature");
assert!(d0.get("start_byte").is_none(), "detail 1 drops byte offsets");
let full = project(&syms, 2);
let f0 = &full.as_array().unwrap()[0];
assert!(f0.get("start_byte").is_some(), "detail 2 includes byte offsets");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn outline_filters_by_kind_and_skips_references() {
let dir = std::env::temp_dir().join(format!("grove_outline_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("lib.rs");
std::fs::write(&file, "struct S;\nfn f() {\n g();\n}\n").unwrap();
let all = outline(&file, None).unwrap();
assert!(all.iter().all(|s| s.is_definition), "outline yields definitions only");
assert!(all.iter().any(|s| s.name == "S"));
assert!(all.iter().any(|s| s.name == "f"));
let classes = outline(&file, Some("class")).unwrap();
assert!(classes.iter().all(|s| s.kind == "class"));
assert!(classes.iter().any(|s| s.name == "S"));
assert!(!classes.iter().any(|s| s.name == "f"), "kind filter excludes fns");
let structs = outline(&file, Some("struct")).unwrap();
assert!(structs.iter().any(|s| s.name == "S"), "struct aliases to class");
assert!(!structs.iter().any(|s| s.name == "f"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn is_generated_decl_flags_typescript_declaration_files() {
assert!(is_generated_decl(Path::new("src/compiler/scanner.d.ts")));
assert!(is_generated_decl(Path::new("tests/baselines/reference/api/typescript.d.ts")));
assert!(is_generated_decl(Path::new("declarations/LoaderContext.d.ts")));
assert!(is_generated_decl(Path::new("pkg/index.d.cts")));
assert!(is_generated_decl(Path::new("pkg/index.d.mts")));
assert!(!is_generated_decl(Path::new("src/compiler/scanner.ts")));
assert!(!is_generated_decl(Path::new("lib/Compiler.js")));
assert!(!is_generated_decl(Path::new("types.ts")), "`types.ts` is real source, not `types.d.ts`");
assert!(!is_generated_decl(Path::new("README.md")));
assert!(!is_generated_decl(Path::new("no_extension")));
}
#[test]
fn symbols_skips_generated_declaration_files() {
let dir = std::env::temp_dir().join(format!("grove_nodecl_test_{}", std::process::id()));
std::fs::create_dir_all(dir.join("declarations")).unwrap();
std::fs::write(dir.join("declarations/LoaderContext.d.ts"), "export class LoaderContext {}").unwrap();
std::fs::write(dir.join("lib.js"), "class Compiler {}").unwrap();
let defs = symbols(&dir, None, Some("Compiler"), false, false).unwrap();
assert!(defs.iter().any(|s| s.name == "Compiler"), "real source is indexed");
assert!(!defs.iter().any(|s| s.name == "LoaderContext"), "generated decl is skipped");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn kind_matches_exact_and_struct_synonyms() {
assert!(kind_matches("class", "class"));
assert!(kind_matches("class", "struct"), "struct → class");
assert!(kind_matches("class", "union"), "union → class");
assert!(kind_matches("function", "function"));
assert!(!kind_matches("function", "struct"), "synonyms only widen onto class");
assert!(!kind_matches("variable", "class"));
}
#[test]
fn symbols_honors_name_kind_and_refs_filters() {
let dir = std::env::temp_dir().join(format!("grove_symbols_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "fn alpha() {}\nfn beta() {\n alpha();\n}\n").unwrap();
let defs = symbols(&dir, None, None, false, false).unwrap();
assert!(defs.iter().all(|s| s.is_definition));
let with_refs = symbols(&dir, None, Some("alpha"), true, false).unwrap();
assert!(with_refs.iter().any(|s| !s.is_definition && s.name == "alpha"));
let named = symbols(&dir, None, Some("ALPHA"), false, false).unwrap();
assert!(named.iter().any(|s| s.name == "alpha"));
assert!(!named.iter().any(|s| s.name == "beta"));
let not_substr = symbols(&dir, None, Some("alp"), false, false).unwrap();
assert!(
not_substr.is_empty(),
"exact mode must not substring-match 'alp' onto 'alpha'"
);
let substr = symbols(&dir, None, Some("alp"), false, true).unwrap();
assert!(substr.iter().any(|s| s.name == "alpha"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn symbols_name_exact_buries_substring_noise_issue_37() {
let dir = std::env::temp_dir().join(format!("grove_symbols_issue37_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn test_create_batch() {}\nfn update_batch_name() {}\nfn batch() {}\n",
)
.unwrap();
let exact = symbols(&dir, None, Some("batch"), false, false).unwrap();
let exact_names: Vec<&str> = exact.iter().map(|s| s.name.as_str()).collect();
assert_eq!(exact_names, vec!["batch"], "exact --name must not leak substrings");
let substr = symbols(&dir, None, Some("batch"), false, true).unwrap();
assert!(substr.iter().any(|s| s.name == "batch"));
assert!(substr.iter().any(|s| s.name == "test_create_batch"));
assert!(substr.iter().any(|s| s.name == "update_batch_name"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn definition_finds_exact_name() {
let dir = std::env::temp_dir().join(format!("grove_def_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("lib.rs"), "fn target() {}\nfn target_helper() {}\n").unwrap();
let defs = definition(&dir, "target").unwrap();
assert_eq!(defs.len(), 1, "exact match only, not the substring `target_helper`");
assert_eq!(defs[0].name, "target");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn definition_at_resolves_use_site_to_def() {
let dir = std::env::temp_dir().join(format!("grove_defat_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("lib.rs");
std::fs::write(&file, "fn target() {}\nfn caller() {\n target();\n}\n").unwrap();
let (name, defs) = definition_at(&file, 2, 4, &dir).unwrap();
assert_eq!(name, "target");
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].line, 1, "def is on line 1 (1-based)");
let err = definition_at(&file, 1, 0, &dir).err();
assert!(err.is_none() || err.unwrap().to_string().contains("no identifier"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn source_rejects_malformed_id() {
let err = source("rust:src/lib.rs", None).unwrap_err();
assert!(err.to_string().contains("symbol id must look like"), "got: {err}");
}
#[test]
fn source_errors_when_name_absent() {
let path = write_temp("absent", DUP);
let err = source(path.to_str().unwrap(), Some("does_not_exist")).unwrap_err();
assert!(err.to_string().contains("no definition named"), "got: {err}");
std::fs::remove_file(&path).ok();
}
#[test]
fn map_returns_definitions_with_references() {
let dir = std::env::temp_dir().join(format!("grove_map_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn helper() {}\nfn main() {\n helper();\n}\n",
)
.unwrap();
let maps = map(&dir, None, None, false).unwrap();
assert_eq!(maps.len(), 1, "one file");
let fm = &maps[0];
assert!(fm.file.ends_with("lib.rs"), "file is lib.rs, got {}", fm.file);
assert_eq!(fm.entries.len(), 2, "two definitions");
let helper = fm.entries.iter().find(|e| e.name == "helper").unwrap();
let main_entry = fm.entries.iter().find(|e| e.name == "main").unwrap();
assert!(helper.references.is_empty(), "helper has no outgoing refs, got {:?}", helper.references);
assert_eq!(main_entry.references, vec!["helper"], "main references helper");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn map_filters_by_kind_and_name() {
let dir = std::env::temp_dir().join(format!("grove_map_filter_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"struct S;\nimpl S {\n fn m(&self) {\n helper();\n }\n}\nfn helper() {}\n",
)
.unwrap();
let maps = map(&dir, Some("function"), None, false).unwrap();
let entries = &maps[0].entries;
assert!(entries.iter().all(|e| e.kind == "function"), "all entries are functions, got {:?}", entries);
assert!(entries.iter().any(|e| e.name == "helper"), "helper is a function");
assert!(!entries.iter().any(|e| e.name == "m"), "m is a method, not a function");
let exact = map(&dir, None, Some("helper"), false).unwrap();
let entries = &exact[0].entries;
assert!(entries.iter().any(|e| e.name == "helper"), "exact 'helper' matches");
assert!(!entries.iter().any(|e| e.name == "S"), "S does not match 'helper'");
let substr = map(&dir, None, Some("help"), true).unwrap();
assert!(
substr.iter().flat_map(|fm| fm.entries.iter()).any(|e| e.name == "helper"),
"helper matches 'help' substring"
);
let not_substr = map(&dir, None, Some("help"), false).unwrap();
assert!(
not_substr.iter().flat_map(|fm| fm.entries.iter()).all(|e| e.name != "helper"),
"exact 'help' must not match 'helper'"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn map_excludes_self_references() {
let dir = std::env::temp_dir().join(format!("grove_map_selfref_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn fib(n: i32) -> i32 {\n fib(n - 1)\n}\n",
)
.unwrap();
let maps = map(&dir, None, None, false).unwrap();
let fib = &maps[0].entries[0];
assert_eq!(fib.name, "fib");
assert!(fib.references.is_empty(), "self-reference is excluded, got {:?}", fib.references);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn map_attributes_refs_to_innermost_definition() {
let dir = std::env::temp_dir().join(format!("grove_map_nesting_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("lib.rs"),
"fn helper() {}\nstruct S;\nimpl S {\n fn m(&self) {\n helper();\n }\n}\n",
)
.unwrap();
let maps = map(&dir, None, None, false).unwrap();
let entries = &maps[0].entries;
let _s_entry = entries.iter().find(|e| e.name == "S").unwrap();
let m_entry = entries.iter().find(|e| e.name == "m").unwrap();
assert!(m_entry.references.contains(&"helper".to_string()),
"m references helper, got {:?}", m_entry.references);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn map_across_multiple_files() {
let dir = std::env::temp_dir().join(format!("grove_map_multi_test_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "fn alpha() {}\nfn call_beta() {\n beta();\n}\n").unwrap();
std::fs::write(dir.join("b.rs"), "fn beta() {}\nfn call_alpha() {\n alpha();\n}\n").unwrap();
let maps = map(&dir, None, None, false).unwrap();
assert!(maps.len() >= 2, "should have entries from both files, got {} files", maps.len());
let a_map = maps.iter().find(|m| m.file.contains("a.rs")).unwrap();
let call_beta = a_map.entries.iter().find(|e| e.name == "call_beta").unwrap();
assert!(call_beta.references.contains(&"beta".to_string()),
"call_beta references beta, got {:?}", call_beta.references);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn import_candidates_dotted_package_absolute() {
let got = import_candidate_paths(
"dotted_package",
"pkg.util",
Path::new("/proj/main.py"),
Path::new("/proj"),
);
assert_eq!(
got,
vec![
PathBuf::from("/proj/pkg/util.py"),
PathBuf::from("/proj/pkg/util/__init__.py"),
]
);
}
#[test]
fn import_candidates_dotted_package_relative_dots() {
let file = Path::new("/proj/pkg/sibling.py");
assert_eq!(
import_candidate_paths("dotted_package", ".util", file, Path::new("/proj")),
vec![
PathBuf::from("/proj/pkg/util.py"),
PathBuf::from("/proj/pkg/util/__init__.py"),
]
);
assert_eq!(
import_candidate_paths("dotted_package", "..util", file, Path::new("/proj"))[0],
PathBuf::from("/proj/util.py")
);
}
#[test]
fn import_candidates_relative_path_js() {
let file = Path::new("/proj/src/app.js");
assert_eq!(
import_candidate_paths("relative_path", "./calc", file, Path::new("/proj")),
vec![
PathBuf::from("/proj/src/calc.js"),
PathBuf::from("/proj/src/calc.jsx"),
PathBuf::from("/proj/src/calc/index.js"),
]
);
assert_eq!(
import_candidate_paths("relative_path", "../lib/calc", file, Path::new("/proj"))[0],
PathBuf::from("/proj/lib/calc.js")
);
assert!(import_candidate_paths("relative_path", "react", file, Path::new("/proj")).is_empty());
}
#[test]
fn import_candidates_unknown_strategy_is_empty() {
assert!(import_candidate_paths("nope", "x", Path::new("/p/a.py"), Path::new("/p")).is_empty());
}
#[test]
fn normalize_resolves_dot_and_dotdot() {
assert_eq!(normalize(Path::new("/a/b/../util")), PathBuf::from("/a/util"));
assert_eq!(normalize(Path::new("/a/./b")), PathBuf::from("/a/b"));
}
}