Skip to content

Commit 0baf59b

Browse files
committed
feat: interactive task selector for vp run with fuzzy search
When `vp run` is called without a task or with a typo, show an interactive fuzzy-searchable selector (TTY) or a plain task list (piped). - Add vite_select crate with crossterm-based interactive widget and nucleo-matcher fuzzy search - Extract RunFlags from RunCommand for flag preservation across selection - Detect interactive vs non-interactive mode via stdin/stdout is_terminal - Show 'did you mean' suggestions for typos (including -r/--recursive) - Add RecursiveTaskNotFound error for recursive queries with no matches - Only intercept task-not-found at top level (non-empty call stack propagates as-is, fixing nested task error hanging) - Style list items as 'taskName: command' with cyan command color - E2E tests: list, did-you-mean, interactive select, search, scroll, cancel, flag preservation, nested task error propagation
1 parent b40f41f commit 0baf59b

36 files changed

Lines changed: 1207 additions & 94 deletions

Cargo.lock

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ memmap2 = "0.9.7"
8585
monostate = "1.0.2"
8686
nix = { version = "0.30.1", features = ["dir"] }
8787
ntapi = "0.4.1"
88+
nucleo-matcher = "0.3.1"
8889
once_cell = "1.19"
8990
os_str_bytes = "7.1.1"
9091
ouroboros = "0.18.5"
@@ -135,6 +136,7 @@ vec1 = "1.12.1"
135136
vite_glob = { path = "crates/vite_glob" }
136137
vite_graph_ser = { path = "crates/vite_graph_ser" }
137138
vite_path = { path = "crates/vite_path" }
139+
vite_select = { path = "crates/vite_select" }
138140
vite_shell = { path = "crates/vite_shell" }
139141
vite_str = { path = "crates/vite_str" }
140142
vite_task = { path = "crates/vite_task" }

crates/vite_select/Cargo.toml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[package]
2+
name = "vite_select"
3+
version = "0.0.0"
4+
authors.workspace = true
5+
edition.workspace = true
6+
license.workspace = true
7+
publish = false
8+
rust-version.workspace = true
9+
10+
[lints]
11+
workspace = true
12+
13+
[dependencies]
14+
anyhow = { workspace = true }
15+
crossterm = { workspace = true }
16+
nucleo-matcher = { workspace = true }
17+
vite_str = { path = "../vite_str" }
18+
19+
[dev-dependencies]
20+
assert2 = { workspace = true }

crates/vite_select/src/fuzzy.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use nucleo_matcher::{
2+
Matcher,
3+
pattern::{AtomKind, CaseMatching, Normalization, Pattern},
4+
};
5+
6+
/// Fuzzy-match `query` against a list of strings.
7+
///
8+
/// Returns original indices sorted by score descending (best match first).
9+
/// When `query` is empty, returns all indices in their original order.
10+
#[must_use]
11+
pub fn fuzzy_match(query: &str, items: &[&str]) -> Vec<usize> {
12+
if query.is_empty() {
13+
return (0..items.len()).collect();
14+
}
15+
16+
let pattern = Pattern::new(query, CaseMatching::Ignore, Normalization::Smart, AtomKind::Fuzzy);
17+
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
18+
19+
let mut scored: Vec<(usize, u32)> = items
20+
.iter()
21+
.enumerate()
22+
.filter_map(|(idx, item)| {
23+
pattern
24+
.score(nucleo_matcher::Utf32Str::Ascii(item.as_bytes()), &mut matcher)
25+
.map(|score| (idx, score))
26+
})
27+
.collect();
28+
29+
scored.sort_by(|a, b| b.1.cmp(&a.1));
30+
scored.into_iter().map(|(idx, _)| idx).collect()
31+
}
32+
33+
#[cfg(test)]
34+
mod tests {
35+
use assert2::{assert, check};
36+
37+
use super::*;
38+
39+
const TASK_NAMES: &[&str] =
40+
&["build", "lint", "test", "app#build", "app#lint", "app#test", "lib#build"];
41+
42+
#[test]
43+
fn exact_match_scores_highest() {
44+
let results = fuzzy_match("build", TASK_NAMES);
45+
assert!(!results.is_empty());
46+
// "build" should be the highest-scoring match
47+
check!(TASK_NAMES[results[0]] == "build");
48+
}
49+
50+
#[test]
51+
fn typo_matches_similar() {
52+
let results = fuzzy_match("buid", TASK_NAMES);
53+
assert!(!results.is_empty());
54+
// Should match "build" and "app#build" and "lib#build" but not "lint" or "test"
55+
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
56+
check!(matched_names.contains(&"build"));
57+
for name in &matched_names {
58+
check!(!name.contains("lint"));
59+
check!(!name.contains("test"));
60+
}
61+
}
62+
63+
#[test]
64+
fn empty_query_returns_all() {
65+
let results = fuzzy_match("", TASK_NAMES);
66+
check!(results.len() == TASK_NAMES.len());
67+
// Indices should be in original order
68+
for (pos, &idx) in results.iter().enumerate() {
69+
check!(idx == pos);
70+
}
71+
}
72+
73+
#[test]
74+
fn completely_unrelated_query_returns_nothing() {
75+
let results = fuzzy_match("zzzzz", TASK_NAMES);
76+
check!(results.is_empty());
77+
}
78+
79+
#[test]
80+
fn package_qualified_match() {
81+
let results = fuzzy_match("app#build", TASK_NAMES);
82+
assert!(!results.is_empty());
83+
check!(TASK_NAMES[results[0]] == "app#build");
84+
}
85+
86+
#[test]
87+
fn lint_matches_lint_tasks() {
88+
let results = fuzzy_match("lint", TASK_NAMES);
89+
assert!(!results.is_empty());
90+
let matched_names: Vec<&str> = results.iter().map(|&i| TASK_NAMES[i]).collect();
91+
check!(matched_names.contains(&"lint"));
92+
check!(matched_names.contains(&"app#lint"));
93+
}
94+
95+
#[test]
96+
fn score_ordering_exact_before_fuzzy() {
97+
let results = fuzzy_match("build", TASK_NAMES);
98+
assert!(results.len() >= 2);
99+
// Exact "build" should appear before "app#build" (higher score = earlier position)
100+
let build_pos = results.iter().position(|&i| TASK_NAMES[i] == "build").unwrap();
101+
let app_build_pos = results.iter().position(|&i| TASK_NAMES[i] == "app#build").unwrap();
102+
check!(build_pos <= app_build_pos);
103+
}
104+
}

0 commit comments

Comments
 (0)