Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,7 @@ PUNICODE = "PUNICODE"
extend-exclude = [
"crates/fspy_detours_sys/detours",
"crates/fspy_detours_sys/src/generated_bindings.rs",
# Intentional typos for testing fuzzy matching and "did you mean" suggestions
"crates/vite_select/src/fuzzy.rs",
"crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-select",
]
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ Test fixtures and snapshots:
- If a feature can't work on a platform, it shouldn't be added

2. **Windows Cross-Testing from macOS**:
`cargo xtest` cross-compiles the test binary and runs it on a real remote Windows environment (not emulation). The filesystem is bridged so the test can access local fixture files.
```bash
# Test on Windows (aarch64) from macOS via cross-compilation
cargo xtest --builder cargo-xwin --target aarch64-pc-windows-msvc -p <package> --test <test>

# Examples:
Expand Down
59 changes: 56 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,11 @@ memmap2 = "0.9.7"
monostate = "1.0.2"
nix = { version = "0.30.1", features = ["dir"] }
ntapi = "0.4.1"
nucleo-matcher = "0.3.1"
once_cell = "1.19"
os_str_bytes = "7.1.1"
ouroboros = "0.18.5"
owo-colors = "4.1.0"
owo-colors = { version = "4.1.0", features = ["supports-colors"] }
passfd = { git = "https://github.com/polachok/passfd", rev = "d55881752c16aced1a49a75f9c428d38d3767213", default-features = false }
pathdiff = "0.2.3"
petgraph = "0.8.2"
Expand Down Expand Up @@ -135,6 +136,7 @@ vec1 = "1.12.1"
vite_glob = { path = "crates/vite_glob" }
vite_graph_ser = { path = "crates/vite_graph_ser" }
vite_path = { path = "crates/vite_path" }
vite_select = { path = "crates/vite_select" }
vite_shell = { path = "crates/vite_shell" }
vite_str = { path = "crates/vite_str" }
vite_task = { path = "crates/vite_task" }
Expand Down
21 changes: 21 additions & 0 deletions crates/vite_select/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "vite_select"
version = "0.0.0"
authors.workspace = true
edition.workspace = true
license.workspace = true
publish = false
rust-version.workspace = true

[lints]
workspace = true

[dependencies]
anyhow = { workspace = true }
crossterm = { workspace = true }
nucleo-matcher = { workspace = true }
owo-colors = { workspace = true }
vite_str = { path = "../vite_str" }

[dev-dependencies]
assert2 = { workspace = true }
104 changes: 104 additions & 0 deletions crates/vite_select/src/fuzzy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use nucleo_matcher::{
Matcher,
pattern::{AtomKind, CaseMatching, Normalization, Pattern},
};

/// Fuzzy-match `query` against a list of strings.
///
/// Returns original indices sorted by score descending (best match first).
/// When `query` is empty, returns all indices in their original order.
#[must_use]
pub fn fuzzy_match(query: &str, items: &[&str]) -> Vec<usize> {
if query.is_empty() {
return (0..items.len()).collect();
}

let pattern = Pattern::new(query, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy);
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);

let mut scored: Vec<(usize, u32)> = items
.iter()
.enumerate()
.filter_map(|(idx, item)| {
pattern
.score(nucleo_matcher::Utf32Str::Ascii(item.as_bytes()), &mut matcher)
.map(|score| (idx, score))
})
.collect();

scored.sort_by(|a, b| b.1.cmp(&a.1));
scored.into_iter().map(|(idx, _)| idx).collect()
}

#[cfg(test)]
mod tests {
use assert2::{assert, check};

use super::*;

const TASK_NAMES: &[&str] =
&["build", "lint", "test", "app#build", "app#lint", "app#test", "lib#build"];

#[test]
fn exact_match_scores_highest() {
let results = fuzzy_match("build", TASK_NAMES);
assert!(!results.is_empty());
// "build" should be the highest-scoring match
check!(TASK_NAMES[results[0]] == "build");
}

#[test]
fn typo_matches_similar() {
let results = fuzzy_match("buid", TASK_NAMES);
assert!(!results.is_empty());
// Should match "build" and "app#build" and "lib#build" but not "lint" or "test"
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
check!(matched_names.contains(&"build"));
for name in &matched_names {
check!(!name.contains("lint"));
check!(!name.contains("test"));
}
}

#[test]
fn empty_query_returns_all() {
let results = fuzzy_match("", TASK_NAMES);
check!(results.len() == TASK_NAMES.len());
// Indices should be in original order
for (pos, &idx) in results.iter().enumerate() {
check!(idx == pos);
}
}

#[test]
fn completely_unrelated_query_returns_nothing() {
let results = fuzzy_match("zzzzz", TASK_NAMES);
check!(results.is_empty());
}

#[test]
fn package_qualified_match() {
let results = fuzzy_match("app#build", TASK_NAMES);
assert!(!results.is_empty());
check!(TASK_NAMES[results[0]] == "app#build");
}

#[test]
fn lint_matches_lint_tasks() {
let results = fuzzy_match("lint", TASK_NAMES);
assert!(!results.is_empty());
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
check!(matched_names.contains(&"lint"));
check!(matched_names.contains(&"app#lint"));
}

#[test]
fn score_ordering_exact_before_fuzzy() {
let results = fuzzy_match("build", TASK_NAMES);
assert!(results.len() >= 2);
// Exact "build" should appear before "app#build" (higher score = earlier position)
let build_pos = results.iter().position(|&i| TASK_NAMES[i] == "build").unwrap();
let app_build_pos = results.iter().position(|&i| TASK_NAMES[i] == "app#build").unwrap();
check!(build_pos <= app_build_pos);
}
}
Loading