singularity-forge/native/crates/engine/src/fd.rs

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:?}"
);
}
}