1use crate::store::SymbolRow;
8
9#[derive(Debug, Clone, PartialEq, serde::Serialize)]
11pub struct Feature {
12 pub name: &'static str,
13 pub value: f64,
14}
15
16#[derive(Debug, Clone, PartialEq)]
18pub struct Scored {
19 pub total: f64,
20 pub features: Vec<Feature>,
21}
22
23pub fn match_quality(features: &[Feature]) -> f64 {
28 for f in features {
29 match f.name {
30 "exact" => return 1.0,
31 "prefix" => return 0.9,
32 "wildcard" => return 0.7,
33 "fuzzy" => return (0.30 + 0.35 * (f.value / 600.0)).clamp(0.30, 0.65),
35 _ => {}
36 }
37 }
38 0.25 }
40
41pub fn confidence(score: f64, quality: f64, best_other: Option<f64>) -> f64 {
47 let lead = match best_other {
48 None => 1.0,
49 Some(_) if score <= 0.0 => 0.5,
50 Some(other) => (0.5 + 3.0 * (score - other) / score).clamp(0.0, 1.0),
53 };
54 ((quality * lead) * 100.0).round() / 100.0
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq)]
61pub struct Boosts {
62 pub learned: f64,
64 pub recency: f64,
66 pub branch: f64,
69}
70
71pub fn score(
77 query: &str,
78 cand: &SymbolRow,
79 current_repo_id: Option<i64>,
80 boosts: Boosts,
81) -> Option<Scored> {
82 let (leaf, qualifier) = parse_qualified(query);
85 let q = leaf.to_ascii_lowercase();
86 let name_lower = cand.name.to_ascii_lowercase();
87
88 let mut features = Vec::new();
89
90 let wildcard = has_wildcard(&q);
92 let name_matched = if wildcard {
93 if let Some(s) = wildcard_score(&q, &cand.name) {
95 features.push(Feature {
96 name: "wildcard",
97 value: s.min(600.0),
98 });
99 true
100 } else {
101 false
102 }
103 } else if name_lower == q {
104 features.push(Feature {
105 name: "exact",
106 value: 1000.0,
107 });
108 true
109 } else if name_lower.starts_with(&q) {
110 let tail = cand.name.chars().count().saturating_sub(q.chars().count());
112 features.push(Feature {
113 name: "prefix",
114 value: 700.0 - (tail as f64).min(100.0),
115 });
116 true
117 } else if let Some(s) = subsequence_score(&q, &cand.name) {
118 features.push(Feature {
119 name: "fuzzy",
120 value: s.min(600.0),
121 });
122 true
123 } else {
124 false
125 };
126
127 let stem = path_stem(&cand.file);
129 let path_match = if wildcard {
130 wildcard_score(&q, stem)
131 } else {
132 subsequence_score(&q, stem)
133 };
134 if name_matched {
135 if let Some(ps) = path_match {
137 features.push(Feature {
138 name: "path",
139 value: (ps * 0.2).min(50.0),
140 });
141 }
142 } else {
143 match path_match {
145 Some(ps)
146 if matches!(
147 cand.kind.as_str(),
148 "class" | "module" | "struct" | "enum" | "trait"
149 ) =>
150 {
151 features.push(Feature {
152 name: "path",
153 value: (ps * 0.6).min(300.0),
154 });
155 }
156 _ => return None,
157 }
158 }
159
160 if matches!(
166 cand.visibility.as_deref(),
167 Some("private") | Some("protected")
168 ) {
169 features.push(Feature {
170 name: "private",
171 value: -15.0,
172 });
173 }
174
175 let kind = match cand.kind.as_str() {
178 "class" | "struct" | "trait" => 15.0,
179 "module" | "enum" => 12.0,
180 _ => 0.0,
181 };
182 if kind != 0.0 {
183 features.push(Feature {
184 name: "kind",
185 value: kind,
186 });
187 }
188
189 if let Some(qual) = qualifier
192 && let Some(b) = parent_boost(qual, cand.parent.as_deref())
193 {
194 features.push(Feature {
195 name: "parent",
196 value: b,
197 });
198 }
199
200 if let Some(cur) = current_repo_id
202 && cur == cand.repository_id
203 {
204 features.push(Feature {
205 name: "current_repo",
206 value: 200.0,
207 });
208 }
209
210 if boosts.learned > 0.0 {
212 features.push(Feature {
213 name: "learned",
214 value: boosts.learned,
215 });
216 }
217
218 if boosts.recency > 0.0 {
220 features.push(Feature {
221 name: "recency",
222 value: boosts.recency,
223 });
224 }
225
226 if boosts.branch > 0.0 {
228 features.push(Feature {
229 name: "branch",
230 value: boosts.branch,
231 });
232 }
233
234 let total = features.iter().map(|f| f.value).sum();
235 Some(Scored { total, features })
236}
237
238const MAX_NONBOUNDARY_GAP: usize = 2;
245
246const GAP_PENALTY: f64 = 3.0;
252
253struct Alignment {
255 score: f64,
256 positions: Vec<usize>,
257}
258
259fn align(query: &str, name: &str) -> Option<Alignment> {
273 let q: Vec<char> = query
274 .chars()
275 .filter(|c| c.is_alphanumeric())
276 .map(|c| c.to_ascii_lowercase())
277 .collect();
278 if q.is_empty() {
279 return None;
280 }
281 let mut qi = 0;
284 for c in name.chars() {
285 if qi < q.len() && c.to_ascii_lowercase() == q[qi] {
286 qi += 1;
287 }
288 }
289 if qi < q.len() {
290 return None;
291 }
292 let chars: Vec<char> = name.chars().collect();
293 let n = chars.len();
294 let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
295 let boundary = boundaries(&chars);
296 let mut bnd_prefix = vec![0usize; n + 1];
299 for i in 0..n {
300 bnd_prefix[i + 1] = bnd_prefix[i] + boundary[i] as usize;
301 }
302
303 let mut table: Vec<Vec<Option<(f64, usize)>>> = vec![vec![None; n]; q.len()];
307
308 for (i, &c) in lower.iter().enumerate() {
309 if c == q[0] {
310 let mut s = 10.0;
311 if boundary[i] {
312 s += 15.0;
313 }
314 if i == 0 {
315 s += 20.0; }
317 table[0][i] = Some((s, i));
318 }
319 }
320
321 for qi in 1..q.len() {
322 for i in qi..n {
323 if lower[i] != q[qi] {
324 continue;
325 }
326 let base = 10.0 + if boundary[i] { 15.0 } else { 0.0 };
327 let j_start = if boundary[i] {
330 qi - 1
331 } else {
332 (qi - 1).max(i.saturating_sub(MAX_NONBOUNDARY_GAP + 1))
333 };
334 let mut best: Option<(f64, usize)> = None;
335 let prev_row = &table[qi - 1];
336 for (j, cell) in prev_row.iter().enumerate().take(i).skip(j_start) {
337 let Some((pscore, _)) = cell else {
338 continue;
339 };
340 let trans = if j + 1 == i {
341 10.0 } else {
343 let gap = i - j - 1;
344 let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
345 if boundary[i] {
346 if crossed_word {
349 continue;
350 }
351 } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
352 continue;
358 }
359 -(gap as f64) * GAP_PENALTY
360 };
361 let cand = pscore + trans;
362 if best.is_none_or(|(b, _)| cand > b) {
363 best = Some((cand, j));
364 }
365 }
366 if let Some((bscore, j)) = best {
367 table[qi][i] = Some((bscore + base, j));
368 }
369 }
370 }
371
372 let last = q.len() - 1;
374 let (mut pos, score) = (0..n)
375 .filter_map(|i| table[last][i].map(|(s, _)| (i, s)))
376 .max_by(|a, b| a.1.total_cmp(&b.1))?;
377 let mut positions = Vec::with_capacity(q.len());
378 for qi in (0..q.len()).rev() {
379 positions.push(pos);
380 pos = table[qi][pos].expect("backtrack hits a filled cell").1;
381 }
382 positions.reverse();
383 Some(Alignment {
384 score: score.max(0.0),
385 positions,
386 })
387}
388
389pub fn match_positions(query: &str, name: &str) -> Vec<usize> {
392 let (leaf, _) = parse_qualified(query);
394 if has_wildcard(leaf) {
395 return glob_positions(leaf, name).unwrap_or_default();
397 }
398 let positions = align(leaf, name).map(|a| a.positions).unwrap_or_default();
399 contiguous_highlight(positions, name)
400}
401
402pub fn parse_qualified(query: &str) -> (&str, Option<&str>) {
408 let sep = query
409 .rmatch_indices("::")
410 .map(|(i, _)| (i, 2usize))
411 .chain(query.rmatch_indices('#').map(|(i, _)| (i, 1usize)))
412 .max_by_key(|&(i, _)| i);
413 match sep {
414 Some((i, len)) if i > 0 && i + len < query.len() => (&query[i + len..], Some(&query[..i])),
415 _ => (query, None),
416 }
417}
418
419fn segments(s: &str) -> Vec<String> {
421 s.split("::")
422 .flat_map(|p| p.split('#'))
423 .filter(|p| !p.is_empty())
424 .map(|p| p.to_ascii_lowercase())
425 .collect()
426}
427
428fn parent_boost(qualifier: &str, parent: Option<&str>) -> Option<f64> {
434 let p = segments(parent?);
435 let q = segments(qualifier);
436 if q.is_empty() || q.len() > p.len() {
437 return None;
438 }
439 let off = p.len() - q.len();
440 (p[off..] == q[..]).then(|| (120.0 + 60.0 * q.len() as f64).min(300.0))
441}
442
443fn contiguous_highlight(positions: Vec<usize>, name: &str) -> Vec<usize> {
451 if positions.is_empty() {
452 return positions;
453 }
454 let boundary = boundaries(&name.chars().collect::<Vec<_>>());
455 let mut out = Vec::with_capacity(positions.len());
456 let mut i = 0;
457 while i < positions.len() {
458 let mut j = i;
460 while j + 1 < positions.len() && positions[j + 1] == positions[j] + 1 {
461 j += 1;
462 }
463 if j > i {
464 out.extend_from_slice(&positions[i..=j]); } else if boundary[positions[i]] {
466 out.push(positions[i]); }
468 i = j + 1;
469 }
470 out
471}
472
473fn subsequence_score(query: &str, name: &str) -> Option<f64> {
476 align(query, name).map(|a| a.score)
477}
478
479pub fn has_wildcard(query: &str) -> bool {
485 query.contains(['*', '?', '.'])
486}
487
488pub fn strip_wildcards(query: &str) -> String {
492 query
493 .chars()
494 .filter(|c| !matches!(c, '*' | '?' | '.'))
495 .collect()
496}
497
498enum Glob {
500 Lit(char), Any, Star, }
504
505fn compile_glob(query: &str) -> Vec<Glob> {
509 query
510 .chars()
511 .filter_map(|c| match c {
512 '*' => Some(Glob::Star),
513 '?' | '.' => Some(Glob::Any),
514 c if c.is_alphanumeric() => Some(Glob::Lit(c.to_ascii_lowercase())),
515 _ => None,
516 })
517 .collect()
518}
519
520fn glob_positions(query: &str, name: &str) -> Option<Vec<usize>> {
526 let mut toks = vec![Glob::Star];
527 toks.extend(compile_glob(query));
528 toks.push(Glob::Star);
529
530 let lower: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
531 let mut ti = 0;
532 let mut ni = 0;
533 let mut positions: Vec<usize> = Vec::new();
534 let mut star: Option<(usize, usize, usize)> = None;
536
537 while ni < lower.len() {
538 match toks.get(ti) {
539 Some(Glob::Lit(c)) if lower[ni] == *c => {
540 positions.push(ni);
541 ti += 1;
542 ni += 1;
543 }
544 Some(Glob::Any) => {
545 ti += 1;
546 ni += 1;
547 }
548 Some(Glob::Star) => {
549 star = Some((ti + 1, ni, positions.len()));
550 ti += 1;
551 }
552 _ => match star {
556 Some((sti, sni, plen)) => {
557 ti = sti;
558 ni = sni + 1;
559 star = Some((sti, sni + 1, plen));
560 positions.truncate(plen);
561 }
562 None => return None,
563 },
564 }
565 }
566 while matches!(toks.get(ti), Some(Glob::Star)) {
567 ti += 1;
568 }
569 (ti == toks.len()).then_some(positions)
570}
571
572fn wildcard_score(query: &str, name: &str) -> Option<f64> {
577 let positions = glob_positions(query, name)?;
578 if positions.is_empty() {
579 return None;
580 }
581 let chars: Vec<char> = name.chars().collect();
582 let boundary = boundaries(&chars);
583 let mut score = 0.0;
584 let mut prev: Option<usize> = None;
585 for &i in &positions {
586 score += 10.0;
587 if boundary[i] {
588 score += 15.0;
589 }
590 match prev {
591 Some(p) if p + 1 == i => score += 10.0, None if i == 0 => score += 20.0, _ => {}
594 }
595 prev = Some(i);
596 }
597 Some(score)
598}
599
600pub fn path_stem(path: &str) -> &str {
603 let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
604 match base.rfind('.') {
605 Some(i) if i > 0 => &base[..i],
606 _ => base,
607 }
608}
609
610fn boundaries(chars: &[char]) -> Vec<bool> {
613 let mut out = vec![false; chars.len()];
614 for i in 0..chars.len() {
615 let c = chars[i];
616 out[i] = if i == 0 {
617 true
618 } else {
619 let prev = chars[i - 1];
620 !prev.is_alphanumeric()
623 || (c.is_uppercase() && prev.is_lowercase())
624 || (c.is_uppercase()
625 && prev.is_uppercase()
626 && chars.get(i + 1).is_some_and(|n| n.is_lowercase()))
627 };
628 }
629 out
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635
636 fn row(name: &str, kind: &str, repo: i64) -> SymbolRow {
637 SymbolRow {
638 name: name.into(),
639 kind: kind.into(),
640 language: "ruby".into(),
641 file: "f.rb".into(),
642 line: 1,
643 end_line: Some(1),
644 parent: None,
645 repository_id: repo,
646 repo_identity: "r".into(),
647 mtime: None,
648 git_ts: None,
649 visibility: None,
650 }
651 }
652
653 fn total(query: &str, name: &str) -> Option<f64> {
654 score(query, &row(name, "class", 1), None, Boosts::default()).map(|s| s.total)
655 }
656
657 #[test]
658 fn private_ranks_below_public_on_an_equal_match() {
659 let mut public = row("save", "method", 1);
660 public.visibility = Some("public".into());
661 let mut private = row("save", "method", 1);
662 private.visibility = Some("private".into());
663 let unknown = row("save", "method", 1); let pub_score = score("save", &public, None, Boosts::default()).unwrap();
666 let priv_score = score("save", &private, None, Boosts::default()).unwrap();
667 let unk_score = score("save", &unknown, None, Boosts::default()).unwrap();
668 assert!(pub_score.total > priv_score.total);
669 assert_eq!(
670 pub_score.total, unk_score.total,
671 "unknown carries no penalty"
672 );
673 assert!(priv_score.total > 700.0, "still comfortably above a prefix");
675 }
676
677 #[test]
678 fn exact_beats_prefix_beats_fuzzy() {
679 let exact = total("user", "user").unwrap();
680 let prefix = total("user", "users").unwrap();
681 let fuzzy = total("usr", "user").unwrap();
682 assert!(exact > prefix, "{exact} > {prefix}");
683 assert!(prefix > fuzzy, "{prefix} > {fuzzy}");
684 }
685
686 #[test]
687 fn abbreviations_match() {
688 assert!(total("refundproc", "RefundProcessor").is_some());
689 assert!(total("refproc", "RefundProcessor").is_some());
690 assert!(total("paymnt", "Payments").is_some());
691 assert!(total("perf", "perform").is_some());
692 assert!(total("usr", "User").is_some());
693 assert!(total("ctrl", "Controller").is_some());
695 }
696
697 #[test]
698 fn rejects_scattered_midword_matches() {
699 assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
702 assert!(total("employeescontroller", "EmployeesController").is_some());
703 assert!(total("employescontroller", "EmployeesController").is_some());
705 }
706
707 #[test]
708 fn match_positions_report_what_matched() {
709 assert_eq!(match_positions("foo", "FooThing"), vec![0, 1, 2]);
710 assert_eq!(match_positions("ft", "FooThing"), vec![0, 3]); assert_eq!(match_positions("wc", "WidgetController"), vec![0, 6]); assert!(match_positions("xyz", "FooThing").is_empty());
714 }
715
716 #[test]
717 fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
718 assert_eq!(
721 match_positions("employee", "xxxe_employee"),
722 vec![5, 6, 7, 8, 9, 10, 11, 12]
723 );
724 assert_eq!(
726 match_positions("controller", "calc_controller"),
727 (5..15).collect::<Vec<_>>()
728 );
729 assert_eq!(
731 match_positions("widgetcontroller", "WidgetController"),
732 (0..16).collect::<Vec<_>>()
733 );
734 }
735
736 #[test]
737 fn matches_only_span_adjacent_words() {
738 assert_eq!(
740 match_positions("employeescontroller", "employees_controller"),
741 vec![
743 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
744 ]
745 );
746 assert!(subsequence_score("employees", "employee_x_syy").is_none());
748 assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
750 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
752 assert!(subsequence_score("refprocsvc", "RefundProcessingService").is_some());
753 }
754
755 #[test]
756 fn a_contiguous_match_beats_a_farther_boundary_jump() {
757 assert_eq!(match_positions("car", "car_r"), vec![0, 1, 2]);
760 }
761
762 #[test]
763 fn acronyms_highlight_word_initials_across_adjacent_words() {
764 assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
767 assert_eq!(
768 match_positions("abc", "alpha_bravo_charlie"),
769 vec![0, 6, 12] );
771 assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
773 assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); }
775
776 #[test]
777 fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
778 assert!(
782 subsequence_score("employeescontroller", "employee_before_starting_controller")
783 .is_none()
784 );
785 assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
787 assert!(subsequence_score("usr", "user").is_some());
789 assert!(subsequence_score("cfg", "config").is_some());
790 }
791
792 #[test]
793 fn a_contiguous_word_match_outranks_a_scattered_cross_word_one() {
794 let contiguous = total("test", "test_helper").unwrap(); let scattered = total("test", "the_settings_store");
800 if let Some(s) = scattered {
801 assert!(contiguous > s, "contiguous {contiguous} > scattered {s}");
802 }
803 }
804
805 #[test]
806 fn score_and_positions_come_from_the_same_alignment() {
807 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
809 assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
810 assert!(subsequence_score("xyz", "RefundProcessor").is_none());
812 assert!(match_positions("xyz", "RefundProcessor").is_empty());
813 }
814
815 #[test]
816 fn highlights_are_ordered_in_bounds_and_correct_across_varied_inputs() {
817 let cases = [
818 ("usr", "UserService"),
819 ("paymnt", "Payments"),
820 ("wc", "WidgetController"),
821 ("ctrl", "Controller"),
822 ("gp", "get_post"),
823 ("ab", "alpha_beta"),
824 ("refproc", "RefundProcessor"),
825 ("emp", "EmployeesController"),
826 ("http", "HTTPParser"),
827 ];
828 for (q, name) in cases {
829 let nchars: Vec<char> = name.chars().collect();
830 let qchars: Vec<char> = q.chars().filter(|c| c.is_alphanumeric()).collect();
831 let boundary = boundaries(&nchars);
832 let pos = match_positions(q, name);
833 assert!(
834 pos.windows(2).all(|w| w[0] < w[1]),
835 "strictly increasing: {q}/{name} {pos:?}"
836 );
837 let mut qi = 0;
839 for &p in &pos {
840 assert!(p < nchars.len(), "in bounds: {q}/{name}");
841 while qi < qchars.len() && !qchars[qi].eq_ignore_ascii_case(&nchars[p]) {
842 qi += 1;
843 }
844 assert!(
845 qi < qchars.len(),
846 "highlight maps to a query char: {q}/{name}"
847 );
848 qi += 1;
849 }
850 for (idx, &p) in pos.iter().enumerate() {
852 let clumped = (idx > 0 && pos[idx - 1] + 1 == p)
853 || (idx + 1 < pos.len() && p + 1 == pos[idx + 1]);
854 assert!(
855 clumped || boundary[p],
856 "no isolated mid-word highlight: {q}/{name} at {p} {pos:?}"
857 );
858 }
859 }
860 }
861
862 #[test]
863 fn highlights_avoid_isolated_single_chars() {
864 assert_eq!(
866 match_positions("paymnt", "Payments"),
867 vec![0, 1, 2, 3, 5, 6]
868 );
869 assert_eq!(match_positions("usr", "UserService"), vec![0, 1]);
871 assert_eq!(match_positions("ctrl", "Controller"), vec![0, 3, 4]);
873 assert!(match_positions("rp", "wrapper").is_empty());
875 assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
877 }
878
879 #[test]
880 fn an_acronym_at_boundaries_outranks_a_mid_word_alignment() {
881 let acronym = subsequence_score("wc", "WidgetController").unwrap();
883 let midword = subsequence_score("wc", "switchcase").unwrap();
884 assert!(acronym > midword, "{acronym} > {midword}");
885 }
886
887 #[test]
888 fn a_far_path_straggler_never_outranks_a_prefix_match() {
889 let mut straggler = row("Thing", "class", 1);
893 straggler.file = "app/employee_x_syy.rb".into();
894 let prefixed = row("EmployeesController", "class", 1);
895 let pre = score("employees", &prefixed, None, Boosts::default())
896 .unwrap()
897 .total;
898 if let Some(s) = score("employees", &straggler, None, Boosts::default()) {
899 assert!(pre > s.total, "prefix {pre} > path straggler {}", s.total);
900 }
901 }
902
903 #[test]
904 fn snake_case_query_matches_camelcase_name() {
905 assert!(total("widget_controller", "WidgetsController").is_some());
908 assert!(total("widget_controller", "WidgetController").is_some());
909 assert!(total("widget_controller", "AdminController").is_none());
911 }
912
913 #[test]
914 fn wildcard_star_spans_an_explicit_gap() {
915 assert!(total("find*controller", "FindController").is_some());
918 assert!(total("find*controller", "FindUserController").is_some());
919 assert!(total("find*controller", "FindUserAccountController").is_some());
920 assert!(total("find*ctrlr", "FindController").is_none());
923 assert!(total("find*controller", "FindService").is_none());
925 }
926
927 #[test]
928 fn wildcard_question_mark_matches_one_char() {
929 assert!(total("find?controller", "FindXController").is_some());
931 assert!(total("find.controller", "Find1Controller").is_some());
932 assert!(total("find?controller", "FindController").is_none());
934 assert!(total("find?controller", "FindXyController").is_none());
935 }
936
937 #[test]
938 fn wildcard_highlights_only_the_literals() {
939 assert_eq!(
941 match_positions("find*er", "FindController"),
942 vec![0, 1, 2, 3, 12, 13] );
944 }
945
946 #[test]
947 fn wildcard_prefers_boundary_aligned_matches() {
948 let boundary = total("a*b", "Alpha_Bravo").unwrap();
951 let midword = total("a*b", "Alphabet").unwrap();
952 assert!(boundary > midword, "{boundary} > {midword}");
953 }
954
955 #[test]
956 fn non_subsequence_does_not_match() {
957 assert!(total("xyz", "RefundProcessor").is_none());
958 assert!(total("zzz", "User").is_none());
959 }
960
961 #[test]
962 fn confidence_reflects_quality_and_dominance() {
963 let exact = vec![Feature {
964 name: "exact",
965 value: 1000.0,
966 }];
967 let fuzzy = vec![Feature {
968 name: "fuzzy",
969 value: 300.0,
970 }];
971 assert_eq!(confidence(1000.0, match_quality(&exact), None), 1.0);
973 let f = confidence(300.0, match_quality(&fuzzy), None);
975 assert!(f > 0.3 && f < 0.65, "fuzzy confidence {f}");
976 let tied = confidence(1000.0, match_quality(&exact), Some(1000.0));
979 assert!(tied < 0.6, "tied exact confidence {tied}");
980 let dominant = confidence(1000.0, match_quality(&exact), Some(300.0));
982 assert!(dominant > 0.9, "dominant confidence {dominant}");
983 }
984
985 #[test]
986 fn parse_qualified_splits_on_scope_separators() {
987 assert_eq!(parse_qualified("User"), ("User", None));
988 assert_eq!(parse_qualified("Foo::Bar"), ("Bar", Some("Foo")));
989 assert_eq!(parse_qualified("App::Foo::Bar"), ("Bar", Some("App::Foo")));
990 assert_eq!(parse_qualified("Foo::Bar#baz"), ("baz", Some("Foo::Bar")));
992 assert_eq!(parse_qualified("::Bar"), ("::Bar", None));
994 assert_eq!(parse_qualified("Foo::"), ("Foo::", None));
995 }
996
997 #[test]
998 fn parent_boost_matches_the_innermost_scopes() {
999 assert!(parent_boost("Foo", Some("Foo")).is_some());
1001 assert!(parent_boost("Foo", Some("App::Foo")).is_some());
1002 assert!(parent_boost("App::Foo", Some("App::Foo")).is_some());
1003 let one = parent_boost("Foo", Some("App::Foo")).unwrap();
1005 let two = parent_boost("App::Foo", Some("App::Foo")).unwrap();
1006 assert!(two > one, "{two} > {one}");
1007 assert!(parent_boost("App", Some("App::Foo")).is_none());
1009 assert!(parent_boost("Foo", Some("Foo::Inner")).is_none());
1010 assert!(parent_boost("Foo", None).is_none());
1011 }
1012
1013 #[test]
1014 fn qualifier_ranks_the_symbol_in_the_named_scope_first() {
1015 let in_foo = SymbolRow {
1017 parent: Some("Foo".into()),
1018 ..row("Bar", "class", 1)
1019 };
1020 let in_baz = SymbolRow {
1021 parent: Some("Baz".into()),
1022 ..row("Bar", "class", 1)
1023 };
1024 let foo = score("Foo::Bar", &in_foo, None, Boosts::default())
1025 .unwrap()
1026 .total;
1027 let baz = score("Foo::Bar", &in_baz, None, Boosts::default())
1028 .unwrap()
1029 .total;
1030 assert!(foo > baz, "{foo} > {baz}");
1031 assert!(score("Bar", &in_baz, None, Boosts::default()).is_some());
1033 assert!(score("Foo::Zzz", &in_foo, None, Boosts::default()).is_none());
1035 }
1036
1037 #[test]
1038 fn boundary_alignment_outranks_scattered() {
1039 let aligned = total("rp", "RefundProcessor").unwrap();
1041 let scattered = total("rp", "wrapper").unwrap();
1042 assert!(aligned > scattered, "{aligned} > {scattered}");
1043 }
1044
1045 #[test]
1046 fn path_only_match_surfaces_a_class_in_a_named_file() {
1047 let mut cand = row("Invoice", "class", 1);
1049 cand.file = "app/models/billing.rb".into();
1050 let s = score("billing", &cand, None, Boosts::default()).expect("path match");
1051 assert!(s.features.iter().any(|f| f.name == "path"));
1052
1053 let mut method = row("compute", "method", 1);
1055 method.file = "app/models/billing.rb".into();
1056 assert!(score("billing", &method, None, Boosts::default()).is_none());
1057 }
1058
1059 #[test]
1060 fn path_bonus_reinforces_a_name_match() {
1061 let mut named = row("User", "class", 1);
1062 named.file = "app/models/user.rb".into();
1063 let mut elsewhere = row("User", "class", 1);
1064 elsewhere.file = "app/lib/misc.rb".into();
1065 let with_path = score("user", &named, None, Boosts::default())
1066 .unwrap()
1067 .total;
1068 let without = score("user", &elsewhere, None, Boosts::default())
1069 .unwrap()
1070 .total;
1071 assert!(with_path > without, "{with_path} > {without}");
1072 }
1073
1074 #[test]
1075 fn current_repo_boost_applies() {
1076 let cand = row("User", "class", 7);
1077 let in_repo = score("user", &cand, Some(7), Boosts::default())
1078 .unwrap()
1079 .total;
1080 let out_repo = score("user", &cand, Some(99), Boosts::default())
1081 .unwrap()
1082 .total;
1083 assert!(in_repo > out_repo);
1084 assert_eq!(in_repo - out_repo, 200.0);
1085 }
1086
1087 #[test]
1088 fn learned_boost_adds_to_the_score() {
1089 let cand = row("User", "class", 1);
1090 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1091 let boosted = score(
1092 "user",
1093 &cand,
1094 None,
1095 Boosts {
1096 learned: 150.0,
1097 ..Default::default()
1098 },
1099 )
1100 .unwrap();
1101 assert_eq!(boosted.total - base, 150.0);
1102 assert!(boosted.features.iter().any(|f| f.name == "learned"));
1103 }
1104
1105 #[test]
1106 fn recency_boost_adds_to_the_score() {
1107 let cand = row("User", "class", 1);
1108 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1109 let boosted = score(
1110 "user",
1111 &cand,
1112 None,
1113 Boosts {
1114 recency: 80.0,
1115 ..Default::default()
1116 },
1117 )
1118 .unwrap();
1119 assert_eq!(boosted.total - base, 80.0);
1120 assert!(boosted.features.iter().any(|f| f.name == "recency"));
1121 }
1122
1123 #[test]
1124 fn branch_boost_adds_to_the_score() {
1125 let cand = row("User", "class", 1);
1126 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1127 let boosted = score(
1128 "user",
1129 &cand,
1130 None,
1131 Boosts {
1132 branch: 180.0,
1133 ..Default::default()
1134 },
1135 )
1136 .unwrap();
1137 assert_eq!(boosted.total - base, 180.0);
1138 assert!(boosted.features.iter().any(|f| f.name == "branch"));
1139 }
1140}