1mod 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#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct Score {
31 pub value: i64,
33 pub energy: f32,
35 pub confidence: f32,
37 pub start: usize,
39 pub end: usize,
41 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#[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#[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 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 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 pub fn is_case_sensitive(&self) -> bool {
113 self.case_sensitive
114 }
115
116 pub fn len(&self) -> usize {
118 self.chars.len()
119 }
120
121 pub fn is_empty(&self) -> bool {
123 self.chars.is_empty()
124 }
125}
126
127#[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
137pub fn score(query: &str, candidate: &str) -> Option<Score> {
139 score_with_config(query, candidate, &ScoreConfig::default())
140}
141
142pub fn score_case_sensitive(query: &str, candidate: &str) -> Option<Score> {
144 score_case_sensitive_with_config(query, candidate, &ScoreConfig::default())
145}
146
147pub 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
155pub 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
174pub 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
186pub 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
195pub 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
205pub 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
216pub 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
225pub 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
235pub 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
246pub 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
255pub 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
265pub 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
315pub 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
323pub 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
333pub 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
368pub 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
378pub 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
409pub 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
440pub 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
458pub 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
487pub 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
517pub 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
535pub 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
562pub 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}