use ignore::WalkBuilder;
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathMatchKind {
File,
Directory,
}
#[derive(Debug, Clone)]
pub struct PathMatch {
pub path: String,
pub kind: PathMatchKind,
pub score: u32,
}
pub struct PathEntry {
pub path: String,
pub kind: PathMatchKind,
}
#[derive(Debug, Clone)]
pub struct SigilExpansion {
pub paths: Vec<String>,
pub suffix: String,
}
pub trait PathSource {
fn find_like(&self, query: &str) -> Option<Vec<PathEntry>>;
fn all_files(&self) -> Option<Vec<PathEntry>>;
}
pub fn expand_sigil(
query: &str,
alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
) -> Option<SigilExpansion> {
if !query.starts_with('@') {
return None;
}
let rest = &query[1..];
let alias_end = rest
.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
.unwrap_or(rest.len());
let alias_name = &rest[..alias_end];
let after_alias = &rest[alias_end..];
let suffix = after_alias
.strip_prefix("::")
.or_else(|| after_alias.strip_prefix('/'))
.or_else(|| after_alias.strip_prefix(':'))
.or_else(|| after_alias.strip_prefix('#'))
.unwrap_or(after_alias);
let targets = alias_lookup(alias_name)?;
Some(SigilExpansion {
paths: targets,
suffix: suffix.to_string(),
})
}
#[derive(Debug, Clone)]
pub struct UnifiedPath {
pub file_path: String,
pub symbol_path: Vec<String>,
pub is_directory: bool,
}
fn normalize_separators(query: &str) -> String {
query
.replace("::", "/")
.replace('#', "/")
.split(':')
.enumerate()
.map(|(i, part)| {
if i == 0 {
part.to_string()
} else {
format!("/{}", part)
}
})
.collect::<String>()
}
pub fn resolve_unified(
query: &str,
root: &Path,
alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
path_source: Option<&dyn PathSource>,
) -> Option<UnifiedPath> {
resolve_unified_depth(query, root, alias_lookup, path_source, 0)
}
fn resolve_unified_depth(
query: &str,
root: &Path,
alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
path_source: Option<&dyn PathSource>,
depth: u8,
) -> Option<UnifiedPath> {
if query.starts_with('@')
&& let Some(expansion) = expand_sigil(query, alias_lookup)
{
if depth >= 32 {
return None;
}
for target in &expansion.paths {
let full_query = if expansion.suffix.is_empty() {
target.clone()
} else {
format!("{}/{}", target, expansion.suffix)
};
if let Some(result) =
resolve_unified_depth(&full_query, root, alias_lookup, None, depth + 1)
{
return Some(result);
}
}
return None;
}
let normalized = normalize_separators(query);
let (segments, base_path): (Vec<&str>, std::path::PathBuf) = if normalized.starts_with('/') {
let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
(segs, std::path::PathBuf::from("/"))
} else {
let segs: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
(segs, root.to_path_buf())
};
let is_absolute = normalized.starts_with('/');
if segments.is_empty() {
return None;
}
let mut current_path = base_path.clone();
for (idx, segment) in segments.iter().enumerate() {
let test_path = current_path.join(segment);
if test_path.is_file() {
let file_path = if is_absolute {
test_path.to_string_lossy().to_string()
} else {
test_path
.strip_prefix(root)
.unwrap_or(&test_path)
.to_string_lossy()
.to_string()
};
return Some(UnifiedPath {
file_path,
symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
is_directory: false,
});
} else if test_path.is_dir() {
current_path = test_path;
} else {
break;
}
}
if current_path != base_path && current_path.is_dir() {
let dir_path = if is_absolute {
current_path.to_string_lossy().to_string()
} else {
current_path
.strip_prefix(root)
.unwrap_or(¤t_path)
.to_string_lossy()
.to_string()
};
let matched_segments = dir_path.matches('/').count() + 1;
if matched_segments >= segments.len() {
return Some(UnifiedPath {
file_path: dir_path,
symbol_path: vec![],
is_directory: true,
});
}
}
if !is_absolute {
let all_paths = get_paths_for_query(root, "", path_source);
for split_point in (1..=segments.len()).rev() {
let file_query = segments[..split_point].join("/");
let matches = resolve_from_paths(&file_query, &all_paths);
if let Some(m) = matches.first() {
if m.kind == PathMatchKind::File {
return Some(UnifiedPath {
file_path: m.path.clone(),
symbol_path: segments[split_point..]
.iter()
.map(|s| s.to_string())
.collect(),
is_directory: false,
});
} else if m.kind == PathMatchKind::Directory && split_point == segments.len() {
return Some(UnifiedPath {
file_path: m.path.clone(),
symbol_path: vec![],
is_directory: true,
});
}
}
}
}
None
}
pub fn resolve_unified_all(
query: &str,
root: &Path,
alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
path_source: Option<&dyn PathSource>,
) -> Vec<UnifiedPath> {
resolve_unified_all_depth(query, root, alias_lookup, path_source, 0)
}
fn resolve_unified_all_depth(
query: &str,
root: &Path,
alias_lookup: &dyn Fn(&str) -> Option<Vec<String>>,
path_source: Option<&dyn PathSource>,
depth: u8,
) -> Vec<UnifiedPath> {
if query.starts_with('@')
&& let Some(expansion) = expand_sigil(query, alias_lookup)
{
if depth >= 32 {
return vec![];
}
let mut results = Vec::new();
for target in &expansion.paths {
let full_query = if expansion.suffix.is_empty() {
target.clone()
} else {
format!("{}/{}", target, expansion.suffix)
};
results.extend(resolve_unified_all_depth(
&full_query,
root,
alias_lookup,
None,
depth + 1,
));
}
return results;
}
let normalized = normalize_separators(query);
let dir_only = normalized.ends_with('/');
if normalized.starts_with('/') {
return resolve_unified_depth(query, root, alias_lookup, None, depth)
.into_iter()
.collect();
}
let segments: Vec<&str> = normalized.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return vec![];
}
let mut current_path = root.to_path_buf();
for (idx, segment) in segments.iter().enumerate() {
let test_path = current_path.join(segment);
if test_path.is_file() {
let file_path = test_path
.strip_prefix(root)
.unwrap_or(&test_path)
.to_string_lossy()
.to_string();
return vec![UnifiedPath {
file_path,
symbol_path: segments[idx + 1..].iter().map(|s| s.to_string()).collect(),
is_directory: false,
}];
} else if test_path.is_dir() {
current_path = test_path;
} else {
break;
}
}
if current_path != root.to_path_buf() && current_path.is_dir() {
let dir_path = current_path
.strip_prefix(root)
.unwrap_or(¤t_path)
.to_string_lossy()
.to_string();
return vec![UnifiedPath {
file_path: dir_path,
symbol_path: vec![],
is_directory: true,
}];
}
let all_paths = get_paths_for_query(root, "", path_source);
for split_point in (1..=segments.len()).rev() {
let file_query = segments[..split_point].join("/");
let matches = resolve_from_paths(&file_query, &all_paths);
if !matches.is_empty() {
let filtered: Vec<_> = if dir_only {
matches
.into_iter()
.filter(|m| m.kind == PathMatchKind::Directory)
.collect()
} else {
matches
};
if !filtered.is_empty() {
return filtered
.into_iter()
.map(|m| UnifiedPath {
file_path: m.path,
symbol_path: segments[split_point..]
.iter()
.map(|s| s.to_string())
.collect(),
is_directory: m.kind == PathMatchKind::Directory,
})
.collect();
}
}
}
vec![]
}
pub fn all_files(root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
get_paths_for_query(root, "", path_source)
.into_iter()
.map(|(path, is_dir)| PathMatch {
path,
kind: if is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score: 0,
})
.collect()
}
pub fn resolve(query: &str, root: &Path, path_source: Option<&dyn PathSource>) -> Vec<PathMatch> {
if query.starts_with('/') {
let abs_path = std::path::Path::new(query);
if abs_path.is_file() {
return vec![PathMatch {
path: query.to_string(),
kind: PathMatchKind::File,
score: u32::MAX,
}];
} else if abs_path.is_dir() {
return vec![PathMatch {
path: query.to_string(),
kind: PathMatchKind::Directory,
score: u32::MAX,
}];
}
return vec![];
}
if query.contains(':') {
let file_part = query.split(':').next().unwrap();
return resolve(file_part, root, path_source);
}
if query.starts_with('.') && !query.contains('/') {
if let Some(src) = path_source.as_ref()
&& let Some(files) = src.find_like(query)
{
return files
.into_iter()
.map(|e| PathMatch {
path: e.path,
kind: e.kind,
score: u32::MAX,
})
.collect();
}
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|e| e.file_name() != ".git")
.build();
return walker
.flatten()
.filter_map(|entry| {
let path = entry.path();
if path.is_file() {
let path_str = path.to_string_lossy();
if path_str.ends_with(query)
&& let Ok(rel) = path.strip_prefix(root)
{
return Some(PathMatch {
path: rel.to_string_lossy().to_string(),
kind: PathMatchKind::File,
score: u32::MAX,
});
}
}
None
})
.collect();
}
let all_paths = get_paths_for_query(root, query, path_source);
resolve_from_paths(query, &all_paths)
}
fn get_paths_for_query(
root: &Path,
query: &str,
path_source: Option<&dyn PathSource>,
) -> Vec<(String, bool)> {
if let Some(src) = path_source {
if !query.is_empty()
&& let Some(files) = src.find_like(query)
&& !files.is_empty()
{
return files
.into_iter()
.map(|e| (e.path, e.kind == PathMatchKind::Directory))
.collect();
}
if let Some(files) = src.all_files() {
return files
.into_iter()
.map(|e| (e.path, e.kind == PathMatchKind::Directory))
.collect();
}
}
let mut all_paths: Vec<(String, bool)> = Vec::new();
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build();
for entry in walker.flatten() {
let path = entry.path();
if let Ok(rel) = path.strip_prefix(root) {
let rel_str = rel.to_string_lossy().to_string();
if rel_str.is_empty() || rel_str == ".git" || rel_str.starts_with(".git/") {
continue;
}
let is_dir = path.is_dir();
all_paths.push((rel_str, is_dir));
}
}
all_paths
}
#[inline]
fn normalize_char(c: char) -> char {
match c {
'-' | '.' | '_' => ' ',
c => c.to_ascii_lowercase(),
}
}
fn eq_normalized(a: &str, b: &str) -> bool {
let mut a_chars = a.chars().map(normalize_char);
let mut b_chars = b.chars().map(normalize_char);
loop {
match (a_chars.next(), b_chars.next()) {
(Some(ac), Some(bc)) if ac == bc => continue,
(None, None) => return true,
_ => return false,
}
}
}
fn normalize_for_match(s: &str) -> String {
s.chars().map(normalize_char).collect()
}
fn resolve_from_paths(query: &str, all_paths: &[(String, bool)]) -> Vec<PathMatch> {
if query.contains('*') {
let pattern = glob::Pattern::new(query).ok();
if let Some(ref pat) = pattern {
let mut glob_matches: Vec<PathMatch> = Vec::new();
for (path, is_dir) in all_paths {
if pat.matches(path) || pat.matches(&path.replace('\\', "/")) {
glob_matches.push(PathMatch {
path: path.clone(),
kind: if *is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score: u32::MAX,
});
}
}
if !glob_matches.is_empty() {
return glob_matches;
}
}
}
let query_lower = query.to_lowercase();
let query_normalized = normalize_for_match(query);
for (path, is_dir) in all_paths {
if eq_normalized(path, query) {
return vec![PathMatch {
path: path.clone(),
kind: if *is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score: u32::MAX,
}];
}
}
let mut exact_matches: Vec<PathMatch> = Vec::new();
for (path, is_dir) in all_paths {
let name = Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let stem = Path::new(path)
.file_stem()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let name_normalized = normalize_for_match(&name);
let stem_normalized = normalize_for_match(&stem);
if name == query_lower
|| stem == query_lower
|| name_normalized == query_normalized
|| stem_normalized == query_normalized
{
exact_matches.push(PathMatch {
path: path.clone(),
kind: if *is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score: u32::MAX - 1,
});
}
}
if !exact_matches.is_empty() {
return exact_matches;
}
if query.contains('/') || query.contains('\\') {
let query_suffix = query.replace('\\', "/");
let mut suffix_matches: Vec<PathMatch> = Vec::new();
for (path, is_dir) in all_paths {
let path_normalized = path.replace('\\', "/");
if path_normalized.ends_with(&query_suffix)
|| path_normalized.ends_with(&format!("/{}", query_suffix))
{
suffix_matches.push(PathMatch {
path: path.clone(),
kind: if *is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score: u32::MAX - 2,
});
}
}
if !suffix_matches.is_empty() {
return suffix_matches;
}
}
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut fuzzy_matches: Vec<PathMatch> = Vec::new();
for (path, is_dir) in all_paths {
let mut buf = Vec::new();
if let Some(score) =
pattern.score(nucleo_matcher::Utf32Str::new(path, &mut buf), &mut matcher)
{
fuzzy_matches.push(PathMatch {
path: path.clone(),
kind: if *is_dir {
PathMatchKind::Directory
} else {
PathMatchKind::File
},
score,
});
}
}
fuzzy_matches.sort_by(|a, b| b.score.cmp(&a.score));
fuzzy_matches.truncate(10);
fuzzy_matches
}
pub fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[')
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn no_aliases(_name: &str) -> Option<Vec<String>> {
None
}
#[test]
fn test_exact_match() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let matches = resolve("src/myapp/cli.py", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "src/myapp/cli.py");
}
#[test]
fn test_filename_match() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
let matches = resolve("dwim.py", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "src/myapp/dwim.py");
}
#[test]
fn test_stem_match() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/dwim.py"), "").unwrap();
let matches = resolve("dwim", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "src/myapp/dwim.py");
}
#[test]
fn test_underscore_hyphen_equivalence() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("docs")).unwrap();
fs::write(dir.path().join("docs/prior-art.md"), "").unwrap();
let matches = resolve("prior_art", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "docs/prior-art.md");
let matches = resolve("prior-art", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "docs/prior-art.md");
let matches = resolve("docs/prior_art.md", dir.path(), None);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path, "docs/prior-art.md");
}
#[test]
fn test_unified_path_file_only() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp/cli.py", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert!(u.symbol_path.is_empty());
assert!(!u.is_directory);
}
#[test]
fn test_unified_path_with_symbol() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp/cli.py/Foo/bar", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
assert!(!u.is_directory);
}
#[test]
fn test_unified_path_directory() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp");
assert!(u.symbol_path.is_empty());
assert!(u.is_directory);
}
#[test]
fn test_unified_path_rust_style_separator() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp/cli.py::Foo::bar", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
}
#[test]
fn test_unified_path_hash_separator() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp/cli.py#Foo", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert_eq!(u.symbol_path, vec!["Foo"]);
}
#[test]
fn test_unified_path_colon_separator() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("src/myapp/cli.py:Foo:bar", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert_eq!(u.symbol_path, vec!["Foo", "bar"]);
}
#[test]
fn test_unified_path_fuzzy_file() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src/myapp")).unwrap();
fs::write(dir.path().join("src/myapp/cli.py"), "").unwrap();
let result = resolve_unified("cli.py/Foo", dir.path(), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, "src/myapp/cli.py");
assert_eq!(u.symbol_path, vec!["Foo"]);
}
#[test]
fn test_unified_path_absolute() {
let dir = tempdir().unwrap();
let abs_path = dir.path().join("test.py");
fs::write(&abs_path, "def foo(): pass").unwrap();
let abs_str = abs_path.to_string_lossy().to_string();
let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, abs_str);
assert!(u.symbol_path.is_empty());
assert!(!u.is_directory);
}
#[test]
fn test_unified_path_absolute_with_symbol() {
let dir = tempdir().unwrap();
let abs_path = dir.path().join("test.py");
fs::write(&abs_path, "def foo(): pass").unwrap();
let query = format!("{}/foo", abs_path.to_string_lossy());
let result = resolve_unified(&query, Path::new("/some/other/root"), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, abs_path.to_string_lossy().to_string());
assert_eq!(u.symbol_path, vec!["foo"]);
}
#[test]
fn test_unified_path_unicode() {
let dir = tempdir().unwrap();
let unicode_dir = dir.path().join("日本語");
fs::create_dir_all(&unicode_dir).unwrap();
let unicode_file = unicode_dir.join("テスト.py");
fs::write(&unicode_file, "def hello(): pass").unwrap();
let abs_str = unicode_file.to_string_lossy().to_string();
let result = resolve_unified(&abs_str, Path::new("/some/other/root"), &no_aliases, None);
assert!(result.is_some());
let u = result.unwrap();
assert_eq!(u.file_path, abs_str);
assert!(!u.is_directory);
}
}