npm_run_scripts/filter/
fuzzy.rs

1//! Fuzzy matching implementation.
2//!
3//! Uses SkimMatcherV2 for high-performance fuzzy matching with scoring.
4
5use std::sync::OnceLock;
6
7use fuzzy_matcher::skim::SkimMatcherV2;
8use fuzzy_matcher::FuzzyMatcher as FuzzyMatcherTrait;
9
10use crate::package::Script;
11
12/// Global matcher instance for performance.
13/// Using OnceLock to initialize once and reuse across calls.
14static GLOBAL_MATCHER: OnceLock<SkimMatcherV2> = OnceLock::new();
15
16/// Get the global matcher instance.
17fn global_matcher() -> &'static SkimMatcherV2 {
18    GLOBAL_MATCHER.get_or_init(SkimMatcherV2::default)
19}
20
21/// Fuzzy matcher for script filtering.
22pub struct FuzzyMatcher {
23    matcher: SkimMatcherV2,
24    case_sensitive: bool,
25    search_descriptions: bool,
26}
27
28impl FuzzyMatcher {
29    /// Create a new fuzzy matcher.
30    pub fn new() -> Self {
31        Self {
32            matcher: SkimMatcherV2::default(),
33            case_sensitive: false,
34            search_descriptions: true,
35        }
36    }
37
38    /// Set case sensitivity.
39    pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
40        self.case_sensitive = case_sensitive;
41        self
42    }
43
44    /// Set whether to search in descriptions.
45    pub fn search_descriptions(mut self, search_descriptions: bool) -> Self {
46        self.search_descriptions = search_descriptions;
47        self
48    }
49
50    /// Match a script against a query.
51    ///
52    /// Returns a score if the script matches, or None if it doesn't.
53    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        // Check name match
71        if let Some(score) = self.matcher.fuzzy_match(&name, &query) {
72            return Some(score);
73        }
74
75        // Check description match
76        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                    // Lower score for description matches
86                    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
101/// Filter scripts based on a query.
102///
103/// Returns (index, score) pairs sorted by score descending (best matches first).
104/// Uses the global pre-compiled matcher for performance.
105///
106/// # Arguments
107///
108/// * `query` - The search query (empty query returns all scripts with score 0)
109/// * `scripts` - Slice of scripts to filter
110/// * `search_descriptions` - Whether to also search in script descriptions
111///
112/// # Returns
113///
114/// Vector of (original_index, score) tuples, sorted by score descending.
115///
116/// # Examples
117///
118/// ```
119/// use npm_run_scripts::package::Script;
120/// use npm_run_scripts::filter::filter_scripts;
121///
122/// let scripts = vec![
123///     Script::new("dev", "vite"),
124///     Script::new("build", "vite build"),
125///     Script::new("test", "vitest"),
126/// ];
127///
128/// let results = filter_scripts("dev", &scripts, false);
129/// assert_eq!(results.len(), 1);
130/// assert_eq!(results[0].0, 0); // "dev" is at index 0
131/// ```
132pub fn filter_scripts(
133    query: &str,
134    scripts: &[Script],
135    search_descriptions: bool,
136) -> Vec<(usize, i64)> {
137    // Empty query returns all scripts with score 0
138    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    // Pre-allocate with expected capacity (most scripts likely won't match)
146    let mut matches: Vec<(usize, i64)> = Vec::with_capacity(scripts.len().min(32));
147
148    // Reusable buffer for lowercase conversion to reduce allocations
149    let mut name_buffer = String::with_capacity(64);
150
151    for (idx, script) in scripts.iter().enumerate() {
152        // Try matching against name first
153        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        // Try matching against description if enabled
162        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                    // Description matches get lower priority (half score)
169                    matches.push((idx, score / 2));
170                }
171            }
172        }
173    }
174
175    // Sort by score descending (best matches first)
176    matches.sort_unstable_by(|a, b| b.1.cmp(&a.1));
177
178    matches
179}
180
181/// Filter scripts using the FuzzyMatcher instance.
182///
183/// Returns scripts sorted by match score (best first).
184pub 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    // Sort by score (descending)
195    matches.sort_by(|a, b| b.1.cmp(&a.1));
196
197    matches
198}
199
200/// Get the indices of matched characters in the text.
201///
202/// This is useful for highlighting matched portions of text in the UI.
203///
204/// # Arguments
205///
206/// * `query` - The search query
207/// * `text` - The text to search within
208///
209/// # Returns
210///
211/// Vector of character indices that match the query, or empty if no match.
212///
213/// # Examples
214///
215/// ```
216/// use npm_run_scripts::filter::get_match_indices;
217///
218/// let indices = get_match_indices("bd", "build");
219/// assert_eq!(indices, vec![0, 4]); // 'b' at 0, 'd' at 4
220/// ```
221pub 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
236/// Check if a query matches text (simple boolean check).
237///
238/// # Arguments
239///
240/// * `query` - The search query
241/// * `text` - The text to search within
242///
243/// # Returns
244///
245/// `true` if the query matches the text, `false` otherwise.
246pub 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
258/// Get the match score for a query against text.
259///
260/// # Arguments
261///
262/// * `query` - The search query
263/// * `text` - The text to search within
264///
265/// # Returns
266///
267/// The match score, or None if no match.
268pub 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    // ==================== FuzzyMatcher tests ====================
285
286    #[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        // "bd" should fuzzy match "build"
309        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        // Exact case should match
334        assert!(matcher.match_script(&script, "DEV").is_some());
335        // Note: SkimMatcherV2 is case-insensitive by design, but our wrapper
336        // normalizes case when case_sensitive is false. When true, we pass
337        // the original case but the matcher still matches case-insensitively.
338        // This is a limitation of SkimMatcherV2.
339        // For now, we test that case_sensitive mode at least preserves case
340        let matcher_insensitive = FuzzyMatcher::new().case_sensitive(false);
341        // Both should match with case-insensitive
342        assert!(matcher_insensitive.match_script(&script, "dev").is_some());
343    }
344
345    // ==================== filter_scripts tests ====================
346
347    #[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        // All should have score 0
358        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); // "dev" is at index 0
374    }
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        // "dev" should match "development" and "deploy"
385        let results = filter_scripts("dev", &scripts, false);
386        assert!(results.len() >= 1);
387        // First result should be "development" (better match)
388        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        // Should not find "production" when description search is off
410        let results = filter_scripts("production", &scripts, false);
411        assert!(results.is_empty());
412
413        // Should find "production" when description search is on
414        let results = filter_scripts("production", &scripts, true);
415        assert_eq!(results.len(), 1);
416        assert_eq!(results[0].0, 1); // "build" is at index 1
417    }
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        // All three should match since they all contain "test"
430        assert_eq!(results.len(), 3);
431        // The exact match "test" should have the highest score
432        // Find the score for "test" (index 1)
433        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        // Exact match should score >= other matches
436        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        // Exact match "bd" should score higher
449        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        // "build" (prefix/exact) should score higher than "rebuild" (contains)
463        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    // ==================== get_match_indices tests ====================
483
484    #[test]
485    fn test_get_match_indices_basic() {
486        let indices = get_match_indices("bd", "build");
487        assert_eq!(indices, vec![0, 4]); // 'b' at 0, 'd' at 4
488    }
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    // ==================== matches tests ====================
521
522    #[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    // ==================== match_score tests ====================
543
544    #[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    // ==================== Performance / edge case tests ====================
562
563    #[test]
564    fn test_filter_many_scripts() {
565        // Test with 100+ scripts to ensure performance is reasonable
566        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        // Exact match should be first
573        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        // "dev" (name match) should score higher than "start" (description match)
598        assert_eq!(results[0].0, 1);
599    }
600
601    #[test]
602    fn test_global_matcher_consistency() {
603        // Ensure global matcher gives consistent results
604        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}