1use std::sync::OnceLock;
6
7use fuzzy_matcher::skim::SkimMatcherV2;
8use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait;
9
10use crate::package::Script;
11
12static GLOBAL_MATCHER: OnceLock<SkimMatcherV2> = OnceLock::new();
15
16fn global_matcher() -> &'static SkimMatcherV2 {
18 GLOBAL_MATCHER.get_or_init(SkimMatcherV2::default)
19}
20
21pub struct FuzzyMatcher {
23 matcher: SkimMatcherV2,
24 case_sensitive: bool,
25 search_descriptions: bool,
26}
27
28impl FuzzyMatcher {
29 pub fn new() -> Self {
31 Self {
32 matcher: SkimMatcherV2::default(),
33 case_sensitive: false,
34 search_descriptions: true,
35 }
36 }
37
38 pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
40 self.case_sensitive = case_sensitive;
41 self
42 }
43
44 pub fn search_descriptions(mut self, search_descriptions: bool) -> Self {
46 self.search_descriptions = search_descriptions;
47 self
48 }
49
50 pub fn match_script(&self, script: &Script, query: &str) -> Option<i64> {
54 if query.is_empty() {
55 return Some(0);
56 }
57
58 let query = if self.case_sensitive {
59 query.to_string()
60 } else {
61 query.to_lowercase()
62 };
63
64 let name = if self.case_sensitive {
65 script.name().to_string()
66 } else {
67 script.name().to_lowercase()
68 };
69
70 if let Some(score) = self.matcher.fuzzy_match(&name, &query) {
72 return Some(score);
73 }
74
75 if self.search_descriptions {
77 if let Some(desc) = script.description() {
78 let desc = if self.case_sensitive {
79 desc.to_string()
80 } else {
81 desc.to_lowercase()
82 };
83
84 if let Some(score) = self.matcher.fuzzy_match(&desc, &query) {
85 return Some(score / 2);
87 }
88 }
89 }
90
91 None
92 }
93}
94
95impl Default for FuzzyMatcher {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101pub fn filter_scripts(
133 query: &str,
134 scripts: &[Script],
135 search_descriptions: bool,
136) -> Vec<(usize, i64)> {
137 if query.is_empty() {
139 return (0..scripts.len()).map(|i| (i, 0)).collect();
140 }
141
142 let matcher = global_matcher();
143 let query_lower = query.to_lowercase();
144
145 let mut matches: Vec<(usize, i64)> = Vec::with_capacity(scripts.len().min(32));
147
148 let mut name_buffer = String::with_capacity(64);
150
151 for (idx, script) in scripts.iter().enumerate() {
152 name_buffer.clear();
154 name_buffer.extend(script.name().chars().flat_map(|c| c.to_lowercase()));
155
156 if let Some(score) = matcher.fuzzy_match(&name_buffer, &query_lower) {
157 matches.push((idx, score));
158 continue;
159 }
160
161 if search_descriptions {
163 if let Some(desc) = script.description() {
164 name_buffer.clear();
165 name_buffer.extend(desc.chars().flat_map(|c| c.to_lowercase()));
166
167 if let Some(score) = matcher.fuzzy_match(&name_buffer, &query_lower) {
168 matches.push((idx, score / 2));
170 }
171 }
172 }
173 }
174
175 matches.sort_unstable_by(|a, b| b.1.cmp(&a.1));
177
178 matches
179}
180
181pub fn filter_scripts_with_matcher<'a>(
185 scripts: impl IntoIterator<Item = &'a Script>,
186 query: &str,
187 matcher: &FuzzyMatcher,
188) -> Vec<(&'a Script, i64)> {
189 let mut matches: Vec<_> = scripts
190 .into_iter()
191 .filter_map(|s| matcher.match_script(s, query).map(|score| (s, score)))
192 .collect();
193
194 matches.sort_by(|a, b| b.1.cmp(&a.1));
196
197 matches
198}
199
200pub fn get_match_indices(query: &str, text: &str) -> Vec<usize> {
222 if query.is_empty() || text.is_empty() {
223 return Vec::new();
224 }
225
226 let matcher = global_matcher();
227 let query_lower = query.to_lowercase();
228 let text_lower = text.to_lowercase();
229
230 matcher
231 .fuzzy_indices(&text_lower, &query_lower)
232 .map(|(_, indices)| indices)
233 .unwrap_or_default()
234}
235
236pub fn matches(query: &str, text: &str) -> bool {
247 if query.is_empty() {
248 return true;
249 }
250
251 let matcher = global_matcher();
252 let query_lower = query.to_lowercase();
253 let text_lower = text.to_lowercase();
254
255 matcher.fuzzy_match(&text_lower, &query_lower).is_some()
256}
257
258pub fn match_score(query: &str, text: &str) -> Option<i64> {
269 if query.is_empty() {
270 return Some(0);
271 }
272
273 let matcher = global_matcher();
274 let query_lower = query.to_lowercase();
275 let text_lower = text.to_lowercase();
276
277 matcher.fuzzy_match(&text_lower, &query_lower)
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
287 fn test_empty_query_matches_all() {
288 let matcher = FuzzyMatcher::new();
289 let script = Script::new("dev", "vite");
290
291 assert!(matcher.match_script(&script, "").is_some());
292 assert_eq!(matcher.match_script(&script, "").unwrap(), 0);
293 }
294
295 #[test]
296 fn test_exact_match() {
297 let matcher = FuzzyMatcher::new();
298 let script = Script::new("dev", "vite");
299
300 assert!(matcher.match_script(&script, "dev").is_some());
301 }
302
303 #[test]
304 fn test_fuzzy_match() {
305 let matcher = FuzzyMatcher::new();
306 let script = Script::new("build", "vite build");
307
308 assert!(matcher.match_script(&script, "bd").is_some());
310 }
311
312 #[test]
313 fn test_no_match() {
314 let matcher = FuzzyMatcher::new();
315 let script = Script::new("dev", "vite");
316
317 assert!(matcher.match_script(&script, "xyz").is_none());
318 }
319
320 #[test]
321 fn test_case_insensitive() {
322 let matcher = FuzzyMatcher::new().case_sensitive(false);
323 let script = Script::new("DEV", "vite");
324
325 assert!(matcher.match_script(&script, "dev").is_some());
326 }
327
328 #[test]
329 fn test_case_sensitive() {
330 let matcher = FuzzyMatcher::new().case_sensitive(true);
331 let script = Script::new("DEV", "vite");
332
333 assert!(matcher.match_script(&script, "DEV").is_some());
335 let matcher_insensitive = FuzzyMatcher::new().case_sensitive(false);
341 assert!(matcher_insensitive.match_script(&script, "dev").is_some());
343 }
344
345 #[test]
348 fn test_filter_scripts_empty_query() {
349 let scripts = vec![
350 Script::new("dev", "vite"),
351 Script::new("build", "vite build"),
352 Script::new("test", "vitest"),
353 ];
354
355 let results = filter_scripts("", &scripts, false);
356 assert_eq!(results.len(), 3);
357 for (_, score) in &results {
359 assert_eq!(*score, 0);
360 }
361 }
362
363 #[test]
364 fn test_filter_scripts_exact_match() {
365 let scripts = vec![
366 Script::new("dev", "vite"),
367 Script::new("build", "vite build"),
368 Script::new("test", "vitest"),
369 ];
370
371 let results = filter_scripts("dev", &scripts, false);
372 assert_eq!(results.len(), 1);
373 assert_eq!(results[0].0, 0); }
375
376 #[test]
377 fn test_filter_scripts_fuzzy_match() {
378 let scripts = vec![
379 Script::new("development", "vite"),
380 Script::new("build", "vite build"),
381 Script::new("deploy", "deploy.sh"),
382 ];
383
384 let results = filter_scripts("dev", &scripts, false);
386 assert!(results.len() >= 1);
387 assert_eq!(results[0].0, 0);
389 }
390
391 #[test]
392 fn test_filter_scripts_no_match() {
393 let scripts = vec![
394 Script::new("dev", "vite"),
395 Script::new("build", "vite build"),
396 ];
397
398 let results = filter_scripts("xyz", &scripts, false);
399 assert!(results.is_empty());
400 }
401
402 #[test]
403 fn test_filter_scripts_description_search() {
404 let scripts = vec![
405 Script::new("start", "node server.js"),
406 Script::with_description("build", "vite build", "Build for production"),
407 ];
408
409 let results = filter_scripts("production", &scripts, false);
411 assert!(results.is_empty());
412
413 let results = filter_scripts("production", &scripts, true);
415 assert_eq!(results.len(), 1);
416 assert_eq!(results[0].0, 1); }
418
419 #[test]
420 fn test_filter_scripts_sorted_by_score() {
421 let scripts = vec![
422 Script::new("test-unit", "vitest unit"),
423 Script::new("test", "vitest"),
424 Script::new("test-e2e", "vitest e2e"),
425 ];
426
427 let results = filter_scripts("test", &scripts, false);
428 assert!(!results.is_empty());
429 assert_eq!(results.len(), 3);
431 let test_score = results.iter().find(|(idx, _)| *idx == 1).map(|(_, s)| *s);
434 let test_unit_score = results.iter().find(|(idx, _)| *idx == 0).map(|(_, s)| *s);
435 assert!(test_score.unwrap() >= test_unit_score.unwrap());
437 }
438
439 #[test]
440 fn test_exact_match_scores_higher_than_fuzzy() {
441 let scripts = vec![
442 Script::new("bd", "command1"),
443 Script::new("build", "command2"),
444 ];
445
446 let results = filter_scripts("bd", &scripts, false);
447 assert!(results.len() >= 1);
448 assert_eq!(results[0].0, 0);
450 assert!(results[0].1 > results.get(1).map(|r| r.1).unwrap_or(0));
451 }
452
453 #[test]
454 fn test_prefix_match_scores_higher_than_middle() {
455 let scripts = vec![
456 Script::new("rebuild", "command1"),
457 Script::new("build", "command2"),
458 ];
459
460 let results = filter_scripts("build", &scripts, false);
461 assert!(results.len() >= 1);
462 assert_eq!(results[0].0, 1);
464 }
465
466 #[test]
467 fn test_filter_scripts_case_insensitive() {
468 let scripts = vec![
469 Script::new("DEV", "vite"),
470 Script::new("Build", "vite build"),
471 ];
472
473 let results = filter_scripts("dev", &scripts, false);
474 assert_eq!(results.len(), 1);
475 assert_eq!(results[0].0, 0);
476
477 let results = filter_scripts("BUILD", &scripts, false);
478 assert_eq!(results.len(), 1);
479 assert_eq!(results[0].0, 1);
480 }
481
482 #[test]
485 fn test_get_match_indices_basic() {
486 let indices = get_match_indices("bd", "build");
487 assert_eq!(indices, vec![0, 4]); }
489
490 #[test]
491 fn test_get_match_indices_exact() {
492 let indices = get_match_indices("build", "build");
493 assert_eq!(indices, vec![0, 1, 2, 3, 4]);
494 }
495
496 #[test]
497 fn test_get_match_indices_empty_query() {
498 let indices = get_match_indices("", "build");
499 assert!(indices.is_empty());
500 }
501
502 #[test]
503 fn test_get_match_indices_empty_text() {
504 let indices = get_match_indices("bd", "");
505 assert!(indices.is_empty());
506 }
507
508 #[test]
509 fn test_get_match_indices_no_match() {
510 let indices = get_match_indices("xyz", "build");
511 assert!(indices.is_empty());
512 }
513
514 #[test]
515 fn test_get_match_indices_case_insensitive() {
516 let indices = get_match_indices("BD", "build");
517 assert_eq!(indices, vec![0, 4]);
518 }
519
520 #[test]
523 fn test_matches_empty_query() {
524 assert!(matches("", "anything"));
525 }
526
527 #[test]
528 fn test_matches_exact() {
529 assert!(matches("dev", "dev"));
530 }
531
532 #[test]
533 fn test_matches_fuzzy() {
534 assert!(matches("bd", "build"));
535 }
536
537 #[test]
538 fn test_matches_no_match() {
539 assert!(!matches("xyz", "build"));
540 }
541
542 #[test]
545 fn test_match_score_empty_query() {
546 assert_eq!(match_score("", "anything"), Some(0));
547 }
548
549 #[test]
550 fn test_match_score_exact() {
551 let score = match_score("dev", "dev");
552 assert!(score.is_some());
553 assert!(score.unwrap() > 0);
554 }
555
556 #[test]
557 fn test_match_score_no_match() {
558 assert!(match_score("xyz", "build").is_none());
559 }
560
561 #[test]
564 fn test_filter_many_scripts() {
565 let scripts: Vec<Script> = (0..150)
567 .map(|i| Script::new(format!("script-{i}"), format!("command-{i}")))
568 .collect();
569
570 let results = filter_scripts("script-50", &scripts, false);
571 assert!(!results.is_empty());
572 assert_eq!(results[0].0, 50);
574 }
575
576 #[test]
577 fn test_filter_special_characters() {
578 let scripts = vec![
579 Script::new("build:prod", "vite build --mode prod"),
580 Script::new("build:dev", "vite build --mode dev"),
581 Script::new("test:unit", "vitest unit"),
582 ];
583
584 let results = filter_scripts("build:", &scripts, false);
585 assert_eq!(results.len(), 2);
586 }
587
588 #[test]
589 fn test_description_scores_lower_than_name() {
590 let scripts = vec![
591 Script::with_description("start", "node index.js", "dev server"),
592 Script::new("dev", "vite"),
593 ];
594
595 let results = filter_scripts("dev", &scripts, true);
596 assert_eq!(results.len(), 2);
597 assert_eq!(results[0].0, 1);
599 }
600
601 #[test]
602 fn test_global_matcher_consistency() {
603 let score1 = match_score("dev", "development");
605 let score2 = match_score("dev", "development");
606 assert_eq!(score1, score2);
607 }
608
609 #[test]
610 fn test_empty_scripts_list() {
611 let scripts: Vec<Script> = vec![];
612 let results = filter_scripts("dev", &scripts, false);
613 assert!(results.is_empty());
614 }
615
616 #[test]
617 fn test_unicode_support() {
618 let scripts = vec![
619 Script::new("développement", "vite"),
620 Script::new("build", "vite build"),
621 ];
622
623 let results = filter_scripts("dév", &scripts, false);
624 assert!(!results.is_empty());
625 }
626}