diff --git a/Cargo.lock b/Cargo.lock index 97412711a55667a4976a35313eb6c0388acc74ef..cbc494f9dc0fc1858a846fabe168b3538de4dbe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6183,6 +6183,7 @@ dependencies = [ "file_icons", "futures 0.3.32", "fuzzy", + "fuzzy_nucleo", "gpui", "menu", "open_path_prompt", @@ -6740,6 +6741,15 @@ dependencies = [ "thread_local", ] +[[package]] +name = "fuzzy_nucleo" +version = "0.1.0" +dependencies = [ + "gpui", + "nucleo", + "util", +] + [[package]] name = "gaoya" version = "0.2.0" @@ -11063,6 +11073,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num" version = "0.4.3" @@ -13203,6 +13234,7 @@ dependencies = [ "fs", "futures 0.3.32", "fuzzy", + "fuzzy_nucleo", "git", "git2", "git_hosting_providers", diff --git a/Cargo.toml b/Cargo.toml index 5cb5b991b645ec1b78b16f48493c7c8dc1426344..4c75dafae5df4d63815e0da5cabb95ccdad25e9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "crates/fs", "crates/fs_benchmarks", "crates/fuzzy", + "crates/fuzzy_nucleo", "crates/git", "crates/git_graph", "crates/git_hosting_providers", @@ -325,6 +326,7 @@ file_finder = { path = "crates/file_finder" } file_icons = { path = "crates/file_icons" } fs = { path = "crates/fs" } fuzzy = { path = "crates/fuzzy" } +fuzzy_nucleo = { path = "crates/fuzzy_nucleo" } git = { path = "crates/git" } git_graph = { path = "crates/git_graph" } git_hosting_providers = { path = "crates/git_hosting_providers" } @@ -609,6 +611,7 @@ naga = { version = "29.0", features = ["wgsl-in"] } nanoid = "0.4" nbformat = "1.2.0" nix = "0.29" +nucleo = "0.5" num-format = "0.4.4" objc = "0.2" objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] } diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 5eb36f0f5150263629b407dbe07dc73b6eff31cf..67ebab62295e8db90a12f99cbc05e9b9e56c2c6b 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -21,6 +21,7 @@ editor.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true +fuzzy_nucleo.workspace = true gpui.workspace = true menu.workspace = true open_path_prompt.workspace = true diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 4302669ddc11c94f7df128534217d00c27ef083a..a4d9ea042dea898b9dd9db7d40354cf960d210d5 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -9,7 +9,8 @@ use client::ChannelId; use collections::HashMap; use editor::Editor; use file_icons::FileIcons; -use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate}; +use fuzzy::{StringMatch, StringMatchCandidate}; +use fuzzy_nucleo::{PathMatch, PathMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity, @@ -663,15 +664,6 @@ impl Matches { // For file-vs-file matches, use the existing detailed comparison. if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) { - let a_in_filename = Self::is_filename_match(a_panel); - let b_in_filename = Self::is_filename_match(b_panel); - - match (a_in_filename, b_in_filename) { - (true, false) => return cmp::Ordering::Greater, - (false, true) => return cmp::Ordering::Less, - _ => {} - } - return a_panel.cmp(b_panel); } @@ -691,32 +683,6 @@ impl Matches { Match::CreateNew(_) => 0.0, } } - - /// Determines if the match occurred within the filename rather than in the path - fn is_filename_match(panel_match: &ProjectPanelOrdMatch) -> bool { - if panel_match.0.positions.is_empty() { - return false; - } - - if let Some(filename) = panel_match.0.path.file_name() { - let path_str = panel_match.0.path.as_unix_str(); - - if let Some(filename_pos) = path_str.rfind(filename) - && panel_match.0.positions[0] >= filename_pos - { - let mut prev_position = panel_match.0.positions[0]; - for p in &panel_match.0.positions[1..] { - if *p != prev_position + 1 { - return false; - } - prev_position = *p; - } - return true; - } - } - - false - } } fn matching_history_items<'a>( @@ -731,25 +697,16 @@ fn matching_history_items<'a>( let history_items_by_worktrees = history_items .into_iter() .chain(currently_opened) - .filter_map(|found_path| { + .map(|found_path| { let candidate = PathMatchCandidate { is_dir: false, // You can't open directories as project items path: &found_path.project.path, // Only match history items names, otherwise their paths may match too many queries, producing false positives. // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item, // it would be shown first always, despite the latter being a better match. - char_bag: CharBag::from_iter( - found_path - .project - .path - .file_name()? - .to_string() - .to_lowercase() - .chars(), - ), }; candidates_paths.insert(&found_path.project, found_path); - Some((found_path.project.worktree_id, candidate)) + (found_path.project.worktree_id, candidate) }) .fold( HashMap::default(), @@ -767,8 +724,9 @@ fn matching_history_items<'a>( let worktree_root_name = worktree_name_by_id .as_ref() .and_then(|w| w.get(&worktree).cloned()); + matching_history_paths.extend( - fuzzy::match_fixed_path_set( + fuzzy_nucleo::match_fixed_path_set( candidates, worktree.to_usize(), worktree_root_name, @@ -778,6 +736,18 @@ fn matching_history_items<'a>( path_style, ) .into_iter() + // filter matches where at least one matched position is in filename portion, to prevent directory matches, nucleo scores them higher as history items are matched against their full path + .filter(|path_match| { + if let Some(filename) = path_match.path.file_name() { + let filename_start = path_match.path.as_unix_str().len() - filename.len(); + path_match + .positions + .iter() + .any(|&pos| pos >= filename_start) + } else { + true + } + }) .filter_map(|path_match| { candidates_paths .remove_entry(&ProjectPath { @@ -940,7 +910,7 @@ impl FileFinderDelegate { self.cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_flag = self.cancel_flag.clone(); cx.spawn_in(window, async move |picker, cx| { - let matches = fuzzy::match_path_sets( + let matches = fuzzy_nucleo::match_path_sets( candidate_sets.as_slice(), query.path_query(), &relative_to, @@ -1452,7 +1422,6 @@ impl PickerDelegate for FileFinderDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { - let raw_query = raw_query.replace(' ', ""); let raw_query = raw_query.trim(); let raw_query = match &raw_query.get(0..2) { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index cd9cdeee1ff266717d380aeaecf7cbeb66ec8309..7a17202a5e4ba96b001ea46ed310518d02baf1ff 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -4161,3 +4161,233 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { "Should have no history items after clearing" ); } + +#[gpui::test] +async fn test_order_independent_search(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "internal": { + "auth": { + "login.rs": "", + } + } + }), + ) + .await; + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (picker, _, cx) = build_find_picker(project, cx); + + // forward order + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("auth internal"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs"); + }); + + // reverse order should give same result + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("internal auth"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs"); + }); +} + +#[gpui::test] +async fn test_filename_preferred_over_directory_match(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "crates": { + "settings_ui": { + "src": { + "pages": { + "audio_test_window.rs": "", + "audio_input_output_setup.rs": "", + } + } + }, + "audio": { + "src": { + "audio_settings.rs": "", + } + } + } + }), + ) + .await; + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (picker, _, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("settings audio"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert!(!matches.is_empty(),); + assert_eq!( + matches[0].path.as_unix_str(), + "crates/audio/src/audio_settings.rs" + ); + }); +} + +#[gpui::test] +async fn test_start_of_word_preferred_over_scattered_match(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "crates": { + "livekit_client": { + "src": { + "livekit_client": { + "playback.rs": "", + } + } + }, + "vim": { + "test_data": { + "test_record_replay_interleaved.json": "", + } + } + } + }), + ) + .await; + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (picker, _, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("live pla"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert!(!matches.is_empty(),); + assert_eq!( + matches[0].path.as_unix_str(), + "crates/livekit_client/src/livekit_client/playback.rs", + ); + }); +} + +#[gpui::test] +async fn test_exact_filename_stem_preferred(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "assets": { + "icons": { + "file_icons": { + "nix.svg": "", + } + } + }, + "crates": { + "zed": { + "resources": { + "app-icon-nightly@2x.png": "", + "app-icon-preview@2x.png": "", + } + } + } + }), + ) + .await; + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (picker, _, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("nix icon"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert!(!matches.is_empty(),); + assert_eq!( + matches[0].path.as_unix_str(), + "assets/icons/file_icons/nix.svg", + ); + }); +} + +#[gpui::test] +async fn test_exact_filename_with_directory_token(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/src", + json!({ + "crates": { + "agent_servers": { + "src": { + "acp.rs": "", + "agent_server.rs": "", + "custom.rs": "", + } + } + } + }), + ) + .await; + let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; + let (picker, _, cx) = build_find_picker(project, cx); + + picker + .update_in(cx, |picker, window, cx| { + picker + .delegate + .spawn_search(test_path_position("acp server"), window, cx) + }) + .await; + picker.update(cx, |picker, _| { + let matches = collect_search_matches(picker).search_matches_only(); + assert!(!matches.is_empty(),); + assert_eq!( + matches[0].path.as_unix_str(), + "crates/agent_servers/src/acp.rs", + ); + }); +} diff --git a/crates/fuzzy_nucleo/Cargo.toml b/crates/fuzzy_nucleo/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..59e8b642524777f449f79edba85093eef069ebff --- /dev/null +++ b/crates/fuzzy_nucleo/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fuzzy_nucleo" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/fuzzy_nucleo.rs" +doctest = false + +[dependencies] +nucleo.workspace = true +gpui.workspace = true +util.workspace = true + +[dev-dependencies] +util = {workspace = true, features = ["test-support"]} diff --git a/crates/fuzzy_nucleo/LICENSE-GPL b/crates/fuzzy_nucleo/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/fuzzy_nucleo/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs new file mode 100644 index 0000000000000000000000000000000000000000..ddaa5c3489cf55d41d31440f037214b1dce0358c --- /dev/null +++ b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs @@ -0,0 +1,5 @@ +mod matcher; +mod paths; +pub use paths::{ + PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets, +}; diff --git a/crates/fuzzy_nucleo/src/matcher.rs b/crates/fuzzy_nucleo/src/matcher.rs new file mode 100644 index 0000000000000000000000000000000000000000..b31da011106341420095bcffbfd012f40014ad6c --- /dev/null +++ b/crates/fuzzy_nucleo/src/matcher.rs @@ -0,0 +1,39 @@ +use std::sync::Mutex; + +static MATCHERS: Mutex> = Mutex::new(Vec::new()); + +pub const LENGTH_PENALTY: f64 = 0.01; + +pub fn get_matcher(config: nucleo::Config) -> nucleo::Matcher { + let mut matchers = MATCHERS.lock().unwrap(); + match matchers.pop() { + Some(mut matcher) => { + matcher.config = config; + matcher + } + None => nucleo::Matcher::new(config), + } +} + +pub fn return_matcher(matcher: nucleo::Matcher) { + MATCHERS.lock().unwrap().push(matcher); +} + +pub fn get_matchers(n: usize, config: nucleo::Config) -> Vec { + let mut matchers: Vec<_> = { + let mut pool = MATCHERS.lock().unwrap(); + let available = pool.len().min(n); + pool.drain(..available) + .map(|mut matcher| { + matcher.config = config.clone(); + matcher + }) + .collect() + }; + matchers.resize_with(n, || nucleo::Matcher::new(config.clone())); + matchers +} + +pub fn return_matchers(mut matchers: Vec) { + MATCHERS.lock().unwrap().append(&mut matchers); +} diff --git a/crates/fuzzy_nucleo/src/paths.rs b/crates/fuzzy_nucleo/src/paths.rs new file mode 100644 index 0000000000000000000000000000000000000000..ac766622c9d12c6e2a119fbcd7dd7fe7a3b5a90d --- /dev/null +++ b/crates/fuzzy_nucleo/src/paths.rs @@ -0,0 +1,352 @@ +use gpui::BackgroundExecutor; +use std::{ + cmp::Ordering, + sync::{ + Arc, + atomic::{self, AtomicBool}, + }, +}; +use util::{paths::PathStyle, rel_path::RelPath}; + +use nucleo::Utf32Str; +use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization}; + +use crate::matcher::{self, LENGTH_PENALTY}; + +#[derive(Clone, Debug)] +pub struct PathMatchCandidate<'a> { + pub is_dir: bool, + pub path: &'a RelPath, +} + +#[derive(Clone, Debug)] +pub struct PathMatch { + pub score: f64, + pub positions: Vec, + pub worktree_id: usize, + pub path: Arc, + pub path_prefix: Arc, + pub is_dir: bool, + /// Number of steps removed from a shared parent with the relative path + /// Used to order closer paths first in the search list + pub distance_to_relative_ancestor: usize, +} + +pub trait PathMatchCandidateSet<'a>: Send + Sync { + type Candidates: Iterator>; + fn id(&self) -> usize; + fn len(&self) -> usize; + fn is_empty(&self) -> bool { + self.len() == 0 + } + fn root_is_file(&self) -> bool; + fn prefix(&self) -> Arc; + fn candidates(&'a self, start: usize) -> Self::Candidates; + fn path_style(&self) -> PathStyle; +} + +impl PartialEq for PathMatch { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl Eq for PathMatch {} + +impl PartialOrd for PathMatch { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PathMatch { + fn cmp(&self, other: &Self) -> Ordering { + self.score + .partial_cmp(&other.score) + .unwrap_or(Ordering::Equal) + .then_with(|| self.worktree_id.cmp(&other.worktree_id)) + .then_with(|| { + other + .distance_to_relative_ancestor + .cmp(&self.distance_to_relative_ancestor) + }) + .then_with(|| self.path.cmp(&other.path)) + } +} + +fn make_atoms(query: &str, smart_case: bool) -> Vec { + let case = if smart_case { + CaseMatching::Smart + } else { + CaseMatching::Ignore + }; + query + .split_whitespace() + .map(|word| Atom::new(word, case, Normalization::Smart, AtomKind::Fuzzy, false)) + .collect() +} + +pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize { + let mut path_components = path.components(); + let mut relative_components = relative_to.components(); + + while path_components + .next() + .zip(relative_components.next()) + .map(|(path_component, relative_component)| path_component == relative_component) + .unwrap_or_default() + {} + path_components.count() + relative_components.count() + 1 +} + +fn get_filename_match_bonus( + candidate_buf: &str, + query_atoms: &[Atom], + matcher: &mut nucleo::Matcher, +) -> f64 { + let filename = match std::path::Path::new(candidate_buf).file_name() { + Some(f) => f.to_str().unwrap_or(""), + None => return 0.0, + }; + if filename.is_empty() || query_atoms.is_empty() { + return 0.0; + } + let mut buf = Vec::new(); + let haystack = Utf32Str::new(filename, &mut buf); + let mut total_score = 0u32; + for atom in query_atoms { + if let Some(score) = atom.score(haystack, matcher) { + total_score = total_score.saturating_add(score as u32); + } + } + total_score as f64 / filename.len().max(1) as f64 +} +struct Cancelled; + +fn path_match_helper<'a>( + matcher: &mut nucleo::Matcher, + atoms: &[Atom], + candidates: impl Iterator>, + results: &mut Vec, + worktree_id: usize, + path_prefix: &Arc, + root_is_file: bool, + relative_to: &Option>, + path_style: PathStyle, + cancel_flag: &AtomicBool, +) -> Result<(), Cancelled> { + let mut candidate_buf = if !path_prefix.is_empty() && !root_is_file { + let mut s = path_prefix.display(path_style).to_string(); + s.push_str(path_style.primary_separator()); + s + } else { + String::new() + }; + let path_prefix_len = candidate_buf.len(); + let mut buf = Vec::new(); + let mut matched_chars: Vec = Vec::new(); + let mut atom_matched_chars = Vec::new(); + for candidate in candidates { + buf.clear(); + matched_chars.clear(); + if cancel_flag.load(atomic::Ordering::Relaxed) { + return Err(Cancelled); + } + + candidate_buf.truncate(path_prefix_len); + if root_is_file { + candidate_buf.push_str(path_prefix.as_unix_str()); + } else { + candidate_buf.push_str(candidate.path.as_unix_str()); + } + + let haystack = Utf32Str::new(&candidate_buf, &mut buf); + + let mut total_score: u32 = 0; + let mut all_matched = true; + + for atom in atoms { + atom_matched_chars.clear(); + if let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) { + total_score = total_score.saturating_add(score as u32); + matched_chars.extend_from_slice(&atom_matched_chars); + } else { + all_matched = false; + break; + } + } + + if all_matched && !atoms.is_empty() { + matched_chars.sort_unstable(); + matched_chars.dedup(); + + let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY; + let filename_bonus = get_filename_match_bonus(&candidate_buf, atoms, matcher); + let adjusted_score = total_score as f64 + filename_bonus - length_penalty; + let mut positions: Vec = candidate_buf + .char_indices() + .enumerate() + .filter_map(|(char_offset, (byte_offset, _))| { + matched_chars + .contains(&(char_offset as u32)) + .then_some(byte_offset) + }) + .collect(); + positions.sort_unstable(); + + results.push(PathMatch { + score: adjusted_score, + positions, + worktree_id, + path: if root_is_file { + Arc::clone(path_prefix) + } else { + candidate.path.into() + }, + path_prefix: if root_is_file { + RelPath::empty().into() + } else { + Arc::clone(path_prefix) + }, + is_dir: candidate.is_dir, + distance_to_relative_ancestor: relative_to + .as_ref() + .map_or(usize::MAX, |relative_to| { + distance_between_paths(candidate.path, relative_to.as_ref()) + }), + }); + } + } + Ok(()) +} + +pub fn match_fixed_path_set( + candidates: Vec, + worktree_id: usize, + worktree_root_name: Option>, + query: &str, + smart_case: bool, + max_results: usize, + path_style: PathStyle, +) -> Vec { + let mut config = nucleo::Config::DEFAULT; + config.set_match_paths(); + let mut matcher = matcher::get_matcher(config); + + let atoms = make_atoms(query, smart_case); + + let root_is_file = worktree_root_name.is_some() && candidates.iter().all(|c| c.path.is_empty()); + + let path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into()); + + let mut results = Vec::new(); + + path_match_helper( + &mut matcher, + &atoms, + candidates.into_iter(), + &mut results, + worktree_id, + &path_prefix, + root_is_file, + &None, + path_style, + &AtomicBool::new(false), + ) + .ok(); + util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + matcher::return_matcher(matcher); + results +} + +pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>( + candidate_sets: &'a [Set], + query: &str, + relative_to: &Option>, + smart_case: bool, + max_results: usize, + cancel_flag: &AtomicBool, + executor: BackgroundExecutor, +) -> Vec { + let path_count: usize = candidate_sets.iter().map(|s| s.len()).sum(); + if path_count == 0 { + return Vec::new(); + } + + let path_style = candidate_sets[0].path_style(); + + let query = if path_style.is_windows() { + query.replace('\\', "/") + } else { + query.to_owned() + }; + + let atoms = make_atoms(&query, smart_case); + + let num_cpus = executor.num_cpus().min(path_count); + let segment_size = path_count.div_ceil(num_cpus); + let mut segment_results = (0..num_cpus) + .map(|_| Vec::with_capacity(max_results)) + .collect::>(); + let mut config = nucleo::Config::DEFAULT; + config.set_match_paths(); + let mut matchers = matcher::get_matchers(num_cpus, config); + executor + .scoped(|scope| { + for (segment_idx, (results, matcher)) in segment_results + .iter_mut() + .zip(matchers.iter_mut()) + .enumerate() + { + let atoms = atoms.clone(); + let relative_to = relative_to.clone(); + scope.spawn(async move { + let segment_start = segment_idx * segment_size; + let segment_end = segment_start + segment_size; + + let mut tree_start = 0; + for candidate_set in candidate_sets { + let tree_end = tree_start + candidate_set.len(); + + if tree_start < segment_end && segment_start < tree_end { + let start = tree_start.max(segment_start) - tree_start; + let end = tree_end.min(segment_end) - tree_start; + let candidates = candidate_set.candidates(start).take(end - start); + + if path_match_helper( + matcher, + &atoms, + candidates, + results, + candidate_set.id(), + &candidate_set.prefix(), + candidate_set.root_is_file(), + &relative_to, + path_style, + cancel_flag, + ) + .is_err() + { + break; + } + } + + if tree_end >= segment_end { + break; + } + tree_start = tree_end; + } + }); + } + }) + .await; + + matcher::return_matchers(matchers); + if cancel_flag.load(atomic::Ordering::Acquire) { + return Vec::new(); + } + + let mut results = segment_results.concat(); + util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a)); + results +} diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index cd037786a399eb979fd5d9053c57efe3100dd473..628e979aab939a74bb4838477ae3e3657e2c91bc 100644 --- a/crates/project/Cargo.toml +++ b/crates/project/Cargo.toml @@ -52,6 +52,7 @@ fancy-regex.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true +fuzzy_nucleo.workspace = true git.workspace = true git_hosting_providers.workspace = true globset.workspace = true diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0ec3366ca8f9f6c6e4e3cbd411e1894de4d0f2b8..b90972b3489c25f8a2bf10d7dbdb6d6cfe0c4c6c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -6186,6 +6186,76 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> { } } +impl<'a> fuzzy_nucleo::PathMatchCandidateSet<'a> for PathMatchCandidateSet { + type Candidates = PathMatchCandidateSetNucleoIter<'a>; + fn id(&self) -> usize { + self.snapshot.id().to_usize() + } + fn len(&self) -> usize { + match self.candidates { + Candidates::Files => { + if self.include_ignored { + self.snapshot.file_count() + } else { + self.snapshot.visible_file_count() + } + } + Candidates::Directories => { + if self.include_ignored { + self.snapshot.dir_count() + } else { + self.snapshot.visible_dir_count() + } + } + Candidates::Entries => { + if self.include_ignored { + self.snapshot.entry_count() + } else { + self.snapshot.visible_entry_count() + } + } + } + } + fn prefix(&self) -> Arc { + if self.snapshot.root_entry().is_some_and(|e| e.is_file()) || self.include_root_name { + self.snapshot.root_name().into() + } else { + RelPath::empty().into() + } + } + fn root_is_file(&self) -> bool { + self.snapshot.root_entry().is_some_and(|f| f.is_file()) + } + fn path_style(&self) -> PathStyle { + self.snapshot.path_style() + } + fn candidates(&'a self, start: usize) -> Self::Candidates { + PathMatchCandidateSetNucleoIter { + traversal: match self.candidates { + Candidates::Directories => self.snapshot.directories(self.include_ignored, start), + Candidates::Files => self.snapshot.files(self.include_ignored, start), + Candidates::Entries => self.snapshot.entries(self.include_ignored, start), + }, + } + } +} + +pub struct PathMatchCandidateSetNucleoIter<'a> { + traversal: Traversal<'a>, +} + +impl<'a> Iterator for PathMatchCandidateSetNucleoIter<'a> { + type Item = fuzzy_nucleo::PathMatchCandidate<'a>; + fn next(&mut self) -> Option { + self.traversal + .next() + .map(|entry| fuzzy_nucleo::PathMatchCandidate { + is_dir: entry.kind.is_dir(), + path: &entry.path, + }) + } +} + impl EventEmitter for Project {} impl<'a> From<&'a ProjectPath> for SettingsLocation<'a> {