Skip to main content

reference_query/search/
score.rs

1//! Ranking: a simple, explainable, additive score.
2//!
3//! Match quality dominates (exact > prefix > abbreviation/subsequence), with
4//! smaller additive features layered on (kind, current-repo). Every component
5//! is recorded so `--explain` can show why a result ranked where it did.
6
7use crate::store::SymbolRow;
8
9/// One named contribution to a score.
10#[derive(Debug, Clone, PartialEq, serde::Serialize)]
11pub struct Feature {
12    pub name: &'static str,
13    pub value: f64,
14}
15
16/// A scored candidate: total plus the per-feature breakdown.
17#[derive(Debug, Clone, PartialEq)]
18pub struct Scored {
19    pub total: f64,
20    pub features: Vec<Feature>,
21}
22
23/// Dynamic, context-dependent boosts computed by [`crate::search`] (which owns
24/// the time math and store lookups). Kept out of the pure match scoring so each
25/// signal can be added without threading more parameters.
26#[derive(Debug, Clone, Copy, Default, PartialEq)]
27pub struct Boosts {
28    /// Behavioral signal: results chosen before for this query.
29    pub learned: f64,
30    /// Git/filesystem signal: symbols in recently-modified files.
31    pub recency: f64,
32    /// Branch signal: symbols in files you're changing on this branch (or their
33    /// directory neighbors) — where you're most likely working.
34    pub branch: f64,
35}
36
37/// Score `cand` for `query`. Returns `None` when the candidate doesn't match at
38/// all (not even as a subsequence), filtering FTS trigram noise.
39///
40/// `boosts` carries the dynamic signals (behavioral, recency) computed by
41/// [`crate::search`], which owns the time math.
42pub 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    // Match quality on the symbol name — the dominant term.
54    let wildcard = has_wildcard(&q);
55    let name_matched = if wildcard {
56        // explicit glob: literal segments separated by the user's `*`/`?` gaps
57        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        // shorter remaining tail ranks higher
74        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    // Layer 3: path / filename matching (same glob/fuzzy split as the name).
91    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        // a file named after the query reinforces a name match (small bonus)
99        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        // no name match: a path hit only surfaces a file's primary definitions
107        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    // Kind weight — definitions you navigate to most sit slightly higher.
124    // Top-level types rank alongside classes; methods/functions stay neutral.
125    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    // Current-repo boost — the repo you're in dominates other repos.
138    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    // Learned boost — results you've chosen before for this query rank higher.
148    if boosts.learned > 0.0 {
149        features.push(Feature {
150            name: "learned",
151            value: boosts.learned,
152        });
153    }
154
155    // Recency boost — symbols in recently-modified files rank higher.
156    if boosts.recency > 0.0 {
157        features.push(Feature {
158            name: "recency",
159            value: boosts.recency,
160        });
161    }
162
163    // Branch boost — symbols in files you're changing on this branch (or nearby).
164    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
175/// Largest gap (chars skipped) allowed between two matched query chars that land
176/// *mid-word* (not at a word boundary). Boundary jumps are how abbreviations work
177/// and stay unlimited; off-boundary we tolerate a couple of skipped chars — a
178/// consonant run like `ctrl`→`Controller` (the `c`→`t` skips `on`), or a typo —
179/// but no more. A bigger gap (the `s` in `employeescontroller` reaching past
180/// `XYZ`, three chars) is coincidence, not a match.
181const MAX_NONBOUNDARY_GAP: usize = 2;
182
183/// Penalty per skipped char between two matched chars. Strong enough that a
184/// closer match wins over a farther one — so the query's trailing chars don't
185/// straggle to a distant word boundary (the `r` of a query landing in `.rb`
186/// instead of `controller`) — but not so strong it lets a scattered mid-word
187/// alignment outrank a boundary-aligned abbreviation.
188const GAP_PENALTY: f64 = 3.0;
189
190/// One way `query` lines up against `name`: its score and the matched indices.
191struct Alignment {
192    score: f64,
193    positions: Vec<usize>,
194}
195
196/// Find the **best** alignment of `query` as a subsequence of `name`, maximizing
197/// matches at word boundaries (camelCase / underscore) and contiguous runs while
198/// penalizing gaps. `None` if `query` isn't a subsequence. Handles abbreviations
199/// (`refproc → RefundProcessor`, `usr → User`, `paymnt → Payments`) and ignores
200/// separators in the query, so a snake_case query matches CamelCase
201/// (`widget_controller → WidgetsController`).
202///
203/// This is a small dynamic program rather than a greedy left-to-right scan: greedy
204/// takes the *first* candidate for each query char, which mis-aligns (matching the
205/// `e` in `xxxe_employee` instead of the contiguous `employee`, or letting a
206/// trailing char straggle to a far position). The DP considers every placement and
207/// keeps the highest-scoring one, so the score and the highlight reflect the match
208/// a human would read.
209fn 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    // prefix count of word boundaries, so we can ask "is a whole word skipped
226    // between j and i?" in O(1) — the "only span adjacent words" rule
227    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    // table[qi][i] = best (score, backpointer) for aligning q[0..=qi] with q[qi]
233    // landing on name position `i`; `None` if q[qi] can't end there. The
234    // backpointer is the position where q[qi-1] matched (self for qi == 0).
235    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; // anchored at the very start
245            }
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            // a non-boundary char can only follow within MAX_NONBOUNDARY_GAP;
257            // a boundary char may follow from the previous word (scan back further)
258            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 // contiguous run
271                } else {
272                    let gap = i - j - 1;
273                    let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
274                    if boundary[i] {
275                        // entering a new word: only the *adjacent* one — reject if
276                        // a whole word boundary sits between j and i (a word skipped)
277                        if crossed_word {
278                            continue;
279                        }
280                    } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
281                        // a mid-word target may follow only a small same-word gap (a
282                        // dropped vowel). A larger gap, or one that crosses into a
283                        // new word, is scatter — you enter a new word at its
284                        // boundary, never mid-word (the `ees` of `employees`
285                        // threading employee→b[e]fore→[s]tarting).
286                        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    // best end position for the final query char, then backtrack to collect indices
302    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
318/// The char indices in `name` that `query` matched, from the best alignment —
319/// for highlighting *what* matched. Empty if `query` isn't a subsequence.
320pub 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
327/// Score `query` as a subsequence of `name` (the best alignment's score), or
328/// `None` if it isn't a subsequence.
329fn subsequence_score(query: &str, name: &str) -> Option<f64> {
330    align(query, name).map(|a| a.score)
331}
332
333/// Does `query` use wildcard syntax — `*` (any run), `?`/`.` (one char)? When it
334/// does, matching switches from fuzzy subsequence to an explicit glob: literal
335/// chars match *contiguously*, and the only gaps are the ones the user marked.
336/// `find*controller` keeps `FindController` and `FindUserController` but, unlike
337/// fuzzy, won't reach into a scattered `FxIxNxDxController`.
338pub fn has_wildcard(query: &str) -> bool {
339    query.contains(['*', '?', '.'])
340}
341
342/// A wildcard query's literal characters, metachars removed — used to seed the
343/// store's candidate recall (which keys off literal trigrams) before the glob
344/// does the precise matching. `find*controller` → `findcontroller`.
345pub fn strip_wildcards(query: &str) -> String {
346    query
347        .chars()
348        .filter(|c| !matches!(c, '*' | '?' | '.'))
349        .collect()
350}
351
352/// One token of a compiled wildcard pattern.
353enum Glob {
354    Lit(char), // a literal (lowercased) char — matches itself
355    Any,       // `?` / `.` — exactly one char
356    Star,      // `*` — zero or more chars
357}
358
359/// Compile a wildcard query into glob tokens. The query's own separators
360/// (`_`, `-`, …) are ignored, like the fuzzy matcher, so `emp_*_ctrl` and
361/// `emp*ctrl` compile alike.
362fn 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
374/// Match a wildcard `query` against `name`, unanchored (the pattern may match any
375/// substring — implicit `*` at both ends). Returns the indices the *literal*
376/// chars matched (the highlight), or `None` if it doesn't match. Classic
377/// two-pointer glob with `*` backtracking; literal positions are recorded and
378/// rolled back on each backtrack.
379fn 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    // the last `*` to fall back to: (token index after it, name index, #positions)
389    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            // mismatch, or pattern ran out with chars left: extend the last star
407            // by one char and retry from just after it; no star to fall back to
408            // means no match
409            _ => 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
426/// Score a wildcard match from its literal positions — the same boundary /
427/// contiguity / start signals as the fuzzy scorer, but no gap penalty: the gaps
428/// are the `*`/`?` the user placed deliberately. `None` when it doesn't match,
429/// or when nothing literal matched (an all-wildcard query like `*`).
430fn 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, // contiguous literal run
446            None if i == 0 => score += 20.0,        // anchored at the very start
447            _ => {}
448        }
449        prev = Some(i);
450    }
451    Some(score)
452}
453
454/// The filename stem of a repo-relative path: last segment, extension dropped.
455/// `app/models/user.rb` → `user`.
456fn 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
464/// Mark word-boundary positions: index 0, anything after `_`/non-alphanumeric,
465/// and camelCase humps (lower→Upper, and the last cap of an ACRONYMWord run).
466fn 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            // start of a word: after a separator, a lower→Upper hump, or the
475            // tail cap of an acronym run (the `P` in `HTTPParser`)
476            !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        // a consonant run skipping a couple of chars (gap 2) still matches
526        assert!(total("ctrl", "Controller").is_some());
527    }
528
529    #[test]
530    fn rejects_scattered_midword_matches() {
531        // the trailing `s` of the query landed past `XYZ` mid-word — coincidence,
532        // not a match. The clean plural (boundary/contiguous `s`) still matches.
533        assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
534        assert!(total("employeescontroller", "EmployeesController").is_some());
535        // a single skipped char off-boundary is tolerated (looks like a typo)
536        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]); // F, T
543        // separator-insensitive: snake query highlights across CamelCase
544        assert_eq!(match_positions("wc", "WidgetController"), vec![0, 6]); // W, C
545        assert!(match_positions("xyz", "FooThing").is_empty());
546    }
547
548    #[test]
549    fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
550        // the bug: a greedy scan anchored on the first `e` (in `xxxe`) and lit up
551        // a scattered match; the best alignment is the contiguous `employee`.
552        assert_eq!(
553            match_positions("employee", "xxxe_employee"),
554            vec![5, 6, 7, 8, 9, 10, 11, 12]
555        );
556        // align to the `controller` word, not a stray earlier `c` in `calc`
557        assert_eq!(
558            match_positions("controller", "calc_controller"),
559            (5..15).collect::<Vec<_>>()
560        );
561        // and to the camelCase humps across the whole name
562        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        // a query char may jump to the *next* word but not skip a whole one
571        assert_eq!(
572            match_positions("employeescontroller", "employees_controller"),
573            // employees (0-8) + controller (10-19); the `_` at 9 is skipped
574            vec![
575                0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
576            ]
577        );
578        // the trailing `s` would have to skip the `x` word to reach `syy` — reject
579        assert!(subsequence_score("employees", "employee_x_syy").is_none());
580        // skipping a whole middle word isn't a match either
581        assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
582        // adjacent-word abbreviations still match
583        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        // both `r`s are reachable; the closer contiguous one wins, so the query
590        // doesn't straggle to a separated boundary `r` (e.g. a file extension)
591        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        // crossing word boundaries IS correct for an acronym — each query char
597        // lands on a word start (`uc` → the U and C humps of UserController)
598        assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
599        assert_eq!(
600            match_positions("abc", "alpha_bravo_charlie"),
601            vec![0, 6, 12] // a, b, c — each a word initial
602        );
603        // but only *adjacent* words — skipping a whole word is not a match
604        assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
605        assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); // alpha→charlie skips bravo
606    }
607
608    #[test]
609    fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
610        // the reported scatter: `employeescontroller` threaded its `ees` through
611        // employee → b[e]fore → [s]tarting (small gaps crossing word boundaries
612        // into mid-word chars). You enter a new word at its boundary, not mid-word.
613        assert!(
614            subsequence_score("employeescontroller", "employee_before_starting_controller")
615                .is_none()
616        );
617        // the clean target still matches
618        assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
619        // and within-word vowel drops still match (the gap stays in one word)
620        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        // `test` scatters across `the`+`settings` (jump + dropped vowel — the same
627        // shape as a real abbreviation, so it still matches), but a clean
628        // contiguous match must rank well above it. Ranking, not rejection, is the
629        // defense against scatter.
630        let contiguous = total("test", "test_helper").unwrap(); // prefix
631        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        // a match yields a score and exactly one highlight per query char
640        assert!(subsequence_score("refproc", "RefundProcessor").is_some());
641        assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
642        // a non-match yields neither
643        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        // both letters on word boundaries (acronym) beats them landing mid-word
687        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        // "employees" can match the stem `employee_x_syy` only via a trailing `s`
695        // straggling to a far word boundary — a weak match. The real target, where
696        // "employees" is a prefix, dominates via the prefix layer.
697        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        // typed a snake_case query, want the CamelCase class — even when the
711        // class is plural and you forgot the `s`
712        assert!(total("widget_controller", "WidgetsController").is_some());
713        assert!(total("widget_controller", "WidgetController").is_some());
714        // unrelated controller still doesn't match
715        assert!(total("widget_controller", "AdminController").is_none());
716    }
717
718    #[test]
719    fn wildcard_star_spans_an_explicit_gap() {
720        // `*` bridges any run, so the scattered tail the fuzzy gate rejects is
721        // exactly what an explicit star asks for
722        assert!(total("find*controller", "FindController").is_some());
723        assert!(total("find*controller", "FindUserController").is_some());
724        assert!(total("find*controller", "FindUserAccountController").is_some());
725        // but the literals must still appear contiguously — `controller` is a
726        // literal, not an abbreviation
727        assert!(total("find*ctrlr", "FindController").is_none());
728        // and a name missing a literal segment doesn't match
729        assert!(total("find*controller", "FindService").is_none());
730    }
731
732    #[test]
733    fn wildcard_question_mark_matches_one_char() {
734        // `?` and `.` each consume exactly one char
735        assert!(total("find?controller", "FindXController").is_some());
736        assert!(total("find.controller", "Find1Controller").is_some());
737        // zero chars or two chars in the slot don't fit a single `?`
738        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        // the gap chars aren't highlighted, only the literals the user typed
745        assert_eq!(
746            match_positions("find*er", "FindController"),
747            vec![0, 1, 2, 3, 12, 13] // Find + er
748        );
749    }
750
751    #[test]
752    fn wildcard_prefers_boundary_aligned_matches() {
753        // a star landing the second literal on a word boundary outranks one
754        // landing it mid-word
755        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        // "rp" aligned to Refund/Processor humps should beat an incidental match
769        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        // name "Invoice" doesn't match "billing", but the file does
777        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        // a method (not a primary definition) in the same file does NOT surface
783        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}