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/// Absolute match quality in [0,1] — how good the match *itself* is, independent
24/// of ranking boosts. The dominant term in [`confidence`]. Exact is certain; a
25/// prefix nearly so; a fuzzy/abbreviation match scales with its alignment; a
26/// path-only match (name didn't match) is weak.
27pub 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            // the fuzzy feature value is the alignment score (capped ~600)
34            "fuzzy" => return (0.30 + 0.35 * (f.value / 600.0)).clamp(0.30, 0.65),
35            _ => {}
36        }
37    }
38    0.25 // path-only, or no name match at all
39}
40
41/// Presented confidence in [0,1]: match quality scaled by *dominance* — how much
42/// this result leads the strongest other one. A unique strong match → ~1.0;
43/// evenly-tied candidates → ~0.5 (rq isn't sure which you mean); a lone weak
44/// fuzzy match stays low. `best_other` is the top score among the other results
45/// (`None` when this is the only one). Rounded to two decimals.
46pub 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        // a modest score lead already signals dominance, so ramp steeply: an
51        // even tie sits at 0.5, and pulling ~15%+ ahead saturates to 1.0.
52        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/// Dynamic, context-dependent boosts computed by [`crate::search`] (which owns
58/// the time math and store lookups). Kept out of the pure match scoring so each
59/// signal can be added without threading more parameters.
60#[derive(Debug, Clone, Copy, Default, PartialEq)]
61pub struct Boosts {
62    /// Behavioral signal: results chosen before for this query.
63    pub learned: f64,
64    /// Git/filesystem signal: symbols in recently-modified files.
65    pub recency: f64,
66    /// Branch signal: symbols in files you're changing on this branch (or their
67    /// directory neighbors) — where you're most likely working.
68    pub branch: f64,
69}
70
71/// Score `cand` for `query`. Returns `None` when the candidate doesn't match at
72/// all (not even as a subsequence), filtering FTS trigram noise.
73///
74/// `boosts` carries the dynamic signals (behavioral, recency) computed by
75/// [`crate::search`], which owns the time math.
76pub fn score(
77    query: &str,
78    cand: &SymbolRow,
79    current_repo_id: Option<i64>,
80    boosts: Boosts,
81) -> Option<Scored> {
82    // A qualified query (`Foo::Bar`, `Foo::Bar#baz`) names an enclosing scope:
83    // match the leaf against the name, and reward a matching `parent` below.
84    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    // Match quality on the symbol name — the dominant term.
91    let wildcard = has_wildcard(&q);
92    let name_matched = if wildcard {
93        // explicit glob: literal segments separated by the user's `*`/`?` gaps
94        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        // shorter remaining tail ranks higher
111        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    // Layer 3: path / filename matching (same glob/fuzzy split as the name).
128    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        // a file named after the query reinforces a name match (small bonus)
136        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        // no name match: a path hit only surfaces a file's primary definitions
144        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    // Visibility — a definition the language marks private/protected is less
161    // likely the navigation target than public API. A small penalty (never a
162    // filter): it breaks ties among comparable matches without overriding
163    // match quality, and unknown visibility (pre-v9 rows, or languages that
164    // don't express one) carries no signal at all.
165    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    // Kind weight — definitions you navigate to most sit slightly higher.
176    // Top-level types rank alongside classes; methods/functions stay neutral.
177    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    // Qualifier boost — the user named an enclosing scope (`Foo::Bar`); reward a
190    // candidate whose recorded parent ends with that scope chain.
191    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    // Current-repo boost — the repo you're in dominates other repos.
201    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    // Learned boost — results you've chosen before for this query rank higher.
211    if boosts.learned > 0.0 {
212        features.push(Feature {
213            name: "learned",
214            value: boosts.learned,
215        });
216    }
217
218    // Recency boost — symbols in recently-modified files rank higher.
219    if boosts.recency > 0.0 {
220        features.push(Feature {
221            name: "recency",
222            value: boosts.recency,
223        });
224    }
225
226    // Branch boost — symbols in files you're changing on this branch (or nearby).
227    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
238/// Largest gap (chars skipped) allowed between two matched query chars that land
239/// *mid-word* (not at a word boundary). Boundary jumps are how abbreviations work
240/// and stay unlimited; off-boundary we tolerate a couple of skipped chars — a
241/// consonant run like `ctrl`→`Controller` (the `c`→`t` skips `on`), or a typo —
242/// but no more. A bigger gap (the `s` in `employeescontroller` reaching past
243/// `XYZ`, three chars) is coincidence, not a match.
244const MAX_NONBOUNDARY_GAP: usize = 2;
245
246/// Penalty per skipped char between two matched chars. Strong enough that a
247/// closer match wins over a farther one — so the query's trailing chars don't
248/// straggle to a distant word boundary (the `r` of a query landing in `.rb`
249/// instead of `controller`) — but not so strong it lets a scattered mid-word
250/// alignment outrank a boundary-aligned abbreviation.
251const GAP_PENALTY: f64 = 3.0;
252
253/// One way `query` lines up against `name`: its score and the matched indices.
254struct Alignment {
255    score: f64,
256    positions: Vec<usize>,
257}
258
259/// Find the **best** alignment of `query` as a subsequence of `name`, maximizing
260/// matches at word boundaries (camelCase / underscore) and contiguous runs while
261/// penalizing gaps. `None` if `query` isn't a subsequence. Handles abbreviations
262/// (`refproc → RefundProcessor`, `usr → User`, `paymnt → Payments`) and ignores
263/// separators in the query, so a snake_case query matches CamelCase
264/// (`widget_controller → WidgetsController`).
265///
266/// This is a small dynamic program rather than a greedy left-to-right scan: greedy
267/// takes the *first* candidate for each query char, which mis-aligns (matching the
268/// `e` in `xxxe_employee` instead of the contiguous `employee`, or letting a
269/// trailing char straggle to a far position). The DP considers every placement and
270/// keeps the highest-scoring one, so the score and the highlight reflect the match
271/// a human would read.
272fn 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    // cheap gate: most candidates aren't even a subsequence of the query, so
282    // reject them with one linear scan before any of the DP allocations below
283    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    // prefix count of word boundaries, so we can ask "is a whole word skipped
297    // between j and i?" in O(1) — the "only span adjacent words" rule
298    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    // table[qi][i] = best (score, backpointer) for aligning q[0..=qi] with q[qi]
304    // landing on name position `i`; `None` if q[qi] can't end there. The
305    // backpointer is the position where q[qi-1] matched (self for qi == 0).
306    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; // anchored at the very start
316            }
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            // a non-boundary char can only follow within MAX_NONBOUNDARY_GAP;
328            // a boundary char may follow from the previous word (scan back further)
329            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 // contiguous run
342                } else {
343                    let gap = i - j - 1;
344                    let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
345                    if boundary[i] {
346                        // entering a new word: only the *adjacent* one — reject if
347                        // a whole word boundary sits between j and i (a word skipped)
348                        if crossed_word {
349                            continue;
350                        }
351                    } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
352                        // a mid-word target may follow only a small same-word gap (a
353                        // dropped vowel). A larger gap, or one that crosses into a
354                        // new word, is scatter — you enter a new word at its
355                        // boundary, never mid-word (the `ees` of `employees`
356                        // threading employee→b[e]fore→[s]tarting).
357                        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    // best end position for the final query char, then backtrack to collect indices
373    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
389/// The char indices in `name` that `query` matched, from the best alignment —
390/// for highlighting *what* matched. Empty if `query` isn't a subsequence.
391pub fn match_positions(query: &str, name: &str) -> Vec<usize> {
392    // highlight what the *leaf* matched; a qualifier targets the parent, not the name
393    let (leaf, _) = parse_qualified(query);
394    if has_wildcard(leaf) {
395        // a wildcard's gaps are deliberate, so highlight every literal as-is
396        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
402/// Split a query into its leaf name and the optional enclosing scope the user
403/// typed before it. The qualifier is everything before the last `::`/`#`
404/// separator: `Foo::Bar` → (`Bar`, `Some("Foo")`), `Foo::Bar#baz` → (`baz`,
405/// `Some("Foo::Bar")`), a plain `User` → (`User`, `None`). A leading or trailing
406/// separator (`::Bar`, `Foo::`) is treated as an ordinary unqualified query.
407pub 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
419/// Lowercased scope segments of a (possibly qualified) name, split on `::`/`#`.
420fn 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
428/// Boost a candidate whose enclosing scope matches a query's qualifier. The
429/// qualifier must match the *innermost* segments of the candidate's `parent`
430/// (a suffix): `Foo::Bar` (qualifier `Foo`) rewards a `Bar` whose parent is
431/// `Foo` or `App::Foo`, but not one nested under some other scope. More matched
432/// segments are stronger evidence of intent, so the boost grows with them.
433fn 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
443/// Trim a fuzzy match's highlight so it reads cleanly. We keep contiguous runs of
444/// two or more matched chars, and a lone matched char only when it sits on a word
445/// boundary (an acronym/abbreviation initial — the `U`/`C` of `UserController`).
446/// Isolated mid-word matches — single lit letters with dark gaps on both sides —
447/// are dropped even though they technically matched: they're visually noisy and
448/// carry no navigational signal. Separate clumps each survive, so a vowel-dropped
449/// abbreviation still lights both halves (`Paym`e`nt`s).
450fn 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        // positions are strictly increasing; extend a run of adjacent indices
459        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]); // a clump of >= 2
465        } else if boundary[positions[i]] {
466            out.push(positions[i]); // a lone match, but a word-boundary initial
467        }
468        i = j + 1;
469    }
470    out
471}
472
473/// Score `query` as a subsequence of `name` (the best alignment's score), or
474/// `None` if it isn't a subsequence.
475fn subsequence_score(query: &str, name: &str) -> Option<f64> {
476    align(query, name).map(|a| a.score)
477}
478
479/// Does `query` use wildcard syntax — `*` (any run), `?`/`.` (one char)? When it
480/// does, matching switches from fuzzy subsequence to an explicit glob: literal
481/// chars match *contiguously*, and the only gaps are the ones the user marked.
482/// `find*controller` keeps `FindController` and `FindUserController` but, unlike
483/// fuzzy, won't reach into a scattered `FxIxNxDxController`.
484pub fn has_wildcard(query: &str) -> bool {
485    query.contains(['*', '?', '.'])
486}
487
488/// A wildcard query's literal characters, metachars removed — used to seed the
489/// store's candidate recall (which keys off literal trigrams) before the glob
490/// does the precise matching. `find*controller` → `findcontroller`.
491pub fn strip_wildcards(query: &str) -> String {
492    query
493        .chars()
494        .filter(|c| !matches!(c, '*' | '?' | '.'))
495        .collect()
496}
497
498/// One token of a compiled wildcard pattern.
499enum Glob {
500    Lit(char), // a literal (lowercased) char — matches itself
501    Any,       // `?` / `.` — exactly one char
502    Star,      // `*` — zero or more chars
503}
504
505/// Compile a wildcard query into glob tokens. The query's own separators
506/// (`_`, `-`, …) are ignored, like the fuzzy matcher, so `emp_*_ctrl` and
507/// `emp*ctrl` compile alike.
508fn 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
520/// Match a wildcard `query` against `name`, unanchored (the pattern may match any
521/// substring — implicit `*` at both ends). Returns the indices the *literal*
522/// chars matched (the highlight), or `None` if it doesn't match. Classic
523/// two-pointer glob with `*` backtracking; literal positions are recorded and
524/// rolled back on each backtrack.
525fn 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    // the last `*` to fall back to: (token index after it, name index, #positions)
535    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            // mismatch, or pattern ran out with chars left: extend the last star
553            // by one char and retry from just after it; no star to fall back to
554            // means no match
555            _ => 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
572/// Score a wildcard match from its literal positions — the same boundary /
573/// contiguity / start signals as the fuzzy scorer, but no gap penalty: the gaps
574/// are the `*`/`?` the user placed deliberately. `None` when it doesn't match,
575/// or when nothing literal matched (an all-wildcard query like `*`).
576fn 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, // contiguous literal run
592            None if i == 0 => score += 20.0,        // anchored at the very start
593            _ => {}
594        }
595        prev = Some(i);
596    }
597    Some(score)
598}
599
600/// The filename stem of a repo-relative path: last segment, extension dropped.
601/// `app/models/user.rb` → `user`.
602pub 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
610/// Mark word-boundary positions: index 0, anything after `_`/non-alphanumeric,
611/// and camelCase humps (lower→Upper, and the last cap of an ACRONYMWord run).
612fn 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            // start of a word: after a separator, a lower→Upper hump, or the
621            // tail cap of an acronym run (the `P` in `HTTPParser`)
622            !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); // pre-v9 row: no signal
664
665        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        // the penalty is a tiebreaker, never bigger than a match-quality step
674        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        // a consonant run skipping a couple of chars (gap 2) still matches
694        assert!(total("ctrl", "Controller").is_some());
695    }
696
697    #[test]
698    fn rejects_scattered_midword_matches() {
699        // the trailing `s` of the query landed past `XYZ` mid-word — coincidence,
700        // not a match. The clean plural (boundary/contiguous `s`) still matches.
701        assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
702        assert!(total("employeescontroller", "EmployeesController").is_some());
703        // a single skipped char off-boundary is tolerated (looks like a typo)
704        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]); // F, T
711        // separator-insensitive: snake query highlights across CamelCase
712        assert_eq!(match_positions("wc", "WidgetController"), vec![0, 6]); // W, C
713        assert!(match_positions("xyz", "FooThing").is_empty());
714    }
715
716    #[test]
717    fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
718        // the bug: a greedy scan anchored on the first `e` (in `xxxe`) and lit up
719        // a scattered match; the best alignment is the contiguous `employee`.
720        assert_eq!(
721            match_positions("employee", "xxxe_employee"),
722            vec![5, 6, 7, 8, 9, 10, 11, 12]
723        );
724        // align to the `controller` word, not a stray earlier `c` in `calc`
725        assert_eq!(
726            match_positions("controller", "calc_controller"),
727            (5..15).collect::<Vec<_>>()
728        );
729        // and to the camelCase humps across the whole name
730        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        // a query char may jump to the *next* word but not skip a whole one
739        assert_eq!(
740            match_positions("employeescontroller", "employees_controller"),
741            // employees (0-8) + controller (10-19); the `_` at 9 is skipped
742            vec![
743                0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
744            ]
745        );
746        // the trailing `s` would have to skip the `x` word to reach `syy` — reject
747        assert!(subsequence_score("employees", "employee_x_syy").is_none());
748        // skipping a whole middle word isn't a match either
749        assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
750        // adjacent-word abbreviations still match
751        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        // both `r`s are reachable; the closer contiguous one wins, so the query
758        // doesn't straggle to a separated boundary `r` (e.g. a file extension)
759        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        // crossing word boundaries IS correct for an acronym — each query char
765        // lands on a word start (`uc` → the U and C humps of UserController)
766        assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
767        assert_eq!(
768            match_positions("abc", "alpha_bravo_charlie"),
769            vec![0, 6, 12] // a, b, c — each a word initial
770        );
771        // but only *adjacent* words — skipping a whole word is not a match
772        assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
773        assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); // alpha→charlie skips bravo
774    }
775
776    #[test]
777    fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
778        // the reported scatter: `employeescontroller` threaded its `ees` through
779        // employee → b[e]fore → [s]tarting (small gaps crossing word boundaries
780        // into mid-word chars). You enter a new word at its boundary, not mid-word.
781        assert!(
782            subsequence_score("employeescontroller", "employee_before_starting_controller")
783                .is_none()
784        );
785        // the clean target still matches
786        assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
787        // and within-word vowel drops still match (the gap stays in one word)
788        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        // `test` scatters across `the`+`settings` (jump + dropped vowel — the same
795        // shape as a real abbreviation, so it still matches), but a clean
796        // contiguous match must rank well above it. Ranking, not rejection, is the
797        // defense against scatter.
798        let contiguous = total("test", "test_helper").unwrap(); // prefix
799        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        // a match yields a score and exactly one highlight per query char
808        assert!(subsequence_score("refproc", "RefundProcessor").is_some());
809        assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
810        // a non-match yields neither
811        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            // highlights are a subsequence of the query, each in bounds
838            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            // every highlight is part of a clump (>= 2 adjacent) or a boundary initial
851            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        // a vowel-dropped abbreviation lights both clumps across the dark gap
865        assert_eq!(
866            match_positions("paymnt", "Payments"),
867            vec![0, 1, 2, 3, 5, 6]
868        );
869        // the straggling `r` of `usr` (mid-word, gap before it) is dropped, not lit
870        assert_eq!(match_positions("usr", "UserService"), vec![0, 1]);
871        // boundary initial `C` stays; the contiguous `tr` stays; the lone `l` drops
872        assert_eq!(match_positions("ctrl", "Controller"), vec![0, 3, 4]);
873        // two scattered mid-word singles leave nothing to highlight
874        assert!(match_positions("rp", "wrapper").is_empty());
875        // a pure boundary acronym is all single chars, but each is a real initial
876        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        // both letters on word boundaries (acronym) beats them landing mid-word
882        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        // "employees" can match the stem `employee_x_syy` only via a trailing `s`
890        // straggling to a far word boundary — a weak match. The real target, where
891        // "employees" is a prefix, dominates via the prefix layer.
892        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        // typed a snake_case query, want the CamelCase class — even when the
906        // class is plural and you forgot the `s`
907        assert!(total("widget_controller", "WidgetsController").is_some());
908        assert!(total("widget_controller", "WidgetController").is_some());
909        // unrelated controller still doesn't match
910        assert!(total("widget_controller", "AdminController").is_none());
911    }
912
913    #[test]
914    fn wildcard_star_spans_an_explicit_gap() {
915        // `*` bridges any run, so the scattered tail the fuzzy gate rejects is
916        // exactly what an explicit star asks for
917        assert!(total("find*controller", "FindController").is_some());
918        assert!(total("find*controller", "FindUserController").is_some());
919        assert!(total("find*controller", "FindUserAccountController").is_some());
920        // but the literals must still appear contiguously — `controller` is a
921        // literal, not an abbreviation
922        assert!(total("find*ctrlr", "FindController").is_none());
923        // and a name missing a literal segment doesn't match
924        assert!(total("find*controller", "FindService").is_none());
925    }
926
927    #[test]
928    fn wildcard_question_mark_matches_one_char() {
929        // `?` and `.` each consume exactly one char
930        assert!(total("find?controller", "FindXController").is_some());
931        assert!(total("find.controller", "Find1Controller").is_some());
932        // zero chars or two chars in the slot don't fit a single `?`
933        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        // the gap chars aren't highlighted, only the literals the user typed
940        assert_eq!(
941            match_positions("find*er", "FindController"),
942            vec![0, 1, 2, 3, 12, 13] // Find + er
943        );
944    }
945
946    #[test]
947    fn wildcard_prefers_boundary_aligned_matches() {
948        // a star landing the second literal on a word boundary outranks one
949        // landing it mid-word
950        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        // a unique exact match is fully confident
972        assert_eq!(confidence(1000.0, match_quality(&exact), None), 1.0);
973        // a lone fuzzy match is mid/low even though it's the only result
974        let f = confidence(300.0, match_quality(&fuzzy), None);
975        assert!(f > 0.3 && f < 0.65, "fuzzy confidence {f}");
976        // three evenly-tied exacts: the leader isn't dominant → ~0.5, well below a
977        // unique exact
978        let tied = confidence(1000.0, match_quality(&exact), Some(1000.0));
979        assert!(tied < 0.6, "tied exact confidence {tied}");
980        // a clear leader (big gap to #2) stays near the top
981        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        // a `#` is the innermost separator (Ruby instance method)
991        assert_eq!(parse_qualified("Foo::Bar#baz"), ("baz", Some("Foo::Bar")));
992        // a leading or trailing separator is not a qualifier
993        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        // exact parent, and a qualifier naming only the immediate scope
1000        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        // more matched segments → a stronger boost
1004        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        // the qualifier must be a suffix, not just any ancestor or sibling
1008        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        // two classes both named `Bar`; the qualifier picks the one inside `Foo`
1016        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        // the unqualified `Bar` still matches both (qualifier only reorders)
1032        assert!(score("Bar", &in_baz, None, Boosts::default()).is_some());
1033        // a wrong leaf still doesn't match, qualifier or not
1034        assert!(score("Foo::Zzz", &in_foo, None, Boosts::default()).is_none());
1035    }
1036
1037    #[test]
1038    fn boundary_alignment_outranks_scattered() {
1039        // "rp" aligned to Refund/Processor humps should beat an incidental match
1040        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        // name "Invoice" doesn't match "billing", but the file does
1048        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        // a method (not a primary definition) in the same file does NOT surface
1054        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}