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 if leaf != q && cand.name == leaf {
114 features.push(Feature {
115 name: "case",
116 value: CASE_MATCH,
117 });
118 }
119 true
120 } else if name_lower.starts_with(&q) {
121 let tail = cand.name.chars().count().saturating_sub(q.chars().count());
123 features.push(Feature {
124 name: "prefix",
125 value: 700.0 - (tail as f64).min(100.0),
126 });
127 true
128 } else if let Some(s) = subsequence_score(&q, &cand.name) {
129 features.push(Feature {
130 name: "fuzzy",
131 value: s.min(600.0),
132 });
133 true
134 } else {
135 false
136 };
137
138 let stem = path_stem(&cand.file);
140 let path_match = if wildcard {
141 wildcard_score(&q, stem)
142 } else {
143 subsequence_score(&q, stem)
144 };
145 if name_matched {
146 if let Some(ps) = path_match {
148 features.push(Feature {
149 name: "path",
150 value: (ps * 0.2).min(50.0),
151 });
152 }
153 } else {
154 match path_match {
156 Some(ps)
157 if matches!(
158 cand.kind.as_str(),
159 "class" | "module" | "struct" | "enum" | "trait"
160 ) =>
161 {
162 features.push(Feature {
163 name: "path",
164 value: (ps * 0.6).min(300.0),
165 });
166 }
167 _ => return None,
168 }
169 }
170
171 if matches!(
177 cand.visibility.as_deref(),
178 Some("private") | Some("protected")
179 ) {
180 features.push(Feature {
181 name: "private",
182 value: -15.0,
183 });
184 }
185
186 let kind = match cand.kind.as_str() {
189 "class" | "struct" | "trait" => 15.0,
190 "module" | "enum" => 12.0,
191 _ => 0.0,
192 };
193 if kind != 0.0 {
194 features.push(Feature {
195 name: "kind",
196 value: kind,
197 });
198 }
199
200 if let Some(qual) = qualifier
203 && let Some(b) = parent_boost(qual, cand.parent.as_deref())
204 {
205 features.push(Feature {
206 name: "parent",
207 value: b,
208 });
209 }
210
211 if let Some(cur) = current_repo_id
213 && cur == cand.repository_id
214 {
215 features.push(Feature {
216 name: "current_repo",
217 value: 200.0,
218 });
219 }
220
221 if boosts.learned > 0.0 {
223 features.push(Feature {
224 name: "learned",
225 value: boosts.learned,
226 });
227 }
228
229 if boosts.recency > 0.0 {
231 features.push(Feature {
232 name: "recency",
233 value: boosts.recency,
234 });
235 }
236
237 if boosts.branch > 0.0 {
239 features.push(Feature {
240 name: "branch",
241 value: boosts.branch,
242 });
243 }
244
245 let total = features.iter().map(|f| f.value).sum();
246 Some(Scored { total, features })
247}
248
249const MAX_NONBOUNDARY_GAP: usize = 2;
256
257const CASE_MATCH: f64 = 150.0;
263
264const GAP_PENALTY: f64 = 3.0;
270
271struct Alignment {
273 score: f64,
274 positions: Vec<usize>,
275}
276
277fn align(query: &str, name: &str) -> Option<Alignment> {
291 let q: Vec<char> = query
292 .chars()
293 .filter(|c| c.is_alphanumeric())
294 .map(|c| c.to_ascii_lowercase())
295 .collect();
296 if q.is_empty() {
297 return None;
298 }
299 let mut qi = 0;
302 for c in name.chars() {
303 if qi < q.len() && c.to_ascii_lowercase() == q[qi] {
304 qi += 1;
305 }
306 }
307 if qi < q.len() {
308 return None;
309 }
310 let chars: Vec<char> = name.chars().collect();
311 let n = chars.len();
312 let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
313 let boundary = boundaries(&chars);
314 let mut bnd_prefix = vec![0usize; n + 1];
317 for i in 0..n {
318 bnd_prefix[i + 1] = bnd_prefix[i] + boundary[i] as usize;
319 }
320
321 let mut table: Vec<Vec<Option<(f64, usize)>>> = vec![vec![None; n]; q.len()];
325
326 for (i, &c) in lower.iter().enumerate() {
327 if c == q[0] {
328 let mut s = 10.0;
329 if boundary[i] {
330 s += 15.0;
331 }
332 if i == 0 {
333 s += 20.0; }
335 table[0][i] = Some((s, i));
336 }
337 }
338
339 for qi in 1..q.len() {
340 for i in qi..n {
341 if lower[i] != q[qi] {
342 continue;
343 }
344 let base = 10.0 + if boundary[i] { 15.0 } else { 0.0 };
345 let j_start = if boundary[i] {
348 qi - 1
349 } else {
350 (qi - 1).max(i.saturating_sub(MAX_NONBOUNDARY_GAP + 1))
351 };
352 let mut best: Option<(f64, usize)> = None;
353 let prev_row = &table[qi - 1];
354 for (j, cell) in prev_row.iter().enumerate().take(i).skip(j_start) {
355 let Some((pscore, _)) = cell else {
356 continue;
357 };
358 let trans = if j + 1 == i {
359 10.0 } else {
361 let gap = i - j - 1;
362 let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
363 if boundary[i] {
364 if crossed_word {
367 continue;
368 }
369 } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
370 continue;
376 }
377 -(gap as f64) * GAP_PENALTY
378 };
379 let cand = pscore + trans;
380 if best.is_none_or(|(b, _)| cand > b) {
381 best = Some((cand, j));
382 }
383 }
384 if let Some((bscore, j)) = best {
385 table[qi][i] = Some((bscore + base, j));
386 }
387 }
388 }
389
390 let last = q.len() - 1;
392 let (mut pos, score) = (0..n)
393 .filter_map(|i| table[last][i].map(|(s, _)| (i, s)))
394 .max_by(|a, b| a.1.total_cmp(&b.1))?;
395 let mut positions = Vec::with_capacity(q.len());
396 for qi in (0..q.len()).rev() {
397 positions.push(pos);
398 pos = table[qi][pos].expect("backtrack hits a filled cell").1;
399 }
400 positions.reverse();
401 Some(Alignment {
402 score: score.max(0.0),
403 positions,
404 })
405}
406
407pub fn match_positions(query: &str, name: &str) -> Vec<usize> {
410 let (leaf, _) = parse_qualified(query);
412 if has_wildcard(leaf) {
413 return glob_positions(leaf, name).unwrap_or_default();
415 }
416 let positions = align(leaf, name).map(|a| a.positions).unwrap_or_default();
417 contiguous_highlight(positions, name)
418}
419
420pub fn parse_qualified(query: &str) -> (&str, Option<&str>) {
426 let sep = query
427 .rmatch_indices("::")
428 .map(|(i, _)| (i, 2usize))
429 .chain(query.rmatch_indices('#').map(|(i, _)| (i, 1usize)))
430 .max_by_key(|&(i, _)| i);
431 match sep {
432 Some((i, len)) if i > 0 && i + len < query.len() => (&query[i + len..], Some(&query[..i])),
433 _ => (query, None),
434 }
435}
436
437fn segments(s: &str) -> Vec<String> {
439 s.split("::")
440 .flat_map(|p| p.split('#'))
441 .filter(|p| !p.is_empty())
442 .map(|p| p.to_ascii_lowercase())
443 .collect()
444}
445
446fn parent_boost(qualifier: &str, parent: Option<&str>) -> Option<f64> {
452 let p = segments(parent?);
453 let q = segments(qualifier);
454 if q.is_empty() || q.len() > p.len() {
455 return None;
456 }
457 let off = p.len() - q.len();
458 (p[off..] == q[..]).then(|| (120.0 + 60.0 * q.len() as f64).min(300.0))
459}
460
461fn contiguous_highlight(positions: Vec<usize>, name: &str) -> Vec<usize> {
469 if positions.is_empty() {
470 return positions;
471 }
472 let boundary = boundaries(&name.chars().collect::<Vec<_>>());
473 let mut out = Vec::with_capacity(positions.len());
474 let mut i = 0;
475 while i < positions.len() {
476 let mut j = i;
478 while j + 1 < positions.len() && positions[j + 1] == positions[j] + 1 {
479 j += 1;
480 }
481 if j > i {
482 out.extend_from_slice(&positions[i..=j]); } else if boundary[positions[i]] {
484 out.push(positions[i]); }
486 i = j + 1;
487 }
488 out
489}
490
491fn subsequence_score(query: &str, name: &str) -> Option<f64> {
494 align(query, name).map(|a| a.score)
495}
496
497pub fn has_wildcard(query: &str) -> bool {
503 query.contains(['*', '?', '.'])
504}
505
506pub fn strip_wildcards(query: &str) -> String {
510 query
511 .chars()
512 .filter(|c| !matches!(c, '*' | '?' | '.'))
513 .collect()
514}
515
516enum Glob {
518 Lit(char), Any, Star, }
522
523fn compile_glob(query: &str) -> Vec<Glob> {
527 query
528 .chars()
529 .filter_map(|c| match c {
530 '*' => Some(Glob::Star),
531 '?' | '.' => Some(Glob::Any),
532 c if c.is_alphanumeric() => Some(Glob::Lit(c.to_ascii_lowercase())),
533 _ => None,
534 })
535 .collect()
536}
537
538fn glob_positions(query: &str, name: &str) -> Option<Vec<usize>> {
544 let mut toks = vec![Glob::Star];
545 toks.extend(compile_glob(query));
546 toks.push(Glob::Star);
547
548 let lower: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
549 let mut ti = 0;
550 let mut ni = 0;
551 let mut positions: Vec<usize> = Vec::new();
552 let mut star: Option<(usize, usize, usize)> = None;
554
555 while ni < lower.len() {
556 match toks.get(ti) {
557 Some(Glob::Lit(c)) if lower[ni] == *c => {
558 positions.push(ni);
559 ti += 1;
560 ni += 1;
561 }
562 Some(Glob::Any) => {
563 ti += 1;
564 ni += 1;
565 }
566 Some(Glob::Star) => {
567 star = Some((ti + 1, ni, positions.len()));
568 ti += 1;
569 }
570 _ => match star {
574 Some((sti, sni, plen)) => {
575 ti = sti;
576 ni = sni + 1;
577 star = Some((sti, sni + 1, plen));
578 positions.truncate(plen);
579 }
580 None => return None,
581 },
582 }
583 }
584 while matches!(toks.get(ti), Some(Glob::Star)) {
585 ti += 1;
586 }
587 (ti == toks.len()).then_some(positions)
588}
589
590fn wildcard_score(query: &str, name: &str) -> Option<f64> {
595 let positions = glob_positions(query, name)?;
596 if positions.is_empty() {
597 return None;
598 }
599 let chars: Vec<char> = name.chars().collect();
600 let boundary = boundaries(&chars);
601 let mut score = 0.0;
602 let mut prev: Option<usize> = None;
603 for &i in &positions {
604 score += 10.0;
605 if boundary[i] {
606 score += 15.0;
607 }
608 match prev {
609 Some(p) if p + 1 == i => score += 10.0, None if i == 0 => score += 20.0, _ => {}
612 }
613 prev = Some(i);
614 }
615 Some(score)
616}
617
618pub fn path_stem(path: &str) -> &str {
621 let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
622 match base.rfind('.') {
623 Some(i) if i > 0 => &base[..i],
624 _ => base,
625 }
626}
627
628fn boundaries(chars: &[char]) -> Vec<bool> {
631 let mut out = vec![false; chars.len()];
632 for i in 0..chars.len() {
633 let c = chars[i];
634 out[i] = if i == 0 {
635 true
636 } else {
637 let prev = chars[i - 1];
638 !prev.is_alphanumeric()
641 || (c.is_uppercase() && prev.is_lowercase())
642 || (c.is_uppercase()
643 && prev.is_uppercase()
644 && chars.get(i + 1).is_some_and(|n| n.is_lowercase()))
645 };
646 }
647 out
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn row(name: &str, kind: &str, repo: i64) -> SymbolRow {
655 SymbolRow {
656 name: name.into(),
657 kind: kind.into(),
658 language: "ruby".into(),
659 file: "f.rb".into(),
660 line: 1,
661 end_line: Some(1),
662 parent: None,
663 repository_id: repo,
664 repo_identity: "r".into(),
665 mtime: None,
666 git_ts: None,
667 visibility: None,
668 }
669 }
670
671 fn total(query: &str, name: &str) -> Option<f64> {
672 score(query, &row(name, "class", 1), None, Boosts::default()).map(|s| s.total)
673 }
674
675 #[test]
676 fn a_typed_capital_picks_the_matching_case() {
677 let upper = total("Symbol", "Symbol").unwrap();
681 let lower = total("Symbol", "symbol").unwrap();
682 assert!(upper > lower, "{upper} > {lower}");
683 assert!(upper - lower > 120.0, "margin {} too small", upper - lower);
685 }
686
687 #[test]
688 fn a_lowercase_query_stays_case_agnostic() {
689 assert_eq!(total("symbol", "symbol"), total("symbol", "Symbol"));
692 assert_eq!(total("user", "User"), total("user", "user"));
693 }
694
695 #[test]
696 fn private_ranks_below_public_on_an_equal_match() {
697 let mut public = row("save", "method", 1);
698 public.visibility = Some("public".into());
699 let mut private = row("save", "method", 1);
700 private.visibility = Some("private".into());
701 let unknown = row("save", "method", 1); let pub_score = score("save", &public, None, Boosts::default()).unwrap();
704 let priv_score = score("save", &private, None, Boosts::default()).unwrap();
705 let unk_score = score("save", &unknown, None, Boosts::default()).unwrap();
706 assert!(pub_score.total > priv_score.total);
707 assert_eq!(
708 pub_score.total, unk_score.total,
709 "unknown carries no penalty"
710 );
711 assert!(priv_score.total > 700.0, "still comfortably above a prefix");
713 }
714
715 #[test]
716 fn exact_beats_prefix_beats_fuzzy() {
717 let exact = total("user", "user").unwrap();
718 let prefix = total("user", "users").unwrap();
719 let fuzzy = total("usr", "user").unwrap();
720 assert!(exact > prefix, "{exact} > {prefix}");
721 assert!(prefix > fuzzy, "{prefix} > {fuzzy}");
722 }
723
724 #[test]
725 fn abbreviations_match() {
726 assert!(total("refundproc", "RefundProcessor").is_some());
727 assert!(total("refproc", "RefundProcessor").is_some());
728 assert!(total("paymnt", "Payments").is_some());
729 assert!(total("perf", "perform").is_some());
730 assert!(total("usr", "User").is_some());
731 assert!(total("ctrl", "Controller").is_some());
733 }
734
735 #[test]
736 fn rejects_scattered_midword_matches() {
737 assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
740 assert!(total("employeescontroller", "EmployeesController").is_some());
741 assert!(total("employescontroller", "EmployeesController").is_some());
743 }
744
745 #[test]
746 fn match_positions_report_what_matched() {
747 assert_eq!(match_positions("foo", "FooThing"), vec![0, 1, 2]);
748 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());
752 }
753
754 #[test]
755 fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
756 assert_eq!(
759 match_positions("employee", "xxxe_employee"),
760 vec![5, 6, 7, 8, 9, 10, 11, 12]
761 );
762 assert_eq!(
764 match_positions("controller", "calc_controller"),
765 (5..15).collect::<Vec<_>>()
766 );
767 assert_eq!(
769 match_positions("widgetcontroller", "WidgetController"),
770 (0..16).collect::<Vec<_>>()
771 );
772 }
773
774 #[test]
775 fn matches_only_span_adjacent_words() {
776 assert_eq!(
778 match_positions("employeescontroller", "employees_controller"),
779 vec![
781 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
782 ]
783 );
784 assert!(subsequence_score("employees", "employee_x_syy").is_none());
786 assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
788 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
790 assert!(subsequence_score("refprocsvc", "RefundProcessingService").is_some());
791 }
792
793 #[test]
794 fn a_contiguous_match_beats_a_farther_boundary_jump() {
795 assert_eq!(match_positions("car", "car_r"), vec![0, 1, 2]);
798 }
799
800 #[test]
801 fn acronyms_highlight_word_initials_across_adjacent_words() {
802 assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
805 assert_eq!(
806 match_positions("abc", "alpha_bravo_charlie"),
807 vec![0, 6, 12] );
809 assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
811 assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); }
813
814 #[test]
815 fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
816 assert!(
820 subsequence_score("employeescontroller", "employee_before_starting_controller")
821 .is_none()
822 );
823 assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
825 assert!(subsequence_score("usr", "user").is_some());
827 assert!(subsequence_score("cfg", "config").is_some());
828 }
829
830 #[test]
831 fn a_contiguous_word_match_outranks_a_scattered_cross_word_one() {
832 let contiguous = total("test", "test_helper").unwrap(); let scattered = total("test", "the_settings_store");
838 if let Some(s) = scattered {
839 assert!(contiguous > s, "contiguous {contiguous} > scattered {s}");
840 }
841 }
842
843 #[test]
844 fn score_and_positions_come_from_the_same_alignment() {
845 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
847 assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
848 assert!(subsequence_score("xyz", "RefundProcessor").is_none());
850 assert!(match_positions("xyz", "RefundProcessor").is_empty());
851 }
852
853 #[test]
854 fn highlights_are_ordered_in_bounds_and_correct_across_varied_inputs() {
855 let cases = [
856 ("usr", "UserService"),
857 ("paymnt", "Payments"),
858 ("wc", "WidgetController"),
859 ("ctrl", "Controller"),
860 ("gp", "get_post"),
861 ("ab", "alpha_beta"),
862 ("refproc", "RefundProcessor"),
863 ("emp", "EmployeesController"),
864 ("http", "HTTPParser"),
865 ];
866 for (q, name) in cases {
867 let nchars: Vec<char> = name.chars().collect();
868 let qchars: Vec<char> = q.chars().filter(|c| c.is_alphanumeric()).collect();
869 let boundary = boundaries(&nchars);
870 let pos = match_positions(q, name);
871 assert!(
872 pos.windows(2).all(|w| w[0] < w[1]),
873 "strictly increasing: {q}/{name} {pos:?}"
874 );
875 let mut qi = 0;
877 for &p in &pos {
878 assert!(p < nchars.len(), "in bounds: {q}/{name}");
879 while qi < qchars.len() && !qchars[qi].eq_ignore_ascii_case(&nchars[p]) {
880 qi += 1;
881 }
882 assert!(
883 qi < qchars.len(),
884 "highlight maps to a query char: {q}/{name}"
885 );
886 qi += 1;
887 }
888 for (idx, &p) in pos.iter().enumerate() {
890 let clumped = (idx > 0 && pos[idx - 1] + 1 == p)
891 || (idx + 1 < pos.len() && p + 1 == pos[idx + 1]);
892 assert!(
893 clumped || boundary[p],
894 "no isolated mid-word highlight: {q}/{name} at {p} {pos:?}"
895 );
896 }
897 }
898 }
899
900 #[test]
901 fn highlights_avoid_isolated_single_chars() {
902 assert_eq!(
904 match_positions("paymnt", "Payments"),
905 vec![0, 1, 2, 3, 5, 6]
906 );
907 assert_eq!(match_positions("usr", "UserService"), vec![0, 1]);
909 assert_eq!(match_positions("ctrl", "Controller"), vec![0, 3, 4]);
911 assert!(match_positions("rp", "wrapper").is_empty());
913 assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
915 }
916
917 #[test]
918 fn an_acronym_at_boundaries_outranks_a_mid_word_alignment() {
919 let acronym = subsequence_score("wc", "WidgetController").unwrap();
921 let midword = subsequence_score("wc", "switchcase").unwrap();
922 assert!(acronym > midword, "{acronym} > {midword}");
923 }
924
925 #[test]
926 fn a_far_path_straggler_never_outranks_a_prefix_match() {
927 let mut straggler = row("Thing", "class", 1);
931 straggler.file = "app/employee_x_syy.rb".into();
932 let prefixed = row("EmployeesController", "class", 1);
933 let pre = score("employees", &prefixed, None, Boosts::default())
934 .unwrap()
935 .total;
936 if let Some(s) = score("employees", &straggler, None, Boosts::default()) {
937 assert!(pre > s.total, "prefix {pre} > path straggler {}", s.total);
938 }
939 }
940
941 #[test]
942 fn snake_case_query_matches_camelcase_name() {
943 assert!(total("widget_controller", "WidgetsController").is_some());
946 assert!(total("widget_controller", "WidgetController").is_some());
947 assert!(total("widget_controller", "AdminController").is_none());
949 }
950
951 #[test]
952 fn wildcard_star_spans_an_explicit_gap() {
953 assert!(total("find*controller", "FindController").is_some());
956 assert!(total("find*controller", "FindUserController").is_some());
957 assert!(total("find*controller", "FindUserAccountController").is_some());
958 assert!(total("find*ctrlr", "FindController").is_none());
961 assert!(total("find*controller", "FindService").is_none());
963 }
964
965 #[test]
966 fn wildcard_question_mark_matches_one_char() {
967 assert!(total("find?controller", "FindXController").is_some());
969 assert!(total("find.controller", "Find1Controller").is_some());
970 assert!(total("find?controller", "FindController").is_none());
972 assert!(total("find?controller", "FindXyController").is_none());
973 }
974
975 #[test]
976 fn wildcard_highlights_only_the_literals() {
977 assert_eq!(
979 match_positions("find*er", "FindController"),
980 vec![0, 1, 2, 3, 12, 13] );
982 }
983
984 #[test]
985 fn wildcard_prefers_boundary_aligned_matches() {
986 let boundary = total("a*b", "Alpha_Bravo").unwrap();
989 let midword = total("a*b", "Alphabet").unwrap();
990 assert!(boundary > midword, "{boundary} > {midword}");
991 }
992
993 #[test]
994 fn non_subsequence_does_not_match() {
995 assert!(total("xyz", "RefundProcessor").is_none());
996 assert!(total("zzz", "User").is_none());
997 }
998
999 #[test]
1000 fn confidence_reflects_quality_and_dominance() {
1001 let exact = vec![Feature {
1002 name: "exact",
1003 value: 1000.0,
1004 }];
1005 let fuzzy = vec![Feature {
1006 name: "fuzzy",
1007 value: 300.0,
1008 }];
1009 assert_eq!(confidence(1000.0, match_quality(&exact), None), 1.0);
1011 let f = confidence(300.0, match_quality(&fuzzy), None);
1013 assert!(f > 0.3 && f < 0.65, "fuzzy confidence {f}");
1014 let tied = confidence(1000.0, match_quality(&exact), Some(1000.0));
1017 assert!(tied < 0.6, "tied exact confidence {tied}");
1018 let dominant = confidence(1000.0, match_quality(&exact), Some(300.0));
1020 assert!(dominant > 0.9, "dominant confidence {dominant}");
1021 }
1022
1023 #[test]
1024 fn parse_qualified_splits_on_scope_separators() {
1025 assert_eq!(parse_qualified("User"), ("User", None));
1026 assert_eq!(parse_qualified("Foo::Bar"), ("Bar", Some("Foo")));
1027 assert_eq!(parse_qualified("App::Foo::Bar"), ("Bar", Some("App::Foo")));
1028 assert_eq!(parse_qualified("Foo::Bar#baz"), ("baz", Some("Foo::Bar")));
1030 assert_eq!(parse_qualified("::Bar"), ("::Bar", None));
1032 assert_eq!(parse_qualified("Foo::"), ("Foo::", None));
1033 }
1034
1035 #[test]
1036 fn parent_boost_matches_the_innermost_scopes() {
1037 assert!(parent_boost("Foo", Some("Foo")).is_some());
1039 assert!(parent_boost("Foo", Some("App::Foo")).is_some());
1040 assert!(parent_boost("App::Foo", Some("App::Foo")).is_some());
1041 let one = parent_boost("Foo", Some("App::Foo")).unwrap();
1043 let two = parent_boost("App::Foo", Some("App::Foo")).unwrap();
1044 assert!(two > one, "{two} > {one}");
1045 assert!(parent_boost("App", Some("App::Foo")).is_none());
1047 assert!(parent_boost("Foo", Some("Foo::Inner")).is_none());
1048 assert!(parent_boost("Foo", None).is_none());
1049 }
1050
1051 #[test]
1052 fn qualifier_ranks_the_symbol_in_the_named_scope_first() {
1053 let in_foo = SymbolRow {
1055 parent: Some("Foo".into()),
1056 ..row("Bar", "class", 1)
1057 };
1058 let in_baz = SymbolRow {
1059 parent: Some("Baz".into()),
1060 ..row("Bar", "class", 1)
1061 };
1062 let foo = score("Foo::Bar", &in_foo, None, Boosts::default())
1063 .unwrap()
1064 .total;
1065 let baz = score("Foo::Bar", &in_baz, None, Boosts::default())
1066 .unwrap()
1067 .total;
1068 assert!(foo > baz, "{foo} > {baz}");
1069 assert!(score("Bar", &in_baz, None, Boosts::default()).is_some());
1071 assert!(score("Foo::Zzz", &in_foo, None, Boosts::default()).is_none());
1073 }
1074
1075 #[test]
1076 fn boundary_alignment_outranks_scattered() {
1077 let aligned = total("rp", "RefundProcessor").unwrap();
1079 let scattered = total("rp", "wrapper").unwrap();
1080 assert!(aligned > scattered, "{aligned} > {scattered}");
1081 }
1082
1083 #[test]
1084 fn path_only_match_surfaces_a_class_in_a_named_file() {
1085 let mut cand = row("Invoice", "class", 1);
1087 cand.file = "app/models/billing.rb".into();
1088 let s = score("billing", &cand, None, Boosts::default()).expect("path match");
1089 assert!(s.features.iter().any(|f| f.name == "path"));
1090
1091 let mut method = row("compute", "method", 1);
1093 method.file = "app/models/billing.rb".into();
1094 assert!(score("billing", &method, None, Boosts::default()).is_none());
1095 }
1096
1097 #[test]
1098 fn path_bonus_reinforces_a_name_match() {
1099 let mut named = row("User", "class", 1);
1100 named.file = "app/models/user.rb".into();
1101 let mut elsewhere = row("User", "class", 1);
1102 elsewhere.file = "app/lib/misc.rb".into();
1103 let with_path = score("user", &named, None, Boosts::default())
1104 .unwrap()
1105 .total;
1106 let without = score("user", &elsewhere, None, Boosts::default())
1107 .unwrap()
1108 .total;
1109 assert!(with_path > without, "{with_path} > {without}");
1110 }
1111
1112 #[test]
1113 fn current_repo_boost_applies() {
1114 let cand = row("User", "class", 7);
1115 let in_repo = score("user", &cand, Some(7), Boosts::default())
1116 .unwrap()
1117 .total;
1118 let out_repo = score("user", &cand, Some(99), Boosts::default())
1119 .unwrap()
1120 .total;
1121 assert!(in_repo > out_repo);
1122 assert_eq!(in_repo - out_repo, 200.0);
1123 }
1124
1125 #[test]
1126 fn learned_boost_adds_to_the_score() {
1127 let cand = row("User", "class", 1);
1128 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1129 let boosted = score(
1130 "user",
1131 &cand,
1132 None,
1133 Boosts {
1134 learned: 150.0,
1135 ..Default::default()
1136 },
1137 )
1138 .unwrap();
1139 assert_eq!(boosted.total - base, 150.0);
1140 assert!(boosted.features.iter().any(|f| f.name == "learned"));
1141 }
1142
1143 #[test]
1144 fn recency_boost_adds_to_the_score() {
1145 let cand = row("User", "class", 1);
1146 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1147 let boosted = score(
1148 "user",
1149 &cand,
1150 None,
1151 Boosts {
1152 recency: 80.0,
1153 ..Default::default()
1154 },
1155 )
1156 .unwrap();
1157 assert_eq!(boosted.total - base, 80.0);
1158 assert!(boosted.features.iter().any(|f| f.name == "recency"));
1159 }
1160
1161 #[test]
1162 fn branch_boost_adds_to_the_score() {
1163 let cand = row("User", "class", 1);
1164 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1165 let boosted = score(
1166 "user",
1167 &cand,
1168 None,
1169 Boosts {
1170 branch: 180.0,
1171 ..Default::default()
1172 },
1173 )
1174 .unwrap();
1175 assert_eq!(boosted.total - base, 180.0);
1176 assert!(boosted.features.iter().any(|f| f.name == "branch"));
1177 }
1178}