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
23#[derive(Debug, Clone, Copy, Default, PartialEq)]
27pub struct Boosts {
28 pub learned: f64,
30 pub recency: f64,
32 pub branch: f64,
35}
36
37pub fn score(
43 query: &str,
44 cand: &SymbolRow,
45 current_repo_id: Option<i64>,
46 boosts: Boosts,
47) -> Option<Scored> {
48 let q = query.to_ascii_lowercase();
49 let name_lower = cand.name.to_ascii_lowercase();
50
51 let mut features = Vec::new();
52
53 let wildcard = has_wildcard(&q);
55 let name_matched = if wildcard {
56 if let Some(s) = wildcard_score(&q, &cand.name) {
58 features.push(Feature {
59 name: "wildcard",
60 value: s.min(600.0),
61 });
62 true
63 } else {
64 false
65 }
66 } else if name_lower == q {
67 features.push(Feature {
68 name: "exact",
69 value: 1000.0,
70 });
71 true
72 } else if name_lower.starts_with(&q) {
73 let tail = cand.name.chars().count().saturating_sub(q.chars().count());
75 features.push(Feature {
76 name: "prefix",
77 value: 700.0 - (tail as f64).min(100.0),
78 });
79 true
80 } else if let Some(s) = subsequence_score(&q, &cand.name) {
81 features.push(Feature {
82 name: "fuzzy",
83 value: s.min(600.0),
84 });
85 true
86 } else {
87 false
88 };
89
90 let stem = path_stem(&cand.file);
92 let path_match = if wildcard {
93 wildcard_score(&q, stem)
94 } else {
95 subsequence_score(&q, stem)
96 };
97 if name_matched {
98 if let Some(ps) = path_match {
100 features.push(Feature {
101 name: "path",
102 value: (ps * 0.2).min(50.0),
103 });
104 }
105 } else {
106 match path_match {
108 Some(ps)
109 if matches!(
110 cand.kind.as_str(),
111 "class" | "module" | "struct" | "enum" | "trait"
112 ) =>
113 {
114 features.push(Feature {
115 name: "path",
116 value: (ps * 0.6).min(300.0),
117 });
118 }
119 _ => return None,
120 }
121 }
122
123 let kind = match cand.kind.as_str() {
126 "class" | "struct" | "trait" => 15.0,
127 "module" | "enum" => 12.0,
128 _ => 0.0,
129 };
130 if kind != 0.0 {
131 features.push(Feature {
132 name: "kind",
133 value: kind,
134 });
135 }
136
137 if let Some(cur) = current_repo_id
139 && cur == cand.repository_id
140 {
141 features.push(Feature {
142 name: "current_repo",
143 value: 200.0,
144 });
145 }
146
147 if boosts.learned > 0.0 {
149 features.push(Feature {
150 name: "learned",
151 value: boosts.learned,
152 });
153 }
154
155 if boosts.recency > 0.0 {
157 features.push(Feature {
158 name: "recency",
159 value: boosts.recency,
160 });
161 }
162
163 if boosts.branch > 0.0 {
165 features.push(Feature {
166 name: "branch",
167 value: boosts.branch,
168 });
169 }
170
171 let total = features.iter().map(|f| f.value).sum();
172 Some(Scored { total, features })
173}
174
175const MAX_NONBOUNDARY_GAP: usize = 2;
182
183const GAP_PENALTY: f64 = 3.0;
189
190struct Alignment {
192 score: f64,
193 positions: Vec<usize>,
194}
195
196fn align(query: &str, name: &str) -> Option<Alignment> {
210 let q: Vec<char> = query
211 .chars()
212 .filter(|c| c.is_alphanumeric())
213 .map(|c| c.to_ascii_lowercase())
214 .collect();
215 if q.is_empty() {
216 return None;
217 }
218 let chars: Vec<char> = name.chars().collect();
219 let n = chars.len();
220 if q.len() > n {
221 return None;
222 }
223 let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
224 let boundary = boundaries(&chars);
225 let mut bnd_prefix = vec![0usize; n + 1];
228 for i in 0..n {
229 bnd_prefix[i + 1] = bnd_prefix[i] + boundary[i] as usize;
230 }
231
232 let mut table: Vec<Vec<Option<(f64, usize)>>> = vec![vec![None; n]; q.len()];
236
237 for (i, &c) in lower.iter().enumerate() {
238 if c == q[0] {
239 let mut s = 10.0;
240 if boundary[i] {
241 s += 15.0;
242 }
243 if i == 0 {
244 s += 20.0; }
246 table[0][i] = Some((s, i));
247 }
248 }
249
250 for qi in 1..q.len() {
251 for i in qi..n {
252 if lower[i] != q[qi] {
253 continue;
254 }
255 let base = 10.0 + if boundary[i] { 15.0 } else { 0.0 };
256 let j_start = if boundary[i] {
259 qi - 1
260 } else {
261 (qi - 1).max(i.saturating_sub(MAX_NONBOUNDARY_GAP + 1))
262 };
263 let mut best: Option<(f64, usize)> = None;
264 let prev_row = &table[qi - 1];
265 for (j, cell) in prev_row.iter().enumerate().take(i).skip(j_start) {
266 let Some((pscore, _)) = cell else {
267 continue;
268 };
269 let trans = if j + 1 == i {
270 10.0 } else {
272 let gap = i - j - 1;
273 let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
274 if boundary[i] {
275 if crossed_word {
278 continue;
279 }
280 } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
281 continue;
287 }
288 -(gap as f64) * GAP_PENALTY
289 };
290 let cand = pscore + trans;
291 if best.is_none_or(|(b, _)| cand > b) {
292 best = Some((cand, j));
293 }
294 }
295 if let Some((bscore, j)) = best {
296 table[qi][i] = Some((bscore + base, j));
297 }
298 }
299 }
300
301 let last = q.len() - 1;
303 let (mut pos, score) = (0..n)
304 .filter_map(|i| table[last][i].map(|(s, _)| (i, s)))
305 .max_by(|a, b| a.1.total_cmp(&b.1))?;
306 let mut positions = Vec::with_capacity(q.len());
307 for qi in (0..q.len()).rev() {
308 positions.push(pos);
309 pos = table[qi][pos].expect("backtrack hits a filled cell").1;
310 }
311 positions.reverse();
312 Some(Alignment {
313 score: score.max(0.0),
314 positions,
315 })
316}
317
318pub fn match_positions(query: &str, name: &str) -> Vec<usize> {
321 if has_wildcard(query) {
322 return glob_positions(query, name).unwrap_or_default();
323 }
324 align(query, name).map(|a| a.positions).unwrap_or_default()
325}
326
327fn subsequence_score(query: &str, name: &str) -> Option<f64> {
330 align(query, name).map(|a| a.score)
331}
332
333pub fn has_wildcard(query: &str) -> bool {
339 query.contains(['*', '?', '.'])
340}
341
342pub fn strip_wildcards(query: &str) -> String {
346 query
347 .chars()
348 .filter(|c| !matches!(c, '*' | '?' | '.'))
349 .collect()
350}
351
352enum Glob {
354 Lit(char), Any, Star, }
358
359fn compile_glob(query: &str) -> Vec<Glob> {
363 query
364 .chars()
365 .filter_map(|c| match c {
366 '*' => Some(Glob::Star),
367 '?' | '.' => Some(Glob::Any),
368 c if c.is_alphanumeric() => Some(Glob::Lit(c.to_ascii_lowercase())),
369 _ => None,
370 })
371 .collect()
372}
373
374fn glob_positions(query: &str, name: &str) -> Option<Vec<usize>> {
380 let mut toks = vec![Glob::Star];
381 toks.extend(compile_glob(query));
382 toks.push(Glob::Star);
383
384 let lower: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
385 let mut ti = 0;
386 let mut ni = 0;
387 let mut positions: Vec<usize> = Vec::new();
388 let mut star: Option<(usize, usize, usize)> = None;
390
391 while ni < lower.len() {
392 match toks.get(ti) {
393 Some(Glob::Lit(c)) if lower[ni] == *c => {
394 positions.push(ni);
395 ti += 1;
396 ni += 1;
397 }
398 Some(Glob::Any) => {
399 ti += 1;
400 ni += 1;
401 }
402 Some(Glob::Star) => {
403 star = Some((ti + 1, ni, positions.len()));
404 ti += 1;
405 }
406 _ => match star {
410 Some((sti, sni, plen)) => {
411 ti = sti;
412 ni = sni + 1;
413 star = Some((sti, sni + 1, plen));
414 positions.truncate(plen);
415 }
416 None => return None,
417 },
418 }
419 }
420 while matches!(toks.get(ti), Some(Glob::Star)) {
421 ti += 1;
422 }
423 (ti == toks.len()).then_some(positions)
424}
425
426fn wildcard_score(query: &str, name: &str) -> Option<f64> {
431 let positions = glob_positions(query, name)?;
432 if positions.is_empty() {
433 return None;
434 }
435 let chars: Vec<char> = name.chars().collect();
436 let boundary = boundaries(&chars);
437 let mut score = 0.0;
438 let mut prev: Option<usize> = None;
439 for &i in &positions {
440 score += 10.0;
441 if boundary[i] {
442 score += 15.0;
443 }
444 match prev {
445 Some(p) if p + 1 == i => score += 10.0, None if i == 0 => score += 20.0, _ => {}
448 }
449 prev = Some(i);
450 }
451 Some(score)
452}
453
454fn path_stem(path: &str) -> &str {
457 let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
458 match base.rfind('.') {
459 Some(i) if i > 0 => &base[..i],
460 _ => base,
461 }
462}
463
464fn boundaries(chars: &[char]) -> Vec<bool> {
467 let mut out = vec![false; chars.len()];
468 for i in 0..chars.len() {
469 let c = chars[i];
470 out[i] = if i == 0 {
471 true
472 } else {
473 let prev = chars[i - 1];
474 !prev.is_alphanumeric()
477 || (c.is_uppercase() && prev.is_lowercase())
478 || (c.is_uppercase()
479 && prev.is_uppercase()
480 && chars.get(i + 1).is_some_and(|n| n.is_lowercase()))
481 };
482 }
483 out
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn row(name: &str, kind: &str, repo: i64) -> SymbolRow {
491 SymbolRow {
492 name: name.into(),
493 kind: kind.into(),
494 language: "ruby".into(),
495 file: "f.rb".into(),
496 line: 1,
497 parent: None,
498 repository_id: repo,
499 repo_identity: "r".into(),
500 mtime: None,
501 git_ts: None,
502 }
503 }
504
505 fn total(query: &str, name: &str) -> Option<f64> {
506 score(query, &row(name, "class", 1), None, Boosts::default()).map(|s| s.total)
507 }
508
509 #[test]
510 fn exact_beats_prefix_beats_fuzzy() {
511 let exact = total("user", "user").unwrap();
512 let prefix = total("user", "users").unwrap();
513 let fuzzy = total("usr", "user").unwrap();
514 assert!(exact > prefix, "{exact} > {prefix}");
515 assert!(prefix > fuzzy, "{prefix} > {fuzzy}");
516 }
517
518 #[test]
519 fn abbreviations_match() {
520 assert!(total("refundproc", "RefundProcessor").is_some());
521 assert!(total("refproc", "RefundProcessor").is_some());
522 assert!(total("paymnt", "Payments").is_some());
523 assert!(total("perf", "perform").is_some());
524 assert!(total("usr", "User").is_some());
525 assert!(total("ctrl", "Controller").is_some());
527 }
528
529 #[test]
530 fn rejects_scattered_midword_matches() {
531 assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
534 assert!(total("employeescontroller", "EmployeesController").is_some());
535 assert!(total("employescontroller", "EmployeesController").is_some());
537 }
538
539 #[test]
540 fn match_positions_report_what_matched() {
541 assert_eq!(match_positions("foo", "FooThing"), vec![0, 1, 2]);
542 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());
546 }
547
548 #[test]
549 fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
550 assert_eq!(
553 match_positions("employee", "xxxe_employee"),
554 vec![5, 6, 7, 8, 9, 10, 11, 12]
555 );
556 assert_eq!(
558 match_positions("controller", "calc_controller"),
559 (5..15).collect::<Vec<_>>()
560 );
561 assert_eq!(
563 match_positions("widgetcontroller", "WidgetController"),
564 (0..16).collect::<Vec<_>>()
565 );
566 }
567
568 #[test]
569 fn matches_only_span_adjacent_words() {
570 assert_eq!(
572 match_positions("employeescontroller", "employees_controller"),
573 vec![
575 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
576 ]
577 );
578 assert!(subsequence_score("employees", "employee_x_syy").is_none());
580 assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
582 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
584 assert!(subsequence_score("refprocsvc", "RefundProcessingService").is_some());
585 }
586
587 #[test]
588 fn a_contiguous_match_beats_a_farther_boundary_jump() {
589 assert_eq!(match_positions("car", "car_r"), vec![0, 1, 2]);
592 }
593
594 #[test]
595 fn acronyms_highlight_word_initials_across_adjacent_words() {
596 assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
599 assert_eq!(
600 match_positions("abc", "alpha_bravo_charlie"),
601 vec![0, 6, 12] );
603 assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
605 assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); }
607
608 #[test]
609 fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
610 assert!(
614 subsequence_score("employeescontroller", "employee_before_starting_controller")
615 .is_none()
616 );
617 assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
619 assert!(subsequence_score("usr", "user").is_some());
621 assert!(subsequence_score("cfg", "config").is_some());
622 }
623
624 #[test]
625 fn a_contiguous_word_match_outranks_a_scattered_cross_word_one() {
626 let contiguous = total("test", "test_helper").unwrap(); let scattered = total("test", "the_settings_store");
632 if let Some(s) = scattered {
633 assert!(contiguous > s, "contiguous {contiguous} > scattered {s}");
634 }
635 }
636
637 #[test]
638 fn score_and_positions_come_from_the_same_alignment() {
639 assert!(subsequence_score("refproc", "RefundProcessor").is_some());
641 assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
642 assert!(subsequence_score("xyz", "RefundProcessor").is_none());
644 assert!(match_positions("xyz", "RefundProcessor").is_empty());
645 }
646
647 #[test]
648 fn highlights_are_ordered_in_bounds_and_correct_across_varied_inputs() {
649 let cases = [
650 ("usr", "UserService"),
651 ("paymnt", "Payments"),
652 ("wc", "WidgetController"),
653 ("ctrl", "Controller"),
654 ("gp", "get_post"),
655 ("ab", "alpha_beta"),
656 ("refproc", "RefundProcessor"),
657 ("emp", "EmployeesController"),
658 ("http", "HTTPParser"),
659 ];
660 for (q, name) in cases {
661 let nchars: Vec<char> = name.chars().collect();
662 let qchars: Vec<char> = q.chars().filter(|c| c.is_alphanumeric()).collect();
663 let pos = match_positions(q, name);
664 assert_eq!(
665 pos.len(),
666 qchars.len(),
667 "one highlight per query char: {q}/{name}"
668 );
669 assert!(
670 pos.windows(2).all(|w| w[0] < w[1]),
671 "strictly increasing: {q}/{name} {pos:?}"
672 );
673 for (qi, &p) in pos.iter().enumerate() {
674 assert!(p < nchars.len(), "in bounds: {q}/{name}");
675 assert_eq!(
676 nchars[p].to_ascii_lowercase(),
677 qchars[qi].to_ascii_lowercase(),
678 "highlighted char equals the query char: {q}/{name} at {p}"
679 );
680 }
681 }
682 }
683
684 #[test]
685 fn an_acronym_at_boundaries_outranks_a_mid_word_alignment() {
686 let acronym = subsequence_score("wc", "WidgetController").unwrap();
688 let midword = subsequence_score("wc", "switchcase").unwrap();
689 assert!(acronym > midword, "{acronym} > {midword}");
690 }
691
692 #[test]
693 fn a_far_path_straggler_never_outranks_a_prefix_match() {
694 let mut straggler = row("Thing", "class", 1);
698 straggler.file = "app/employee_x_syy.rb".into();
699 let prefixed = row("EmployeesController", "class", 1);
700 let pre = score("employees", &prefixed, None, Boosts::default())
701 .unwrap()
702 .total;
703 if let Some(s) = score("employees", &straggler, None, Boosts::default()) {
704 assert!(pre > s.total, "prefix {pre} > path straggler {}", s.total);
705 }
706 }
707
708 #[test]
709 fn snake_case_query_matches_camelcase_name() {
710 assert!(total("widget_controller", "WidgetsController").is_some());
713 assert!(total("widget_controller", "WidgetController").is_some());
714 assert!(total("widget_controller", "AdminController").is_none());
716 }
717
718 #[test]
719 fn wildcard_star_spans_an_explicit_gap() {
720 assert!(total("find*controller", "FindController").is_some());
723 assert!(total("find*controller", "FindUserController").is_some());
724 assert!(total("find*controller", "FindUserAccountController").is_some());
725 assert!(total("find*ctrlr", "FindController").is_none());
728 assert!(total("find*controller", "FindService").is_none());
730 }
731
732 #[test]
733 fn wildcard_question_mark_matches_one_char() {
734 assert!(total("find?controller", "FindXController").is_some());
736 assert!(total("find.controller", "Find1Controller").is_some());
737 assert!(total("find?controller", "FindController").is_none());
739 assert!(total("find?controller", "FindXyController").is_none());
740 }
741
742 #[test]
743 fn wildcard_highlights_only_the_literals() {
744 assert_eq!(
746 match_positions("find*er", "FindController"),
747 vec![0, 1, 2, 3, 12, 13] );
749 }
750
751 #[test]
752 fn wildcard_prefers_boundary_aligned_matches() {
753 let boundary = total("a*b", "Alpha_Bravo").unwrap();
756 let midword = total("a*b", "Alphabet").unwrap();
757 assert!(boundary > midword, "{boundary} > {midword}");
758 }
759
760 #[test]
761 fn non_subsequence_does_not_match() {
762 assert!(total("xyz", "RefundProcessor").is_none());
763 assert!(total("zzz", "User").is_none());
764 }
765
766 #[test]
767 fn boundary_alignment_outranks_scattered() {
768 let aligned = total("rp", "RefundProcessor").unwrap();
770 let scattered = total("rp", "wrapper").unwrap();
771 assert!(aligned > scattered, "{aligned} > {scattered}");
772 }
773
774 #[test]
775 fn path_only_match_surfaces_a_class_in_a_named_file() {
776 let mut cand = row("Invoice", "class", 1);
778 cand.file = "app/models/billing.rb".into();
779 let s = score("billing", &cand, None, Boosts::default()).expect("path match");
780 assert!(s.features.iter().any(|f| f.name == "path"));
781
782 let mut method = row("compute", "method", 1);
784 method.file = "app/models/billing.rb".into();
785 assert!(score("billing", &method, None, Boosts::default()).is_none());
786 }
787
788 #[test]
789 fn path_bonus_reinforces_a_name_match() {
790 let mut named = row("User", "class", 1);
791 named.file = "app/models/user.rb".into();
792 let mut elsewhere = row("User", "class", 1);
793 elsewhere.file = "app/lib/misc.rb".into();
794 let with_path = score("user", &named, None, Boosts::default())
795 .unwrap()
796 .total;
797 let without = score("user", &elsewhere, None, Boosts::default())
798 .unwrap()
799 .total;
800 assert!(with_path > without, "{with_path} > {without}");
801 }
802
803 #[test]
804 fn current_repo_boost_applies() {
805 let cand = row("User", "class", 7);
806 let in_repo = score("user", &cand, Some(7), Boosts::default())
807 .unwrap()
808 .total;
809 let out_repo = score("user", &cand, Some(99), Boosts::default())
810 .unwrap()
811 .total;
812 assert!(in_repo > out_repo);
813 assert_eq!(in_repo - out_repo, 200.0);
814 }
815
816 #[test]
817 fn learned_boost_adds_to_the_score() {
818 let cand = row("User", "class", 1);
819 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
820 let boosted = score(
821 "user",
822 &cand,
823 None,
824 Boosts {
825 learned: 150.0,
826 ..Default::default()
827 },
828 )
829 .unwrap();
830 assert_eq!(boosted.total - base, 150.0);
831 assert!(boosted.features.iter().any(|f| f.name == "learned"));
832 }
833
834 #[test]
835 fn recency_boost_adds_to_the_score() {
836 let cand = row("User", "class", 1);
837 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
838 let boosted = score(
839 "user",
840 &cand,
841 None,
842 Boosts {
843 recency: 80.0,
844 ..Default::default()
845 },
846 )
847 .unwrap();
848 assert_eq!(boosted.total - base, 80.0);
849 assert!(boosted.features.iter().any(|f| f.name == "recency"));
850 }
851
852 #[test]
853 fn branch_boost_adds_to_the_score() {
854 let cand = row("User", "class", 1);
855 let base = score("user", &cand, None, Boosts::default()).unwrap().total;
856 let boosted = score(
857 "user",
858 &cand,
859 None,
860 Boosts {
861 branch: 180.0,
862 ..Default::default()
863 },
864 )
865 .unwrap();
866 assert_eq!(boosted.total - base, 180.0);
867 assert!(boosted.features.iter().any(|f| f.name == "branch"));
868 }
869}