Skip to main content

neco_fuzzy/
lib.rs

1//! Minimal fuzzy score core for commands, paths, and short identifiers.
2//!
3//! This crate focuses on pure scoring and ranking only. Filesystem indexing,
4//! caches, watchers, and UI rendering stay outside the crate.
5
6mod boundary;
7mod candidate;
8mod config;
9mod corpus;
10mod dp;
11mod encode;
12mod top_k;
13
14use core::cmp::Ordering;
15
16pub use boundary::candidate_fingerprint;
17pub use candidate::{OwnedPreparedCandidate, PreparedCandidate, PreparedCandidateRef};
18pub use config::{ScoreConfig, VALUE_SCALE};
19pub use corpus::CorpusStats;
20pub use encode::{
21    DecodeError, EncodeError, PreparedCandidateHeader, PREPARED_CANDIDATE_ALGORITHM_VERSION,
22    PREPARED_CANDIDATE_FORMAT_VERSION,
23};
24
25use dp::{chars_equal_caseless, dp_solve, dp_solve_ascii};
26use top_k::collect_top_k;
27
28/// Fuzzy score summary for a single query/candidate pair.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct Score {
31    /// Higher is better.
32    pub value: i64,
33    /// Lower is better.
34    pub energy: f32,
35    /// Confidence in `(0, 1]`. Higher is better.
36    pub confidence: f32,
37    /// Byte offset of the first matched character.
38    pub start: usize,
39    /// Exclusive byte offset after the last matched character.
40    pub end: usize,
41    /// Number of matched query characters.
42    pub matched: usize,
43}
44
45impl Eq for Score {}
46
47impl Ord for Score {
48    fn cmp(&self, other: &Self) -> Ordering {
49        self.value
50            .cmp(&other.value)
51            .then_with(|| other.start.cmp(&self.start))
52            .then_with(|| other.end.cmp(&self.end))
53            .then_with(|| self.matched.cmp(&other.matched))
54    }
55}
56
57impl PartialOrd for Score {
58    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
59        Some(self.cmp(other))
60    }
61}
62
63/// Ranked candidate returned from `top_k` variants.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct Match<'a> {
66    pub candidate: &'a str,
67    pub score: Score,
68    pub index: usize,
69}
70
71impl Eq for Match<'_> {}
72
73/// Query prepared for repeated matching.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct PreparedQuery {
76    chars: Vec<char>,
77    case_sensitive: bool,
78    ascii_bytes: Option<Vec<u8>>,
79    ascii_folded: Option<Vec<u8>>,
80}
81
82impl PreparedQuery {
83    /// Prepare a case-insensitive query.
84    pub fn new(query: &str) -> Self {
85        let ascii_bytes = query.is_ascii().then(|| query.as_bytes().to_vec());
86        let ascii_folded = ascii_bytes
87            .as_ref()
88            .map(|bytes| bytes.iter().map(u8::to_ascii_lowercase).collect());
89        Self {
90            chars: query.chars().collect(),
91            case_sensitive: false,
92            ascii_bytes,
93            ascii_folded,
94        }
95    }
96
97    /// Prepare a case-sensitive query.
98    pub fn new_case_sensitive(query: &str) -> Self {
99        let ascii_bytes = query.is_ascii().then(|| query.as_bytes().to_vec());
100        let ascii_folded = ascii_bytes
101            .as_ref()
102            .map(|bytes| bytes.iter().map(u8::to_ascii_lowercase).collect());
103        Self {
104            chars: query.chars().collect(),
105            case_sensitive: true,
106            ascii_bytes,
107            ascii_folded,
108        }
109    }
110
111    /// Return whether the prepared query uses case-sensitive matching.
112    pub fn is_case_sensitive(&self) -> bool {
113        self.case_sensitive
114    }
115
116    /// Return the query length in characters.
117    pub fn len(&self) -> usize {
118        self.chars.len()
119    }
120
121    /// Return whether the query is empty.
122    pub fn is_empty(&self) -> bool {
123        self.chars.is_empty()
124    }
125}
126
127/// Reusable working storage for repeated matching.
128#[derive(Debug, Default, Clone)]
129pub struct Scratch {
130    matched: Vec<usize>,
131    dp_cost: Vec<f32>,
132    dp_prev: Vec<usize>,
133    dp_cost_swap: Vec<f32>,
134    dp_prev_swap: Vec<usize>,
135}
136
137/// Score a candidate with the default case-insensitive matcher.
138pub fn score(query: &str, candidate: &str) -> Option<Score> {
139    score_with_config(query, candidate, &ScoreConfig::default())
140}
141
142/// Score a candidate with case-sensitive matching.
143pub fn score_case_sensitive(query: &str, candidate: &str) -> Option<Score> {
144    score_case_sensitive_with_config(query, candidate, &ScoreConfig::default())
145}
146
147/// Score a candidate with an explicit score config.
148pub fn score_with_config(query: &str, candidate: &str, config: &ScoreConfig) -> Option<Score> {
149    let query = PreparedQuery::new(query);
150    let candidate = PreparedCandidate::new(candidate);
151    let mut scratch = Scratch::default();
152    score_candidate_view(&query, candidate.as_ref(), config, &mut scratch, None)
153}
154
155/// Score a candidate with an explicit score config and corpus statistics.
156pub fn score_with_corpus(
157    query: &str,
158    candidate: &str,
159    config: &ScoreConfig,
160    stats: &CorpusStats,
161) -> Option<Score> {
162    let query = PreparedQuery::new(query);
163    let candidate = PreparedCandidate::new(candidate);
164    let mut scratch = Scratch::default();
165    score_candidate_view(
166        &query,
167        candidate.as_ref(),
168        config,
169        &mut scratch,
170        Some(stats),
171    )
172}
173
174/// Score a candidate with case-sensitive matching and an explicit score config.
175pub fn score_case_sensitive_with_config(
176    query: &str,
177    candidate: &str,
178    config: &ScoreConfig,
179) -> Option<Score> {
180    let query = PreparedQuery::new_case_sensitive(query);
181    let candidate = PreparedCandidate::new(candidate);
182    let mut scratch = Scratch::default();
183    score_candidate_view(&query, candidate.as_ref(), config, &mut scratch, None)
184}
185
186/// Score a prepared candidate with a prepared query.
187pub fn score_prepared(
188    query: &PreparedQuery,
189    candidate: &PreparedCandidate<'_>,
190    scratch: &mut Scratch,
191) -> Option<Score> {
192    score_prepared_with_config(query, candidate, &ScoreConfig::default(), scratch)
193}
194
195/// Score a prepared candidate with a prepared query and explicit config.
196pub fn score_prepared_with_config(
197    query: &PreparedQuery,
198    candidate: &PreparedCandidate<'_>,
199    config: &ScoreConfig,
200    scratch: &mut Scratch,
201) -> Option<Score> {
202    score_candidate_view(query, candidate.as_ref(), config, scratch, None)
203}
204
205/// Score a prepared candidate with a prepared query, config, and corpus stats.
206pub fn score_prepared_with_corpus(
207    query: &PreparedQuery,
208    candidate: &PreparedCandidate<'_>,
209    config: &ScoreConfig,
210    scratch: &mut Scratch,
211    stats: &CorpusStats,
212) -> Option<Score> {
213    score_candidate_view(query, candidate.as_ref(), config, scratch, Some(stats))
214}
215
216/// Score a borrowed prepared candidate view with a prepared query.
217pub fn score_prepared_ref(
218    query: &PreparedQuery,
219    candidate: PreparedCandidateRef<'_>,
220    scratch: &mut Scratch,
221) -> Option<Score> {
222    score_prepared_ref_with_config(query, candidate, &ScoreConfig::default(), scratch)
223}
224
225/// Score a borrowed prepared candidate view with explicit config.
226pub fn score_prepared_ref_with_config(
227    query: &PreparedQuery,
228    candidate: PreparedCandidateRef<'_>,
229    config: &ScoreConfig,
230    scratch: &mut Scratch,
231) -> Option<Score> {
232    score_candidate_view(query, candidate, config, scratch, None)
233}
234
235/// Score a borrowed prepared candidate view with explicit config and corpus stats.
236pub fn score_prepared_ref_with_corpus(
237    query: &PreparedQuery,
238    candidate: PreparedCandidateRef<'_>,
239    config: &ScoreConfig,
240    scratch: &mut Scratch,
241    stats: &CorpusStats,
242) -> Option<Score> {
243    score_candidate_view(query, candidate, config, scratch, Some(stats))
244}
245
246/// Score an owned prepared candidate with a prepared query.
247pub fn score_prepared_owned(
248    query: &PreparedQuery,
249    candidate: &OwnedPreparedCandidate,
250    scratch: &mut Scratch,
251) -> Option<Score> {
252    score_prepared_owned_with_config(query, candidate, &ScoreConfig::default(), scratch)
253}
254
255/// Score an owned prepared candidate with explicit config.
256pub fn score_prepared_owned_with_config(
257    query: &PreparedQuery,
258    candidate: &OwnedPreparedCandidate,
259    config: &ScoreConfig,
260    scratch: &mut Scratch,
261) -> Option<Score> {
262    score_candidate_view(query, candidate.as_ref(), config, scratch, None)
263}
264
265/// Score an owned prepared candidate with explicit config and corpus stats.
266pub fn score_prepared_owned_with_corpus(
267    query: &PreparedQuery,
268    candidate: &OwnedPreparedCandidate,
269    config: &ScoreConfig,
270    scratch: &mut Scratch,
271    stats: &CorpusStats,
272) -> Option<Score> {
273    score_candidate_view(query, candidate.as_ref(), config, scratch, Some(stats))
274}
275
276fn score_candidate_view(
277    query: &PreparedQuery,
278    candidate: PreparedCandidateRef<'_>,
279    config: &ScoreConfig,
280    scratch: &mut Scratch,
281    stats: Option<&CorpusStats>,
282) -> Option<Score> {
283    if !fill_match_positions(query, candidate, &mut scratch.matched) {
284        return None;
285    }
286
287    if query.is_empty() {
288        return Some(Score {
289            value: 0,
290            energy: 0.0,
291            confidence: 1.0,
292            start: 0,
293            end: 0,
294            matched: 0,
295        });
296    }
297
298    let energy = solve_match_positions(query, candidate, config, scratch, stats)?;
299    let exact_match = query.chars.len() == candidate.chars.len()
300        && query
301            .chars
302            .iter()
303            .zip(candidate.chars.iter())
304            .all(|(query_char, candidate_char)| *query_char == candidate_char.ch);
305    build_score(
306        candidate,
307        &scratch.matched,
308        query.len(),
309        energy,
310        config,
311        exact_match,
312    )
313}
314
315/// Write matched character byte offsets into `out`.
316pub fn match_indices(query: &str, candidate: &str, out: &mut Vec<usize>) -> bool {
317    let query = PreparedQuery::new(query);
318    let candidate = PreparedCandidate::new(candidate);
319    let mut scratch = Scratch::default();
320    match_indices_candidate_view(&query, candidate.as_ref(), out, &mut scratch)
321}
322
323/// Write matched character byte offsets into `out` using prepared inputs.
324pub fn match_indices_prepared(
325    query: &PreparedQuery,
326    candidate: &PreparedCandidate<'_>,
327    out: &mut Vec<usize>,
328    scratch: &mut Scratch,
329) -> bool {
330    match_indices_candidate_view(query, candidate.as_ref(), out, scratch)
331}
332
333/// Write matched character byte offsets into `out` using a borrowed prepared candidate view.
334pub fn match_indices_prepared_ref(
335    query: &PreparedQuery,
336    candidate: PreparedCandidateRef<'_>,
337    out: &mut Vec<usize>,
338    scratch: &mut Scratch,
339) -> bool {
340    match_indices_candidate_view(query, candidate, out, scratch)
341}
342
343fn match_indices_candidate_view(
344    query: &PreparedQuery,
345    candidate: PreparedCandidateRef<'_>,
346    out: &mut Vec<usize>,
347    scratch: &mut Scratch,
348) -> bool {
349    out.clear();
350    if !fill_match_positions(query, candidate, &mut scratch.matched) {
351        return false;
352    }
353    if query.is_empty() {
354        return true;
355    }
356    if solve_match_positions(query, candidate, &ScoreConfig::default(), scratch, None).is_none() {
357        return false;
358    }
359    out.extend(
360        scratch
361            .matched
362            .iter()
363            .map(|&index| candidate.chars[index].byte),
364    );
365    true
366}
367
368/// Rank the top `limit` candidates and write them into `out`.
369pub fn top_k<'a>(
370    query: &str,
371    candidates: &'a [&'a str],
372    limit: usize,
373    out: &mut Vec<Match<'a>>,
374) -> usize {
375    top_k_with_config(query, candidates, limit, &ScoreConfig::default(), out)
376}
377
378/// Rank the top `limit` candidates with an explicit config.
379pub fn top_k_with_config<'a>(
380    query: &str,
381    candidates: &'a [&'a str],
382    limit: usize,
383    config: &ScoreConfig,
384    out: &mut Vec<Match<'a>>,
385) -> usize {
386    let query = PreparedQuery::new(query);
387    let mut scratch = Scratch::default();
388    out.clear();
389    collect_top_k(
390        candidates
391            .iter()
392            .enumerate()
393            .filter_map(|(index, &candidate)| {
394                let prepared = PreparedCandidate::new(candidate);
395                score_candidate_view(&query, prepared.as_ref(), config, &mut scratch, None).map(
396                    |score| Match {
397                        candidate,
398                        score,
399                        index,
400                    },
401                )
402            }),
403        limit,
404        out,
405    );
406    out.len()
407}
408
409/// Rank the top `limit` candidates with explicit config and corpus stats.
410pub fn top_k_with_corpus<'a>(
411    query: &str,
412    candidates: &'a [&'a str],
413    limit: usize,
414    config: &ScoreConfig,
415    stats: &CorpusStats,
416    out: &mut Vec<Match<'a>>,
417) -> usize {
418    let query = PreparedQuery::new(query);
419    let mut scratch = Scratch::default();
420    out.clear();
421    collect_top_k(
422        candidates
423            .iter()
424            .enumerate()
425            .filter_map(|(index, &candidate)| {
426                let prepared = PreparedCandidate::new(candidate);
427                score_candidate_view(&query, prepared.as_ref(), config, &mut scratch, Some(stats))
428                    .map(|score| Match {
429                        candidate,
430                        score,
431                        index,
432                    })
433            }),
434        limit,
435        out,
436    );
437    out.len()
438}
439
440/// Rank the top `limit` prepared candidates and write them into `out`.
441pub fn top_k_prepared<'a>(
442    query: &PreparedQuery,
443    candidates: &'a [PreparedCandidate<'a>],
444    limit: usize,
445    out: &mut Vec<Match<'a>>,
446    scratch: &mut Scratch,
447) -> usize {
448    top_k_prepared_with_config(
449        query,
450        candidates,
451        limit,
452        &ScoreConfig::default(),
453        out,
454        scratch,
455    )
456}
457
458/// Rank the top `limit` prepared candidates with explicit config.
459pub fn top_k_prepared_with_config<'a>(
460    query: &PreparedQuery,
461    candidates: &'a [PreparedCandidate<'a>],
462    limit: usize,
463    config: &ScoreConfig,
464    out: &mut Vec<Match<'a>>,
465    scratch: &mut Scratch,
466) -> usize {
467    out.clear();
468    collect_top_k(
469        candidates
470            .iter()
471            .enumerate()
472            .filter_map(|(index, candidate)| {
473                score_candidate_view(query, candidate.as_ref(), config, scratch, None).map(
474                    |score| Match {
475                        candidate: candidate.candidate(),
476                        score,
477                        index,
478                    },
479                )
480            }),
481        limit,
482        out,
483    );
484    out.len()
485}
486
487/// Rank the top `limit` prepared candidates with explicit config and corpus stats.
488pub fn top_k_prepared_with_corpus<'a>(
489    query: &PreparedQuery,
490    candidates: &'a [PreparedCandidate<'a>],
491    limit: usize,
492    config: &ScoreConfig,
493    out: &mut Vec<Match<'a>>,
494    scratch: &mut Scratch,
495    stats: &CorpusStats,
496) -> usize {
497    out.clear();
498    collect_top_k(
499        candidates
500            .iter()
501            .enumerate()
502            .filter_map(|(index, candidate)| {
503                score_candidate_view(query, candidate.as_ref(), config, scratch, Some(stats)).map(
504                    |score| Match {
505                        candidate: candidate.candidate(),
506                        score,
507                        index,
508                    },
509                )
510            }),
511        limit,
512        out,
513    );
514    out.len()
515}
516
517/// Rank the top `limit` prepared candidate views and write them into `out`.
518pub fn top_k_prepared_refs<'a>(
519    query: &PreparedQuery,
520    candidates: &[PreparedCandidateRef<'a>],
521    limit: usize,
522    out: &mut Vec<Match<'a>>,
523    scratch: &mut Scratch,
524) -> usize {
525    top_k_prepared_refs_with_config(
526        query,
527        candidates,
528        limit,
529        &ScoreConfig::default(),
530        out,
531        scratch,
532    )
533}
534
535/// Rank the top `limit` prepared candidate views with explicit config.
536pub fn top_k_prepared_refs_with_config<'a>(
537    query: &PreparedQuery,
538    candidates: &[PreparedCandidateRef<'a>],
539    limit: usize,
540    config: &ScoreConfig,
541    out: &mut Vec<Match<'a>>,
542    scratch: &mut Scratch,
543) -> usize {
544    out.clear();
545    collect_top_k(
546        candidates
547            .iter()
548            .enumerate()
549            .filter_map(|(index, candidate)| {
550                score_candidate_view(query, *candidate, config, scratch, None).map(|score| Match {
551                    candidate: candidate.candidate(),
552                    score,
553                    index,
554                })
555            }),
556        limit,
557        out,
558    );
559    out.len()
560}
561
562/// Rank the top `limit` prepared candidate views with explicit config and corpus stats.
563pub fn top_k_prepared_refs_with_corpus<'a>(
564    query: &PreparedQuery,
565    candidates: &[PreparedCandidateRef<'a>],
566    limit: usize,
567    config: &ScoreConfig,
568    out: &mut Vec<Match<'a>>,
569    scratch: &mut Scratch,
570    stats: &CorpusStats,
571) -> usize {
572    out.clear();
573    collect_top_k(
574        candidates
575            .iter()
576            .enumerate()
577            .filter_map(|(index, candidate)| {
578                score_candidate_view(query, *candidate, config, scratch, Some(stats)).map(|score| {
579                    Match {
580                        candidate: candidate.candidate(),
581                        score,
582                        index,
583                    }
584                })
585            }),
586        limit,
587        out,
588    );
589    out.len()
590}
591
592fn build_score(
593    candidate: PreparedCandidateRef<'_>,
594    matched: &[usize],
595    query_len: usize,
596    energy: f32,
597    config: &ScoreConfig,
598    exact_match: bool,
599) -> Option<Score> {
600    let first_index = *matched.first()?;
601    let last_index = *matched.last()?;
602    let first = candidate.chars[first_index];
603    let last = candidate.chars[last_index];
604    let trailing_chars = candidate.chars.len().saturating_sub(last_index + 1);
605    let tail_penalty = if candidate.chars.is_empty() {
606        0.0
607    } else {
608        config.w_tail * (trailing_chars as f32 / candidate.chars.len() as f32)
609    };
610    let exact_bonus = if exact_match && first_index == 0 && trailing_chars == 0 {
611        config.w_exact
612    } else {
613        0.0
614    };
615    let total_energy = energy + tail_penalty - exact_bonus;
616    Some(Score {
617        value: value_from_energy(total_energy),
618        energy: total_energy,
619        confidence: confidence_from_energy(total_energy, query_len, config.confidence_scale),
620        start: first.byte,
621        end: last.byte + last.ch.len_utf8(),
622        matched: matched.len(),
623    })
624}
625
626fn value_from_energy(energy: f32) -> i64 {
627    (-energy * VALUE_SCALE).round() as i64
628}
629
630fn confidence_from_energy(energy: f32, query_len: usize, scale: f32) -> f32 {
631    if query_len == 0 {
632        return 1.0;
633    }
634    let denom = (query_len as f32) * scale.max(f32::EPSILON);
635    let x = -energy / denom;
636    1.0 / (1.0 + (-x).exp())
637}
638
639fn solve_match_positions(
640    query: &PreparedQuery,
641    candidate: PreparedCandidateRef<'_>,
642    config: &ScoreConfig,
643    scratch: &mut Scratch,
644    stats: Option<&CorpusStats>,
645) -> Option<f32> {
646    let idf = stats.map(|stats| {
647        move |ch: char| -> f32 {
648            if ch.is_ascii() {
649                let byte = u8::try_from(u32::from(ch)).expect("ASCII char fits in u8");
650                stats.idf(byte)
651            } else {
652                0.0
653            }
654        }
655    });
656    let idf_ref = idf.as_ref().map(|func| func as &dyn Fn(char) -> f32);
657
658    if query.case_sensitive {
659        if let (Some(query_ascii), Some(candidate_ascii)) =
660            (&query.ascii_bytes, candidate.ascii_bytes)
661        {
662            return dp_solve_ascii(
663                query_ascii,
664                candidate_ascii,
665                candidate.chars,
666                candidate.basename_start_char,
667                config,
668                &mut scratch.matched,
669                &mut scratch.dp_cost,
670                &mut scratch.dp_prev,
671                &mut scratch.dp_cost_swap,
672                &mut scratch.dp_prev_swap,
673                idf_ref,
674            );
675        }
676        return dp::dp_solve_case_sensitive(
677            &query.chars,
678            candidate.chars,
679            candidate.basename_start_char,
680            config,
681            &mut scratch.matched,
682            &mut scratch.dp_cost,
683            &mut scratch.dp_prev,
684            &mut scratch.dp_cost_swap,
685            &mut scratch.dp_prev_swap,
686            idf_ref,
687        );
688    }
689
690    if let (Some(query_ascii), Some(candidate_ascii)) =
691        (&query.ascii_folded, candidate.ascii_folded)
692    {
693        return dp_solve_ascii(
694            query_ascii,
695            candidate_ascii,
696            candidate.chars,
697            candidate.basename_start_char,
698            config,
699            &mut scratch.matched,
700            &mut scratch.dp_cost,
701            &mut scratch.dp_prev,
702            &mut scratch.dp_cost_swap,
703            &mut scratch.dp_prev_swap,
704            idf_ref,
705        );
706    }
707
708    dp_solve(
709        &query.chars,
710        candidate.chars,
711        candidate.basename_start_char,
712        config,
713        &mut scratch.matched,
714        &mut scratch.dp_cost,
715        &mut scratch.dp_prev,
716        &mut scratch.dp_cost_swap,
717        &mut scratch.dp_prev_swap,
718        idf_ref,
719    )
720}
721
722fn fill_match_positions(
723    query: &PreparedQuery,
724    candidate: PreparedCandidateRef<'_>,
725    out: &mut Vec<usize>,
726) -> bool {
727    out.clear();
728
729    if query.is_empty() {
730        return true;
731    }
732
733    if query.case_sensitive {
734        if let (Some(query_ascii), Some(candidate_ascii)) =
735            (&query.ascii_bytes, candidate.ascii_bytes)
736        {
737            return fill_match_positions_ascii(query_ascii, candidate_ascii, out);
738        }
739    } else if let (Some(query_ascii), Some(candidate_ascii)) =
740        (&query.ascii_folded, candidate.ascii_folded)
741    {
742        return fill_match_positions_ascii(query_ascii, candidate_ascii, out);
743    }
744
745    let mut next_candidate = 0usize;
746    for &query_char in &query.chars {
747        let mut found = None;
748        for candidate_index in next_candidate..candidate.chars.len() {
749            if chars_equal(
750                query_char,
751                candidate.chars[candidate_index].ch,
752                query.case_sensitive,
753            ) {
754                found = Some(candidate_index);
755                next_candidate = candidate_index + 1;
756                break;
757            }
758        }
759
760        let Some(index) = found else {
761            out.clear();
762            return false;
763        };
764        out.push(index);
765    }
766
767    true
768}
769
770fn fill_match_positions_ascii(query: &[u8], candidate: &[u8], out: &mut Vec<usize>) -> bool {
771    let mut next_candidate = 0usize;
772    for &query_byte in query {
773        let mut found = None;
774        for (candidate_index, &candidate_byte) in candidate.iter().enumerate().skip(next_candidate)
775        {
776            if query_byte == candidate_byte {
777                found = Some(candidate_index);
778                next_candidate = candidate_index + 1;
779                break;
780            }
781        }
782
783        let Some(index) = found else {
784            out.clear();
785            return false;
786        };
787        out.push(index);
788    }
789    true
790}
791
792fn chars_equal(lhs: char, rhs: char, case_sensitive: bool) -> bool {
793    if case_sensitive {
794        lhs == rhs
795    } else if lhs.is_ascii() && rhs.is_ascii() {
796        lhs.eq_ignore_ascii_case(&rhs)
797    } else {
798        chars_equal_caseless(lhs, rhs)
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    fn ranked<'a>(query: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
807        let mut out = Vec::new();
808        top_k(query, candidates, candidates.len(), &mut out);
809        out.into_iter().map(|entry| entry.candidate).collect()
810    }
811
812    #[test]
813    fn score_accepts_exact_match() {
814        let score = score("cmd", "cmd").expect("should match");
815        assert_eq!(score.start, 0);
816        assert_eq!(score.end, 3);
817        assert_eq!(score.matched, 3);
818        assert!(score.energy.is_finite());
819        assert!(score.confidence.is_finite());
820    }
821
822    #[test]
823    fn score_accepts_subsequence_match() {
824        let score = score("cmd", "command").expect("should match");
825        assert_eq!(score.start, 0);
826        assert_eq!(score.matched, 3);
827    }
828
829    #[test]
830    fn score_rejects_non_match() {
831        assert!(score("xyz", "command").is_none());
832    }
833
834    #[test]
835    fn empty_query_matches_everything() {
836        let candidates = ["workspace.ts", "src/lib.rs"];
837        let mut out = Vec::new();
838        let written = top_k("", &candidates, 8, &mut out);
839        assert_eq!(written, 2);
840        assert_eq!(out[0].candidate, "src/lib.rs");
841        assert_eq!(out[1].candidate, "workspace.ts");
842    }
843
844    #[test]
845    fn prefix_ranks_above_middle_match() {
846        let candidates = ["workbench-loader.ts", "workspace.ts"];
847        assert_eq!(
848            ranked("wo", &candidates),
849            vec!["workspace.ts", "workbench-loader.ts"]
850        );
851    }
852
853    #[test]
854    fn contiguous_ranks_above_scattered_match() {
855        let candidates = ["foo-bar", "far-out-option"];
856        assert_eq!(
857            ranked("foo", &candidates),
858            vec!["foo-bar", "far-out-option"]
859        );
860    }
861
862    #[test]
863    fn boundary_ranks_above_non_boundary() {
864        let candidates = ["foo_bar", "foobar"];
865        assert_eq!(ranked("fb", &candidates), vec!["foo_bar", "foobar"]);
866    }
867
868    #[test]
869    fn shorter_candidate_breaks_ties() {
870        let candidates = ["foobar", "foobarbaz"];
871        assert_eq!(ranked("foo", &candidates), vec!["foobar", "foobarbaz"]);
872    }
873
874    #[test]
875    fn exact_match_ranks_above_prefix_extension() {
876        let candidates = ["abc_suffix", "abc"];
877        assert_eq!(ranked("abc", &candidates), vec!["abc", "abc_suffix"]);
878    }
879
880    #[test]
881    fn exact_case_variant_ranks_above_prefix_extension() {
882        let candidates = ["abc_suffix", "ABC"];
883        assert_eq!(ranked("ABC", &candidates), vec!["ABC", "abc_suffix"]);
884    }
885
886    #[test]
887    fn exact_match_scores_above_prefix_extension() {
888        let exact = score("abc", "abc").expect("exact match");
889        let extended = score("abc", "abc_suffix").expect("prefix extension");
890        assert!(exact.value > extended.value);
891        assert!(exact.confidence > extended.confidence);
892    }
893
894    #[test]
895    fn exact_match_ranks_above_boundary_abbreviations() {
896        let candidates = ["a_b_c", "abC", "abc"];
897        let ranked = ranked("abc", &candidates);
898        assert_eq!(ranked[0], "abc");
899        assert!(ranked[1] == "abC" || ranked[1] == "a_b_c");
900        assert!(ranked[2] == "abC" || ranked[2] == "a_b_c");
901    }
902
903    #[test]
904    fn stable_input_order_breaks_full_ties() {
905        let candidates = ["alpha", "alpha"];
906        let mut out = Vec::new();
907        top_k("alp", &candidates, 8, &mut out);
908        assert_eq!(out[0].index, 0);
909        assert_eq!(out[1].index, 1);
910    }
911
912    #[test]
913    fn path_cases_favor_basename_and_boundary() {
914        let candidates = [
915            "src/lib/commands.ts",
916            "src/components/StatusCommandBar.vue",
917            "workspace.ts",
918            "workbench-loader.ts",
919        ];
920        assert_eq!(
921            ranked("cmd", &candidates),
922            vec!["src/lib/commands.ts", "src/components/StatusCommandBar.vue"]
923        );
924        assert_eq!(
925            ranked("ws", &candidates),
926            vec!["workspace.ts", "workbench-loader.ts"]
927        );
928    }
929
930    #[test]
931    fn case_sensitive_variant_differs_from_default() {
932        assert!(score("fb", "FooBar").is_some());
933        assert!(score_case_sensitive("fb", "FooBar").is_none());
934        assert!(score_case_sensitive("FB", "FooBar").is_some());
935    }
936
937    #[test]
938    fn match_indices_returns_byte_offsets() {
939        let mut out = Vec::new();
940        assert!(match_indices("fb", "foo_bar", &mut out));
941        assert_eq!(out, vec![0, 4]);
942    }
943
944    #[test]
945    fn unicode_candidate_is_not_broken() {
946        let mut out = Vec::new();
947        assert!(match_indices("あい", "あかい", &mut out));
948        assert_eq!(out, vec![0, 6]);
949    }
950
951    #[test]
952    fn prepared_score_matches_stable_api() {
953        let query = PreparedQuery::new("cmd");
954        let candidate = PreparedCandidate::new("src/lib/commands.ts");
955        let mut scratch = Scratch::default();
956        assert_eq!(
957            score("cmd", "src/lib/commands.ts"),
958            score_prepared(&query, &candidate, &mut scratch)
959        );
960    }
961
962    #[test]
963    fn prepared_top_k_matches_stable_api() {
964        let candidates = [
965            PreparedCandidate::new("src/lib/commands.ts"),
966            PreparedCandidate::new("src/components/StatusCommandBar.vue"),
967            PreparedCandidate::new("workspace.ts"),
968        ];
969        let query = PreparedQuery::new("cmd");
970        let mut stable = Vec::new();
971        let mut prepared = Vec::new();
972        let mut scratch = Scratch::default();
973
974        top_k(
975            "cmd",
976            &[
977                "src/lib/commands.ts",
978                "src/components/StatusCommandBar.vue",
979                "workspace.ts",
980            ],
981            3,
982            &mut stable,
983        );
984        top_k_prepared(&query, &candidates, 3, &mut prepared, &mut scratch);
985
986        assert_eq!(stable, prepared);
987    }
988
989    #[test]
990    fn scratch_reuse_keeps_results_stable() {
991        let query = PreparedQuery::new("fb");
992        let candidate = PreparedCandidate::new("foo_bar");
993        let mut scratch = Scratch::default();
994        let mut first_indices = Vec::new();
995        let mut second_indices = Vec::new();
996
997        let first = score_prepared(&query, &candidate, &mut scratch);
998        assert!(match_indices_prepared(
999            &query,
1000            &candidate,
1001            &mut first_indices,
1002            &mut scratch
1003        ));
1004        let second = score_prepared(&query, &candidate, &mut scratch);
1005        assert!(match_indices_prepared(
1006            &query,
1007            &candidate,
1008            &mut second_indices,
1009            &mut scratch
1010        ));
1011
1012        assert_eq!(first, second);
1013        assert_eq!(first_indices, second_indices);
1014    }
1015
1016    #[test]
1017    fn prepared_case_sensitive_ascii_matches_stable_api() {
1018        let query = PreparedQuery::new_case_sensitive("FB");
1019        let candidate = PreparedCandidate::new("FooBar");
1020        let mut scratch = Scratch::default();
1021        assert_eq!(
1022            score_case_sensitive("FB", "FooBar"),
1023            score_prepared(&query, &candidate, &mut scratch)
1024        );
1025    }
1026
1027    #[test]
1028    fn owned_prepared_roundtrip_keeps_score() {
1029        let owned = OwnedPreparedCandidate::new("src/lib/commands.ts");
1030        let mut bytes = vec![0; owned.encoded_len()];
1031        let written = owned.encode_into(&mut bytes).expect("encode");
1032        let decoded = OwnedPreparedCandidate::decode(&bytes[..written]).expect("decode");
1033        let query = PreparedQuery::new("cmd");
1034        let mut scratch = Scratch::default();
1035
1036        assert_eq!(
1037            owned.header().expect("owned header"),
1038            decoded.header().expect("decoded header")
1039        );
1040        assert_eq!(
1041            score_prepared_owned(&query, &owned, &mut scratch),
1042            score_prepared_owned(&query, &decoded, &mut scratch)
1043        );
1044    }
1045
1046    #[test]
1047    fn prepared_refs_rank_like_stable_api() {
1048        let query = PreparedQuery::new("cmd");
1049        let owned = [
1050            OwnedPreparedCandidate::new("src/lib/commands.ts"),
1051            OwnedPreparedCandidate::new("src/components/StatusCommandBar.vue"),
1052            OwnedPreparedCandidate::new("workspace.ts"),
1053        ];
1054        let refs: Vec<PreparedCandidateRef<'_>> =
1055            owned.iter().map(OwnedPreparedCandidate::as_ref).collect();
1056        let mut out = Vec::new();
1057        let mut scratch = Scratch::default();
1058        top_k_prepared_refs(&query, &refs, 3, &mut out, &mut scratch);
1059        assert_eq!(
1060            out.into_iter()
1061                .map(|entry| entry.candidate)
1062                .collect::<Vec<_>>(),
1063            vec!["src/lib/commands.ts", "src/components/StatusCommandBar.vue"]
1064        );
1065    }
1066
1067    #[test]
1068    fn default_config_matches_explicit_config() {
1069        let config = ScoreConfig::default();
1070        assert_eq!(
1071            score("cmd", "command"),
1072            score_with_config("cmd", "command", &config)
1073        );
1074    }
1075
1076    #[test]
1077    fn score_with_corpus_changes_energy_when_idf_is_enabled() {
1078        let stats = CorpusStats::from_candidates(&["foo_bar", "foobar", "quxbuzz"]);
1079        let config = ScoreConfig {
1080            w_idf: 2.0,
1081            ..ScoreConfig::default()
1082        };
1083
1084        let plain = score_with_config("fb", "foo_bar", &config).expect("plain score");
1085        let weighted = score_with_corpus("fb", "foo_bar", &config, &stats).expect("weighted score");
1086
1087        assert_ne!(plain.energy, weighted.energy);
1088        assert_ne!(plain.value, weighted.value);
1089    }
1090
1091    #[test]
1092    fn confidence_stays_in_open_interval_for_non_empty_query() {
1093        let score = score("cmd", "src/lib/commands.ts").expect("must match");
1094        assert!(score.confidence > 0.0);
1095        assert!(score.confidence < 1.0);
1096    }
1097}