1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
use super::command::CommandDef;
use lru::LruCache;
use nucleo_matcher::{Matcher, Utf32String};
use std::num::NonZeroUsize;
#[derive(Clone)]
pub struct ScoredCommand {
pub command: &'static CommandDef,
pub score: u32,
pub match_ranges: Vec<(usize, usize)>,
}
pub struct FuzzyMatcher {
matcher: Matcher,
cache: LruCache<String, Vec<ScoredCommand>>,
}
impl FuzzyMatcher {
pub fn new() -> Self {
let matcher = Matcher::default();
// Cache size of 100 is safe (always non-zero)
// This is a compile-time constant, so unwrap is safe
let cache = LruCache::new(
NonZeroUsize::new(100)
.expect("Cache size must be non-zero (this is a compile-time constant)")
);
Self { matcher, cache }
}
pub fn filter_commands(
&mut self,
query: &str,
commands: &[&'static CommandDef],
workflow_context: Option<&crate::app::workflow::WorkflowContext>,
) -> Vec<ScoredCommand> {
// Check cache first
if let Some(cached) = self.cache.get(query) {
return cached.to_vec();
}
if query.is_empty() {
// Return all commands with neutral score
let results: Vec<ScoredCommand> = commands
.iter()
.map(|cmd| ScoredCommand {
command: *cmd,
score: 1000,
match_ranges: Vec::new(),
})
.collect();
self.cache.put(query.to_string(), results.clone());
return results;
}
// Early termination for exact prefix matches
// Precompute lowercase query once to avoid repeated allocations
let query_lower = query.to_lowercase();
let mut exact_matches = Vec::new();
let mut fuzzy_matches = Vec::new();
// Helper function for case-insensitive prefix matching
// Uses ASCII-only optimization for common case (most commands use ASCII)
#[inline]
fn case_insensitive_starts_with(s: &str, prefix: &str) -> bool {
if s.len() < prefix.len() {
return false;
}
// Try ASCII-only comparison first (fast path)
if s.is_ascii() && prefix.is_ascii() {
s[..prefix.len()].eq_ignore_ascii_case(prefix)
} else {
// Fallback to full Unicode lowercase comparison
s.to_lowercase().starts_with(prefix)
}
}
for cmd in commands {
// Use case-insensitive comparison without allocating new strings
let name_matches = case_insensitive_starts_with(cmd.name, &query_lower);
let key_matches = case_insensitive_starts_with(cmd.key, &query_lower);
// Check for exact prefix match (highest priority)
if name_matches || key_matches {
// Defensive: ensure match range is within bounds
let match_end = query.len().min(cmd.name.len());
exact_matches.push(ScoredCommand {
command: *cmd,
score: 2000,
match_ranges: vec![(0, match_end)],
});
continue;
}
// Check keywords with optimized case-insensitive comparison
let mut keyword_match = false;
for keyword in cmd.keywords {
if case_insensitive_starts_with(keyword, &query_lower) {
exact_matches.push(ScoredCommand {
command: *cmd,
score: 1800,
match_ranges: Vec::new(),
});
keyword_match = true;
break;
}
}
if keyword_match {
continue;
}
// Fuzzy match on name using nucleo-matcher
let name_utf32 = Utf32String::from(cmd.name);
let pattern_utf32 = Utf32String::from(query);
// Use slice(..) to get Utf32Str from Utf32String
let name_str = name_utf32.slice(..);
let pattern_str = pattern_utf32.slice(..);
// Try fuzzy match with indices for highlighting
let mut indices = Vec::new();
if let Some(score) = self.matcher.fuzzy_indices(name_str, pattern_str, &mut indices) {
let ranges = self.indices_to_ranges_u32(&indices);
fuzzy_matches.push(ScoredCommand {
command: *cmd,
score: score as u32,
match_ranges: ranges,
});
}
}
// Combine and sort: exact matches first, then fuzzy matches
let mut results = exact_matches;
results.append(&mut fuzzy_matches);
// Apply context-aware scoring boost
if let Some(context) = workflow_context {
for result in &mut results {
result.score += Self::get_context_boost(result.command, context);
}
}
results.sort_by(|a, b| b.score.cmp(&a.score));
// Cache results
// Use the processed query (which may be truncated) as cache key
// This prevents storing very long keys in cache while maintaining correctness
// since we already checked cache with original query above
let results_clone = results.iter().map(|r| ScoredCommand {
command: r.command,
score: r.score,
match_ranges: r.match_ranges.clone(),
}).collect();
self.cache.put(query.to_string(), results_clone);
results
}
fn indices_to_ranges_u32(&self, indices: &[u32]) -> Vec<(usize, usize)> {
if indices.is_empty() {
return Vec::new();
}
let mut ranges = Vec::new();
let mut start = indices[0] as usize;
let mut end = (indices[0] + 1) as usize;
for &idx in indices.iter().skip(1) {
let idx_usize = idx as usize;
// Defensive: ensure indices are valid (shouldn't happen with nucleo-matcher, but be safe)
if idx_usize == end {
end = idx_usize + 1;
} else {
// Only add range if start < end (defensive check)
if start < end {
ranges.push((start, end));
}
start = idx_usize;
end = idx_usize + 1;
}
}
// Final range
if start < end {
ranges.push((start, end));
}
ranges
}
/// Calculates a context-aware boost score for command ranking.
///
/// Commands are boosted based on the current Git workflow state to prioritize
/// relevant actions. Higher scores appear first in the command palette.
///
/// **Scoring Strategy:**
/// - Primary actions (most relevant): 500 points
/// - Secondary actions (helpful): 300-400 points
/// - Tertiary actions (related): 200 points
/// - Unrelated actions: 0 points (no boost)
///
/// **Note:** Uses command name matching since not all actions have CommandId variants.
fn get_context_boost(
cmd: &CommandDef,
context: &crate::app::workflow::WorkflowContext,
) -> u32 {
use crate::app::workflow::WorkflowState;
// Use case-insensitive contains check without allocating
// Most command names are ASCII, so we can optimize for that case
#[inline]
fn case_insensitive_contains(haystack: &str, needle: &str) -> bool {
// Fast path for ASCII strings (most common case)
if haystack.is_ascii() && needle.is_ascii() {
haystack.as_bytes().windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
} else {
// Fallback to lowercase comparison for Unicode
haystack.to_lowercase().contains(&needle.to_lowercase())
}
}
match context.state {
WorkflowState::Conflicts => Self::boost_for_conflicts(cmd.name, &case_insensitive_contains),
WorkflowState::RebaseInProgress => Self::boost_for_rebase(cmd.name, &case_insensitive_contains),
WorkflowState::MergeInProgress => Self::boost_for_merge(cmd.name, &case_insensitive_contains),
WorkflowState::Staging => Self::boost_for_staging(cmd.name, &case_insensitive_contains),
WorkflowState::Committing => Self::boost_for_committing(cmd.name, &case_insensitive_contains),
WorkflowState::CherryPickInProgress | WorkflowState::RevertInProgress => {
Self::boost_for_cherry_pick_or_revert(cmd.name, &case_insensitive_contains)
}
WorkflowState::Clean => Self::boost_for_clean_state(cmd.name, &case_insensitive_contains),
}
}
/// Boost scores for conflict resolution workflow.
fn boost_for_conflicts(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "conflict") || contains(cmd_name, "resolve") {
500 // Primary: resolve conflicts
} else if contains(cmd_name, "stage") {
300 // Secondary: stage resolved files
} else if contains(cmd_name, "commit") {
200 // Tertiary: commit after resolution
} else {
0
}
}
/// Boost scores for rebase workflow.
fn boost_for_rebase(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "rebase") && contains(cmd_name, "continue") {
500 // Primary: continue rebase
} else if contains(cmd_name, "rebase") && contains(cmd_name, "abort") {
400 // Secondary: abort rebase
} else if contains(cmd_name, "rebase") && contains(cmd_name, "todo") {
300 // Tertiary: view rebase todo
} else {
0
}
}
/// Boost scores for merge workflow.
fn boost_for_merge(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "commit") {
500 // Primary: complete merge
} else if contains(cmd_name, "conflict") {
400 // Secondary: resolve conflicts
} else {
0
}
}
/// Boost scores for staging workflow.
fn boost_for_staging(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "commit") {
500 // Primary: commit staged changes
} else if contains(cmd_name, "stage") {
400 // Secondary: stage more files
} else {
0
}
}
/// Boost scores for committing workflow.
fn boost_for_committing(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "commit") {
500 // Primary: submit or cancel commit
} else {
0
}
}
/// Boost scores for cherry-pick or revert workflow.
fn boost_for_cherry_pick_or_revert(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "commit") {
500 // Primary: complete cherry-pick/revert
} else {
0
}
}
/// Boost scores for clean state (no active workflow).
fn boost_for_clean_state(cmd_name: &str, contains: &dyn Fn(&str, &str) -> bool) -> u32 {
if contains(cmd_name, "push") {
300 // Secondary: push changes
} else if contains(cmd_name, "fetch") {
200 // Tertiary: fetch updates
} else {
0
}
}
}
impl Default for FuzzyMatcher {
fn default() -> Self {
Self::new()
}
}