use std::path::Path;
use napi::bindgen_prelude::*;
use napi_derive::napi;
use crate::{fs_cache, task};
#[napi(object)]
pub struct FuzzyFindOptions<'env> {
pub query: String,
pub path: String,
pub hidden: Option<bool>,
pub gitignore: Option<bool>,
pub cache: Option<bool>,
pub max_results: Option<u32>,
pub signal: Option<Unknown<'env>>,
pub timeout_ms: Option<u32>,
}
#[napi(object)]
pub struct FuzzyFindMatch {
pub path: String,
pub is_directory: bool,
pub score: u32,
}
#[napi(object)]
pub struct FuzzyFindResult {
pub matches: Vec<FuzzyFindMatch>,
pub total_matches: u32,
}
fn normalize_fuzzy_text(value: &str) -> String {
value
.chars()
.filter(|ch| !ch.is_whitespace() && !matches!(ch, '/' | '\\' | '.' | '_' | '-'))
.flat_map(|ch| ch.to_lowercase())
.collect()
}
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
&& 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)
}
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 };
}
// Match against the full relative path only when the user typed a path-style
// query (contains '/'). Plain queries should match by basename only, otherwise
// '@plan' surfaces every file whose ancestor directories contain 'plan'.
let query_has_slash = query_lower.contains('/');
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 if !query_has_slash {
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 {
0
}
} 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
}
struct FuzzyFindConfig {
query: String,
path: String,
hidden: Option<bool>,
gitignore: Option<bool>,
max_results: Option<u32>,
cache: Option<bool>,
}
fn score_entries(
entries: &[fs_cache::GlobMatch],
query_lower: &str,
normalized_query: &str,
query_chars: &[char],
ct: &task::CancelToken,
) -> Result<Vec<FuzzyFindMatch>> {
let mut scored = Vec::with_capacity(entries.len().min(256));
for entry in entries {
ct.heartbeat()?;
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 });
}
Ok(scored)
}
fn fuzzy_find_sync(config: FuzzyFindConfig, ct: task::CancelToken) -> Result<FuzzyFindResult> {
let root = fs_cache::resolve_search_path(&config.path)?;
let include_hidden = config.hidden.unwrap_or(false);
let respect_gitignore = config.gitignore.unwrap_or(true);
let max_results = config.max_results.unwrap_or(100) as usize;
if max_results == 0 {
return Ok(FuzzyFindResult { matches: Vec::new(), total_matches: 0 });
}
let query_lower = config.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 use_cache = config.cache.unwrap_or(false);
let scan_options = fs_cache::ScanOptions {
include_hidden,
use_gitignore: respect_gitignore,
skip_node_modules: true,
follow_links: true,
detail: fs_cache::ScanDetail::Minimal,
};
let mut scored = if use_cache {
let scan = fs_cache::get_or_scan(&root, scan_options, &ct)?;
let mut scored =
score_entries(&scan.entries, &query_lower, &normalized_query, &query_chars, &ct)?;
if scored.is_empty()
&& !query_lower.is_empty()
&& scan.cache_age_ms >= fs_cache::empty_recheck_ms()
{
let fresh = fs_cache::force_rescan(&root, scan_options, true, &ct)?;
scored = score_entries(&fresh, &query_lower, &normalized_query, &query_chars, &ct)?;
}
scored
} else {
let fresh = fs_cache::force_rescan(&root, scan_options, false, &ct)?;
score_entries(&fresh, &query_lower, &normalized_query, &query_chars, &ct)?
};
scored.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.path.cmp(&b.path)));
let total_matches = crate::utils::clamp_u32(scored.len() as u64);
let matches = scored.into_iter().take(max_results).collect();
Ok(FuzzyFindResult { matches, total_matches })
}
/// Fuzzy file path search for autocomplete.
#[napi(js_name = "fuzzyFind")]
pub fn fuzzy_find(options: FuzzyFindOptions<'_>) -> task::Promise<FuzzyFindResult> {
let FuzzyFindOptions { query, path, hidden, gitignore, cache, max_results, timeout_ms, signal } =
options;
let ct = task::CancelToken::new(timeout_ms, signal);
let config = FuzzyFindConfig { query, path, hidden, gitignore, max_results, cache };
task::blocking("fuzzy_find", ct, move |ct| fuzzy_find_sync(config, ct))
}