385 lines
13 KiB
Rust
385 lines
13 KiB
Rust
//! Fuzzy file path discovery for autocomplete and @-mention resolution.
|
|
//!
|
|
//! Searches for files and directories whose paths match a query string via
|
|
//! subsequence scoring. Uses the `ignore` crate for directory walking
|
|
//! (respects `.gitignore`, hidden files, etc.).
|
|
|
|
use std::path::Path;
|
|
|
|
use napi::bindgen_prelude::*;
|
|
use napi_derive::napi;
|
|
|
|
use crate::{fs_cache, task};
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Public types
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Options for fuzzy file path search.
|
|
#[napi(object)]
|
|
pub struct FuzzyFindOptions {
|
|
/// Fuzzy query to match against file paths (case-insensitive).
|
|
pub query: String,
|
|
/// Directory to search.
|
|
pub path: String,
|
|
/// Include hidden files (default: false).
|
|
pub hidden: Option<bool>,
|
|
/// Respect .gitignore (default: true).
|
|
pub gitignore: Option<bool>,
|
|
/// Maximum number of matches to return (default: 100).
|
|
#[napi(js_name = "maxResults")]
|
|
pub max_results: Option<u32>,
|
|
}
|
|
|
|
/// A single match in fuzzy find results.
|
|
#[napi(object)]
|
|
pub struct FuzzyFindMatch {
|
|
/// Relative path from the search root (uses `/` separators).
|
|
pub path: String,
|
|
/// Whether this entry is a directory.
|
|
#[napi(js_name = "isDirectory")]
|
|
pub is_directory: bool,
|
|
/// Match quality score (higher is better).
|
|
pub score: u32,
|
|
}
|
|
|
|
/// Result of fuzzy file path search.
|
|
#[napi(object)]
|
|
pub struct FuzzyFindResult {
|
|
/// Matched entries (up to `maxResults`).
|
|
pub matches: Vec<FuzzyFindMatch>,
|
|
/// Total number of matches found (may exceed `matches.len()`).
|
|
#[napi(js_name = "totalMatches")]
|
|
pub total_matches: u32,
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Scoring
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Strips separators, whitespace, and punctuation for normalized fuzzy comparison.
|
|
fn normalize_fuzzy_text(value: &str) -> String {
|
|
value
|
|
.chars()
|
|
.filter(|ch| !ch.is_whitespace() && !matches!(ch, '/' | '\\' | '.' | '_' | '-'))
|
|
.flat_map(|ch| ch.to_lowercase())
|
|
.collect()
|
|
}
|
|
|
|
/// Scores a query as a subsequence of `target`. Returns 0 if not a subsequence.
|
|
fn fuzzy_subsequence_score(query_chars: &[char], target: &str) -> u32 {
|
|
if query_chars.is_empty() {
|
|
return 1;
|
|
}
|
|
let mut query_index = 0usize;
|
|
let mut gaps = 0u32;
|
|
let mut last_match_index: Option<usize> = None;
|
|
for (target_index, target_ch) in target.chars().enumerate() {
|
|
if query_index >= query_chars.len() {
|
|
break;
|
|
}
|
|
if query_chars[query_index] == target_ch {
|
|
if let Some(last_index) = last_match_index {
|
|
if target_index > last_index + 1 {
|
|
gaps = gaps.saturating_add(1);
|
|
}
|
|
}
|
|
last_match_index = Some(target_index);
|
|
query_index += 1;
|
|
}
|
|
}
|
|
if query_index != query_chars.len() {
|
|
return 0;
|
|
}
|
|
let gap_penalty = gaps.saturating_mul(5);
|
|
40u32.saturating_sub(gap_penalty).max(1)
|
|
}
|
|
|
|
/// Composite path scoring: exact > starts-with > contains > fuzzy subsequence.
|
|
fn score_fuzzy_path(
|
|
path: &str,
|
|
is_directory: bool,
|
|
query_lower: &str,
|
|
normalized_query: &str,
|
|
query_chars: &[char],
|
|
) -> u32 {
|
|
if query_lower.is_empty() {
|
|
return if is_directory { 11 } else { 1 };
|
|
}
|
|
|
|
let file_name = Path::new(path)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(path);
|
|
let lower_file_name = file_name.to_lowercase();
|
|
|
|
let mut score = if lower_file_name == query_lower {
|
|
120
|
|
} else if lower_file_name.starts_with(query_lower) {
|
|
100
|
|
} else if lower_file_name.contains(query_lower) {
|
|
80
|
|
} else {
|
|
let lower_path = path.to_lowercase();
|
|
if lower_path.contains(query_lower) {
|
|
60
|
|
} else {
|
|
let normalized_file_name = normalize_fuzzy_text(file_name);
|
|
let file_name_fuzzy = fuzzy_subsequence_score(query_chars, &normalized_file_name);
|
|
if file_name_fuzzy > 0 {
|
|
50 + file_name_fuzzy
|
|
} else {
|
|
let normalized_path = normalize_fuzzy_text(path);
|
|
let path_fuzzy = if normalized_path == normalized_query {
|
|
40
|
|
} else {
|
|
fuzzy_subsequence_score(query_chars, &normalized_path)
|
|
};
|
|
if path_fuzzy > 0 {
|
|
30 + path_fuzzy
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
if is_directory && score > 0 {
|
|
score += 10;
|
|
}
|
|
|
|
score
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Execution
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Saturating cast from u64 to u32.
|
|
fn clamp_u32(value: u64) -> u32 {
|
|
value.min(u32::MAX as u64) as u32
|
|
}
|
|
|
|
/// Fuzzy file path search for autocomplete and @-mention resolution.
|
|
///
|
|
/// Searches for files and directories whose paths match the query string.
|
|
/// Results are sorted by match quality (higher score = better match).
|
|
#[napi(js_name = "fuzzyFind")]
|
|
pub fn fuzzy_find(options: FuzzyFindOptions) -> Result<FuzzyFindResult> {
|
|
let root = fs_cache::resolve_search_path(&options.path)?;
|
|
let include_hidden = options.hidden.unwrap_or(false);
|
|
let respect_gitignore = options.gitignore.unwrap_or(true);
|
|
let max_results = options.max_results.unwrap_or(100) as usize;
|
|
|
|
if max_results == 0 {
|
|
return Ok(FuzzyFindResult {
|
|
matches: Vec::new(),
|
|
total_matches: 0,
|
|
});
|
|
}
|
|
|
|
let query_lower = options.query.trim().to_lowercase();
|
|
let normalized_query = normalize_fuzzy_text(&query_lower);
|
|
let query_chars: Vec<char> = normalized_query.chars().collect();
|
|
|
|
if !query_lower.is_empty() && normalized_query.is_empty() {
|
|
return Ok(FuzzyFindResult {
|
|
matches: Vec::new(),
|
|
total_matches: 0,
|
|
});
|
|
}
|
|
|
|
let ct = task::CancelToken::default();
|
|
let scan = fs_cache::get_or_scan(&root, include_hidden, respect_gitignore, &ct)?;
|
|
let mut scored = collect_matches(&scan.entries, &query_lower, &normalized_query, &query_chars);
|
|
|
|
if scored.is_empty() && scan.cache_age_ms >= fs_cache::empty_recheck_ms() {
|
|
let fresh = fs_cache::force_rescan(&root, include_hidden, respect_gitignore, true, &ct)?;
|
|
scored = collect_matches(&fresh, &query_lower, &normalized_query, &query_chars);
|
|
}
|
|
|
|
scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
|
|
let total_matches = clamp_u32(scored.len() as u64);
|
|
let matches = scored.into_iter().take(max_results).collect();
|
|
|
|
Ok(FuzzyFindResult {
|
|
matches,
|
|
total_matches,
|
|
})
|
|
}
|
|
|
|
fn collect_matches(
|
|
entries: &[fs_cache::GlobMatch],
|
|
query_lower: &str,
|
|
normalized_query: &str,
|
|
query_chars: &[char],
|
|
) -> Vec<FuzzyFindMatch> {
|
|
let mut scored: Vec<FuzzyFindMatch> = Vec::with_capacity(entries.len().min(256));
|
|
|
|
for entry in entries {
|
|
if entry.file_type == fs_cache::FileType::Symlink {
|
|
continue;
|
|
}
|
|
|
|
let is_directory = entry.file_type == fs_cache::FileType::Dir;
|
|
let score = score_fuzzy_path(
|
|
&entry.path,
|
|
is_directory,
|
|
&query_lower,
|
|
&normalized_query,
|
|
&query_chars,
|
|
);
|
|
if score == 0 {
|
|
continue;
|
|
}
|
|
|
|
let mut path = entry.path.clone();
|
|
if is_directory {
|
|
path.push('/');
|
|
}
|
|
scored.push(FuzzyFindMatch {
|
|
path,
|
|
is_directory,
|
|
score,
|
|
});
|
|
}
|
|
|
|
scored
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Tests
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_normalize_fuzzy_text() {
|
|
assert_eq!(normalize_fuzzy_text("foo/bar.ts"), "foobarts");
|
|
assert_eq!(normalize_fuzzy_text("my_file-name.rs"), "myfilenamers");
|
|
assert_eq!(normalize_fuzzy_text("MyFile"), "myfile");
|
|
assert_eq!(normalize_fuzzy_text(""), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_subsequence_score_exact() {
|
|
let query: Vec<char> = "abc".chars().collect();
|
|
let score = fuzzy_subsequence_score(&query, "abc");
|
|
assert_eq!(score, 40);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_subsequence_score_with_gaps() {
|
|
let query: Vec<char> = "ac".chars().collect();
|
|
let score = fuzzy_subsequence_score(&query, "abc");
|
|
assert_eq!(score, 35);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_subsequence_score_no_match() {
|
|
let query: Vec<char> = "xyz".chars().collect();
|
|
let score = fuzzy_subsequence_score(&query, "abc");
|
|
assert_eq!(score, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_fuzzy_subsequence_score_empty_query() {
|
|
let query: Vec<char> = Vec::new();
|
|
let score = fuzzy_subsequence_score(&query, "abc");
|
|
assert_eq!(score, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_exact_filename() {
|
|
let score = score_fuzzy_path(
|
|
"src/main.rs",
|
|
false,
|
|
"main.rs",
|
|
"mainrs",
|
|
&"mainrs".chars().collect::<Vec<_>>(),
|
|
);
|
|
assert_eq!(score, 120);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_starts_with() {
|
|
let score = score_fuzzy_path(
|
|
"src/main.rs",
|
|
false,
|
|
"main",
|
|
"main",
|
|
&"main".chars().collect::<Vec<_>>(),
|
|
);
|
|
assert_eq!(score, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_contains() {
|
|
let score = score_fuzzy_path(
|
|
"src/my_main.rs",
|
|
false,
|
|
"main",
|
|
"main",
|
|
&"main".chars().collect::<Vec<_>>(),
|
|
);
|
|
assert_eq!(score, 80);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_directory_bonus() {
|
|
let file_score = score_fuzzy_path(
|
|
"src/main.rs",
|
|
false,
|
|
"main.rs",
|
|
"mainrs",
|
|
&"mainrs".chars().collect::<Vec<_>>(),
|
|
);
|
|
let dir_score = score_fuzzy_path(
|
|
"src/main.rs",
|
|
true,
|
|
"main.rs",
|
|
"mainrs",
|
|
&"mainrs".chars().collect::<Vec<_>>(),
|
|
);
|
|
assert_eq!(dir_score, file_score + 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_empty_query() {
|
|
let file_score = score_fuzzy_path("src/main.rs", false, "", "", &[]);
|
|
let dir_score = score_fuzzy_path("src/", true, "", "", &[]);
|
|
assert_eq!(file_score, 1);
|
|
assert_eq!(dir_score, 11);
|
|
}
|
|
|
|
#[test]
|
|
fn test_score_fuzzy_path_no_match() {
|
|
let score = score_fuzzy_path(
|
|
"src/main.rs",
|
|
false,
|
|
"xyz",
|
|
"xyz",
|
|
&"xyz".chars().collect::<Vec<_>>(),
|
|
);
|
|
assert_eq!(score, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_walk_directory_real_fs() {
|
|
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let entries =
|
|
fs_cache::force_rescan(&root, false, true, false, &task::CancelToken::default())
|
|
.expect("force_rescan should succeed");
|
|
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
|
|
assert!(
|
|
paths.iter().any(|p| p.contains("fd.rs")),
|
|
"Should find fd.rs in {paths:?}"
|
|
);
|
|
assert!(
|
|
paths.iter().any(|p| p.contains("lib.rs")),
|
|
"Should find lib.rs in {paths:?}"
|
|
);
|
|
}
|
|
}
|