Add fuzzy_nucleo crate for order independent file finder search (#51164)

Bhuminjay Soni created

Closes #14428 

Before you mark this PR as ready for review, make sure that you have:
- [ ] Added a solid test coverage and/or screenshots from doing manual
testing


https://github.com/user-attachments/assets/7e0d67ff-cc4e-4609-880d-5c1794c64dcf


- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- Adds a new `fuzzy_nucleo` crate that implements order independent path
matching using the `nucleo` library. currently integrated for file
finder.

---------

Signed-off-by: Bhuminjay <bhuminjaysoni@gmail.com>
Signed-off-by: 11happy <soni5happy@gmail.com>

Change summary

Cargo.lock                                  |  32 ++
Cargo.toml                                  |   3 
crates/file_finder/Cargo.toml               |   1 
crates/file_finder/src/file_finder.rs       |  69 +---
crates/file_finder/src/file_finder_tests.rs | 230 +++++++++++++++
crates/fuzzy_nucleo/Cargo.toml              |  21 +
crates/fuzzy_nucleo/LICENSE-GPL             |   1 
crates/fuzzy_nucleo/src/fuzzy_nucleo.rs     |   5 
crates/fuzzy_nucleo/src/matcher.rs          |  39 ++
crates/fuzzy_nucleo/src/paths.rs            | 352 +++++++++++++++++++++++
crates/project/Cargo.toml                   |   1 
crates/project/src/project.rs               |  70 ++++
12 files changed, 774 insertions(+), 50 deletions(-)

Detailed changes

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",

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" ] }

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

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<Picker<Self>>,
     ) -> Task<()> {
-        let raw_query = raw_query.replace(' ', "");
         let raw_query = raw_query.trim();
 
         let raw_query = match &raw_query.get(0..2) {

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",
+        );
+    });
+}

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"]}

crates/fuzzy_nucleo/src/matcher.rs 🔗

@@ -0,0 +1,39 @@
+use std::sync::Mutex;
+
+static MATCHERS: Mutex<Vec<nucleo::Matcher>> = 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<nucleo::Matcher> {
+    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<nucleo::Matcher>) {
+    MATCHERS.lock().unwrap().append(&mut matchers);
+}

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<usize>,
+    pub worktree_id: usize,
+    pub path: Arc<RelPath>,
+    pub path_prefix: Arc<RelPath>,
+    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<Item = PathMatchCandidate<'a>>;
+    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<RelPath>;
+    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<Ordering> {
+        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<Atom> {
+    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<Item = PathMatchCandidate<'a>>,
+    results: &mut Vec<PathMatch>,
+    worktree_id: usize,
+    path_prefix: &Arc<RelPath>,
+    root_is_file: bool,
+    relative_to: &Option<Arc<RelPath>>,
+    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<u32> = 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<usize> = 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<PathMatchCandidate>,
+    worktree_id: usize,
+    worktree_root_name: Option<Arc<RelPath>>,
+    query: &str,
+    smart_case: bool,
+    max_results: usize,
+    path_style: PathStyle,
+) -> Vec<PathMatch> {
+    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<Arc<RelPath>>,
+    smart_case: bool,
+    max_results: usize,
+    cancel_flag: &AtomicBool,
+    executor: BackgroundExecutor,
+) -> Vec<PathMatch> {
+    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::<Vec<_>>();
+    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
+}

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

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<RelPath> {
+        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::Item> {
+        self.traversal
+            .next()
+            .map(|entry| fuzzy_nucleo::PathMatchCandidate {
+                is_dir: entry.kind.is_dir(),
+                path: &entry.path,
+            })
+    }
+}
+
 impl EventEmitter<Event> for Project {}
 
 impl<'a> From<&'a ProjectPath> for SettingsLocation<'a> {