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        // Typing a capital is a deliberate signal: `Symbol` means the type, not
109        // the `symbol` method that happens to share the name case-insensitively.
110        // Only when the query carries case, though — an all-lowercase query is
111        // how people type casually, and reading intent into it would demote
112        // `User` for `user`.
113        if leaf != q && cand.name == leaf {
114            features.push(Feature {
115                name: "case",
116                value: CASE_MATCH,
117            });
118        }
119        true
120    } else if name_lower.starts_with(&q) {
121        // shorter remaining tail ranks higher
122        let tail = cand.name.chars().count().saturating_sub(q.chars().count());
123        features.push(Feature {
124            name: "prefix",
125            value: 700.0 - (tail as f64).min(100.0),
126        });
127        true
128    } else if let Some(s) = subsequence_score(&q, &cand.name) {
129        features.push(Feature {
130            name: "fuzzy",
131            value: s.min(600.0),
132        });
133        true
134    } else {
135        false
136    };
137
138    // Layer 3: path / filename matching (same glob/fuzzy split as the name).
139    let stem = path_stem(&cand.file);
140    let path_match = if wildcard {
141        wildcard_score(&q, stem)
142    } else {
143        subsequence_score(&q, stem)
144    };
145    if name_matched {
146        // a file named after the query reinforces a name match (small bonus)
147        if let Some(ps) = path_match {
148            features.push(Feature {
149                name: "path",
150                value: (ps * 0.2).min(50.0),
151            });
152        }
153    } else {
154        // no name match: a path hit only surfaces a file's primary definitions
155        match path_match {
156            Some(ps)
157                if matches!(
158                    cand.kind.as_str(),
159                    "class" | "module" | "struct" | "enum" | "trait"
160                ) =>
161            {
162                features.push(Feature {
163                    name: "path",
164                    value: (ps * 0.6).min(300.0),
165                });
166            }
167            _ => return None,
168        }
169    }
170
171    // Visibility — a definition the language marks private/protected is less
172    // likely the navigation target than public API. A small penalty (never a
173    // filter): it breaks ties among comparable matches without overriding
174    // match quality, and unknown visibility (pre-v9 rows, or languages that
175    // don't express one) carries no signal at all.
176    if matches!(
177        cand.visibility.as_deref(),
178        Some("private") | Some("protected")
179    ) {
180        features.push(Feature {
181            name: "private",
182            value: -15.0,
183        });
184    }
185
186    // Kind weight — definitions you navigate to most sit slightly higher.
187    // Top-level types rank alongside classes; methods/functions stay neutral.
188    let kind = match cand.kind.as_str() {
189        "class" | "struct" | "trait" => 15.0,
190        "module" | "enum" => 12.0,
191        _ => 0.0,
192    };
193    if kind != 0.0 {
194        features.push(Feature {
195            name: "kind",
196            value: kind,
197        });
198    }
199
200    // Qualifier boost — the user named an enclosing scope (`Foo::Bar`); reward a
201    // candidate whose recorded parent ends with that scope chain.
202    if let Some(qual) = qualifier
203        && let Some(b) = parent_boost(qual, cand.parent.as_deref())
204    {
205        features.push(Feature {
206            name: "parent",
207            value: b,
208        });
209    }
210
211    // Current-repo boost — the repo you're in dominates other repos.
212    if let Some(cur) = current_repo_id
213        && cur == cand.repository_id
214    {
215        features.push(Feature {
216            name: "current_repo",
217            value: 200.0,
218        });
219    }
220
221    // Learned boost — results you've chosen before for this query rank higher.
222    if boosts.learned > 0.0 {
223        features.push(Feature {
224            name: "learned",
225            value: boosts.learned,
226        });
227    }
228
229    // Recency boost — symbols in recently-modified files rank higher.
230    if boosts.recency > 0.0 {
231        features.push(Feature {
232            name: "recency",
233            value: boosts.recency,
234        });
235    }
236
237    // Branch boost — symbols in files you're changing on this branch (or nearby).
238    if boosts.branch > 0.0 {
239        features.push(Feature {
240            name: "branch",
241            value: boosts.branch,
242        });
243    }
244
245    let total = features.iter().map(|f| f.value).sum();
246    Some(Scored { total, features })
247}
248
249/// Largest gap (chars skipped) allowed between two matched query chars that land
250/// *mid-word* (not at a word boundary). Boundary jumps are how abbreviations work
251/// and stay unlimited; off-boundary we tolerate a couple of skipped chars — a
252/// consonant run like `ctrl`→`Controller` (the `c`→`t` skips `on`), or a typo —
253/// but no more. A bigger gap (the `s` in `employeescontroller` reaching past
254/// `XYZ`, three chars) is coincidence, not a match.
255const MAX_NONBOUNDARY_GAP: usize = 2;
256
257/// Reward for matching the case the query was typed in, when the query carries
258/// any. It has to outweigh the spread in `recency` (0-120), or which of two
259/// same-named symbols wins would come down to whichever file was touched more
260/// recently — that made ranking depend on file mtimes, so a fresh checkout
261/// ranked differently from a stale one.
262const CASE_MATCH: f64 = 150.0;
263
264/// Penalty per skipped char between two matched chars. Strong enough that a
265/// closer match wins over a farther one — so the query's trailing chars don't
266/// straggle to a distant word boundary (the `r` of a query landing in `.rb`
267/// instead of `controller`) — but not so strong it lets a scattered mid-word
268/// alignment outrank a boundary-aligned abbreviation.
269const GAP_PENALTY: f64 = 3.0;
270
271/// One way `query` lines up against `name`: its score and the matched indices.
272struct Alignment {
273    score: f64,
274    positions: Vec<usize>,
275}
276
277/// Find the **best** alignment of `query` as a subsequence of `name`, maximizing
278/// matches at word boundaries (camelCase / underscore) and contiguous runs while
279/// penalizing gaps. `None` if `query` isn't a subsequence. Handles abbreviations
280/// (`refproc → RefundProcessor`, `usr → User`, `paymnt → Payments`) and ignores
281/// separators in the query, so a snake_case query matches CamelCase
282/// (`widget_controller → WidgetsController`).
283///
284/// This is a small dynamic program rather than a greedy left-to-right scan: greedy
285/// takes the *first* candidate for each query char, which mis-aligns (matching the
286/// `e` in `xxxe_employee` instead of the contiguous `employee`, or letting a
287/// trailing char straggle to a far position). The DP considers every placement and
288/// keeps the highest-scoring one, so the score and the highlight reflect the match
289/// a human would read.
290fn align(query: &str, name: &str) -> Option<Alignment> {
291    let q: Vec<char> = query
292        .chars()
293        .filter(|c| c.is_alphanumeric())
294        .map(|c| c.to_ascii_lowercase())
295        .collect();
296    if q.is_empty() {
297        return None;
298    }
299    // cheap gate: most candidates aren't even a subsequence of the query, so
300    // reject them with one linear scan before any of the DP allocations below
301    let mut qi = 0;
302    for c in name.chars() {
303        if qi < q.len() && c.to_ascii_lowercase() == q[qi] {
304            qi += 1;
305        }
306    }
307    if qi < q.len() {
308        return None;
309    }
310    let chars: Vec<char> = name.chars().collect();
311    let n = chars.len();
312    let lower: Vec<char> = chars.iter().map(|c| c.to_ascii_lowercase()).collect();
313    let boundary = boundaries(&chars);
314    // prefix count of word boundaries, so we can ask "is a whole word skipped
315    // between j and i?" in O(1) — the "only span adjacent words" rule
316    let mut bnd_prefix = vec![0usize; n + 1];
317    for i in 0..n {
318        bnd_prefix[i + 1] = bnd_prefix[i] + boundary[i] as usize;
319    }
320
321    // table[qi][i] = best (score, backpointer) for aligning q[0..=qi] with q[qi]
322    // landing on name position `i`; `None` if q[qi] can't end there. The
323    // backpointer is the position where q[qi-1] matched (self for qi == 0).
324    let mut table: Vec<Vec<Option<(f64, usize)>>> = vec![vec![None; n]; q.len()];
325
326    for (i, &c) in lower.iter().enumerate() {
327        if c == q[0] {
328            let mut s = 10.0;
329            if boundary[i] {
330                s += 15.0;
331            }
332            if i == 0 {
333                s += 20.0; // anchored at the very start
334            }
335            table[0][i] = Some((s, i));
336        }
337    }
338
339    for qi in 1..q.len() {
340        for i in qi..n {
341            if lower[i] != q[qi] {
342                continue;
343            }
344            let base = 10.0 + if boundary[i] { 15.0 } else { 0.0 };
345            // a non-boundary char can only follow within MAX_NONBOUNDARY_GAP;
346            // a boundary char may follow from the previous word (scan back further)
347            let j_start = if boundary[i] {
348                qi - 1
349            } else {
350                (qi - 1).max(i.saturating_sub(MAX_NONBOUNDARY_GAP + 1))
351            };
352            let mut best: Option<(f64, usize)> = None;
353            let prev_row = &table[qi - 1];
354            for (j, cell) in prev_row.iter().enumerate().take(i).skip(j_start) {
355                let Some((pscore, _)) = cell else {
356                    continue;
357                };
358                let trans = if j + 1 == i {
359                    10.0 // contiguous run
360                } else {
361                    let gap = i - j - 1;
362                    let crossed_word = bnd_prefix[i] - bnd_prefix[j + 1] > 0;
363                    if boundary[i] {
364                        // entering a new word: only the *adjacent* one — reject if
365                        // a whole word boundary sits between j and i (a word skipped)
366                        if crossed_word {
367                            continue;
368                        }
369                    } else if gap > MAX_NONBOUNDARY_GAP || crossed_word {
370                        // a mid-word target may follow only a small same-word gap (a
371                        // dropped vowel). A larger gap, or one that crosses into a
372                        // new word, is scatter — you enter a new word at its
373                        // boundary, never mid-word (the `ees` of `employees`
374                        // threading employee→b[e]fore→[s]tarting).
375                        continue;
376                    }
377                    -(gap as f64) * GAP_PENALTY
378                };
379                let cand = pscore + trans;
380                if best.is_none_or(|(b, _)| cand > b) {
381                    best = Some((cand, j));
382                }
383            }
384            if let Some((bscore, j)) = best {
385                table[qi][i] = Some((bscore + base, j));
386            }
387        }
388    }
389
390    // best end position for the final query char, then backtrack to collect indices
391    let last = q.len() - 1;
392    let (mut pos, score) = (0..n)
393        .filter_map(|i| table[last][i].map(|(s, _)| (i, s)))
394        .max_by(|a, b| a.1.total_cmp(&b.1))?;
395    let mut positions = Vec::with_capacity(q.len());
396    for qi in (0..q.len()).rev() {
397        positions.push(pos);
398        pos = table[qi][pos].expect("backtrack hits a filled cell").1;
399    }
400    positions.reverse();
401    Some(Alignment {
402        score: score.max(0.0),
403        positions,
404    })
405}
406
407/// The char indices in `name` that `query` matched, from the best alignment —
408/// for highlighting *what* matched. Empty if `query` isn't a subsequence.
409pub fn match_positions(query: &str, name: &str) -> Vec<usize> {
410    // highlight what the *leaf* matched; a qualifier targets the parent, not the name
411    let (leaf, _) = parse_qualified(query);
412    if has_wildcard(leaf) {
413        // a wildcard's gaps are deliberate, so highlight every literal as-is
414        return glob_positions(leaf, name).unwrap_or_default();
415    }
416    let positions = align(leaf, name).map(|a| a.positions).unwrap_or_default();
417    contiguous_highlight(positions, name)
418}
419
420/// Split a query into its leaf name and the optional enclosing scope the user
421/// typed before it. The qualifier is everything before the last `::`/`#`
422/// separator: `Foo::Bar` → (`Bar`, `Some("Foo")`), `Foo::Bar#baz` → (`baz`,
423/// `Some("Foo::Bar")`), a plain `User` → (`User`, `None`). A leading or trailing
424/// separator (`::Bar`, `Foo::`) is treated as an ordinary unqualified query.
425pub fn parse_qualified(query: &str) -> (&str, Option<&str>) {
426    let sep = query
427        .rmatch_indices("::")
428        .map(|(i, _)| (i, 2usize))
429        .chain(query.rmatch_indices('#').map(|(i, _)| (i, 1usize)))
430        .max_by_key(|&(i, _)| i);
431    match sep {
432        Some((i, len)) if i > 0 && i + len < query.len() => (&query[i + len..], Some(&query[..i])),
433        _ => (query, None),
434    }
435}
436
437/// Lowercased scope segments of a (possibly qualified) name, split on `::`/`#`.
438fn segments(s: &str) -> Vec<String> {
439    s.split("::")
440        .flat_map(|p| p.split('#'))
441        .filter(|p| !p.is_empty())
442        .map(|p| p.to_ascii_lowercase())
443        .collect()
444}
445
446/// Boost a candidate whose enclosing scope matches a query's qualifier. The
447/// qualifier must match the *innermost* segments of the candidate's `parent`
448/// (a suffix): `Foo::Bar` (qualifier `Foo`) rewards a `Bar` whose parent is
449/// `Foo` or `App::Foo`, but not one nested under some other scope. More matched
450/// segments are stronger evidence of intent, so the boost grows with them.
451fn parent_boost(qualifier: &str, parent: Option<&str>) -> Option<f64> {
452    let p = segments(parent?);
453    let q = segments(qualifier);
454    if q.is_empty() || q.len() > p.len() {
455        return None;
456    }
457    let off = p.len() - q.len();
458    (p[off..] == q[..]).then(|| (120.0 + 60.0 * q.len() as f64).min(300.0))
459}
460
461/// Trim a fuzzy match's highlight so it reads cleanly. We keep contiguous runs of
462/// two or more matched chars, and a lone matched char only when it sits on a word
463/// boundary (an acronym/abbreviation initial — the `U`/`C` of `UserController`).
464/// Isolated mid-word matches — single lit letters with dark gaps on both sides —
465/// are dropped even though they technically matched: they're visually noisy and
466/// carry no navigational signal. Separate clumps each survive, so a vowel-dropped
467/// abbreviation still lights both halves (`Paym`e`nt`s).
468fn contiguous_highlight(positions: Vec<usize>, name: &str) -> Vec<usize> {
469    if positions.is_empty() {
470        return positions;
471    }
472    let boundary = boundaries(&name.chars().collect::<Vec<_>>());
473    let mut out = Vec::with_capacity(positions.len());
474    let mut i = 0;
475    while i < positions.len() {
476        // positions are strictly increasing; extend a run of adjacent indices
477        let mut j = i;
478        while j + 1 < positions.len() && positions[j + 1] == positions[j] + 1 {
479            j += 1;
480        }
481        if j > i {
482            out.extend_from_slice(&positions[i..=j]); // a clump of >= 2
483        } else if boundary[positions[i]] {
484            out.push(positions[i]); // a lone match, but a word-boundary initial
485        }
486        i = j + 1;
487    }
488    out
489}
490
491/// Score `query` as a subsequence of `name` (the best alignment's score), or
492/// `None` if it isn't a subsequence.
493fn subsequence_score(query: &str, name: &str) -> Option<f64> {
494    align(query, name).map(|a| a.score)
495}
496
497/// Does `query` use wildcard syntax — `*` (any run), `?`/`.` (one char)? When it
498/// does, matching switches from fuzzy subsequence to an explicit glob: literal
499/// chars match *contiguously*, and the only gaps are the ones the user marked.
500/// `find*controller` keeps `FindController` and `FindUserController` but, unlike
501/// fuzzy, won't reach into a scattered `FxIxNxDxController`.
502pub fn has_wildcard(query: &str) -> bool {
503    query.contains(['*', '?', '.'])
504}
505
506/// A wildcard query's literal characters, metachars removed — used to seed the
507/// store's candidate recall (which keys off literal trigrams) before the glob
508/// does the precise matching. `find*controller` → `findcontroller`.
509pub fn strip_wildcards(query: &str) -> String {
510    query
511        .chars()
512        .filter(|c| !matches!(c, '*' | '?' | '.'))
513        .collect()
514}
515
516/// One token of a compiled wildcard pattern.
517enum Glob {
518    Lit(char), // a literal (lowercased) char — matches itself
519    Any,       // `?` / `.` — exactly one char
520    Star,      // `*` — zero or more chars
521}
522
523/// Compile a wildcard query into glob tokens. The query's own separators
524/// (`_`, `-`, …) are ignored, like the fuzzy matcher, so `emp_*_ctrl` and
525/// `emp*ctrl` compile alike.
526fn compile_glob(query: &str) -> Vec<Glob> {
527    query
528        .chars()
529        .filter_map(|c| match c {
530            '*' => Some(Glob::Star),
531            '?' | '.' => Some(Glob::Any),
532            c if c.is_alphanumeric() => Some(Glob::Lit(c.to_ascii_lowercase())),
533            _ => None,
534        })
535        .collect()
536}
537
538/// Match a wildcard `query` against `name`, unanchored (the pattern may match any
539/// substring — implicit `*` at both ends). Returns the indices the *literal*
540/// chars matched (the highlight), or `None` if it doesn't match. Classic
541/// two-pointer glob with `*` backtracking; literal positions are recorded and
542/// rolled back on each backtrack.
543fn glob_positions(query: &str, name: &str) -> Option<Vec<usize>> {
544    let mut toks = vec![Glob::Star];
545    toks.extend(compile_glob(query));
546    toks.push(Glob::Star);
547
548    let lower: Vec<char> = name.chars().map(|c| c.to_ascii_lowercase()).collect();
549    let mut ti = 0;
550    let mut ni = 0;
551    let mut positions: Vec<usize> = Vec::new();
552    // the last `*` to fall back to: (token index after it, name index, #positions)
553    let mut star: Option<(usize, usize, usize)> = None;
554
555    while ni < lower.len() {
556        match toks.get(ti) {
557            Some(Glob::Lit(c)) if lower[ni] == *c => {
558                positions.push(ni);
559                ti += 1;
560                ni += 1;
561            }
562            Some(Glob::Any) => {
563                ti += 1;
564                ni += 1;
565            }
566            Some(Glob::Star) => {
567                star = Some((ti + 1, ni, positions.len()));
568                ti += 1;
569            }
570            // mismatch, or pattern ran out with chars left: extend the last star
571            // by one char and retry from just after it; no star to fall back to
572            // means no match
573            _ => match star {
574                Some((sti, sni, plen)) => {
575                    ti = sti;
576                    ni = sni + 1;
577                    star = Some((sti, sni + 1, plen));
578                    positions.truncate(plen);
579                }
580                None => return None,
581            },
582        }
583    }
584    while matches!(toks.get(ti), Some(Glob::Star)) {
585        ti += 1;
586    }
587    (ti == toks.len()).then_some(positions)
588}
589
590/// Score a wildcard match from its literal positions — the same boundary /
591/// contiguity / start signals as the fuzzy scorer, but no gap penalty: the gaps
592/// are the `*`/`?` the user placed deliberately. `None` when it doesn't match,
593/// or when nothing literal matched (an all-wildcard query like `*`).
594fn wildcard_score(query: &str, name: &str) -> Option<f64> {
595    let positions = glob_positions(query, name)?;
596    if positions.is_empty() {
597        return None;
598    }
599    let chars: Vec<char> = name.chars().collect();
600    let boundary = boundaries(&chars);
601    let mut score = 0.0;
602    let mut prev: Option<usize> = None;
603    for &i in &positions {
604        score += 10.0;
605        if boundary[i] {
606            score += 15.0;
607        }
608        match prev {
609            Some(p) if p + 1 == i => score += 10.0, // contiguous literal run
610            None if i == 0 => score += 20.0,        // anchored at the very start
611            _ => {}
612        }
613        prev = Some(i);
614    }
615    Some(score)
616}
617
618/// The filename stem of a repo-relative path: last segment, extension dropped.
619/// `app/models/user.rb` → `user`.
620pub fn path_stem(path: &str) -> &str {
621    let base = path.rsplit(['/', '\\']).next().unwrap_or(path);
622    match base.rfind('.') {
623        Some(i) if i > 0 => &base[..i],
624        _ => base,
625    }
626}
627
628/// Mark word-boundary positions: index 0, anything after `_`/non-alphanumeric,
629/// and camelCase humps (lower→Upper, and the last cap of an ACRONYMWord run).
630fn boundaries(chars: &[char]) -> Vec<bool> {
631    let mut out = vec![false; chars.len()];
632    for i in 0..chars.len() {
633        let c = chars[i];
634        out[i] = if i == 0 {
635            true
636        } else {
637            let prev = chars[i - 1];
638            // start of a word: after a separator, a lower→Upper hump, or the
639            // tail cap of an acronym run (the `P` in `HTTPParser`)
640            !prev.is_alphanumeric()
641                || (c.is_uppercase() && prev.is_lowercase())
642                || (c.is_uppercase()
643                    && prev.is_uppercase()
644                    && chars.get(i + 1).is_some_and(|n| n.is_lowercase()))
645        };
646    }
647    out
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    fn row(name: &str, kind: &str, repo: i64) -> SymbolRow {
655        SymbolRow {
656            name: name.into(),
657            kind: kind.into(),
658            language: "ruby".into(),
659            file: "f.rb".into(),
660            line: 1,
661            end_line: Some(1),
662            parent: None,
663            repository_id: repo,
664            repo_identity: "r".into(),
665            mtime: None,
666            git_ts: None,
667            visibility: None,
668        }
669    }
670
671    fn total(query: &str, name: &str) -> Option<f64> {
672        score(query, &row(name, "class", 1), None, Boosts::default()).map(|s| s.total)
673    }
674
675    #[test]
676    fn a_typed_capital_picks_the_matching_case() {
677        // `Symbol` and `symbol` are both exact matches case-insensitively.
678        // Which one wins used to fall through to recency, i.e. to file mtimes,
679        // so a fresh checkout ranked differently from a stale one.
680        let upper = total("Symbol", "Symbol").unwrap();
681        let lower = total("Symbol", "symbol").unwrap();
682        assert!(upper > lower, "{upper} > {lower}");
683        // by enough to outweigh the whole recency range, or mtime decides again
684        assert!(upper - lower > 120.0, "margin {} too small", upper - lower);
685    }
686
687    #[test]
688    fn a_lowercase_query_stays_case_agnostic() {
689        // Lowercase is how people type casually — reading intent into it would
690        // demote `User` for `user`, so neither spelling is rewarded.
691        assert_eq!(total("symbol", "symbol"), total("symbol", "Symbol"));
692        assert_eq!(total("user", "User"), total("user", "user"));
693    }
694
695    #[test]
696    fn private_ranks_below_public_on_an_equal_match() {
697        let mut public = row("save", "method", 1);
698        public.visibility = Some("public".into());
699        let mut private = row("save", "method", 1);
700        private.visibility = Some("private".into());
701        let unknown = row("save", "method", 1); // pre-v9 row: no signal
702
703        let pub_score = score("save", &public, None, Boosts::default()).unwrap();
704        let priv_score = score("save", &private, None, Boosts::default()).unwrap();
705        let unk_score = score("save", &unknown, None, Boosts::default()).unwrap();
706        assert!(pub_score.total > priv_score.total);
707        assert_eq!(
708            pub_score.total, unk_score.total,
709            "unknown carries no penalty"
710        );
711        // the penalty is a tiebreaker, never bigger than a match-quality step
712        assert!(priv_score.total > 700.0, "still comfortably above a prefix");
713    }
714
715    #[test]
716    fn exact_beats_prefix_beats_fuzzy() {
717        let exact = total("user", "user").unwrap();
718        let prefix = total("user", "users").unwrap();
719        let fuzzy = total("usr", "user").unwrap();
720        assert!(exact > prefix, "{exact} > {prefix}");
721        assert!(prefix > fuzzy, "{prefix} > {fuzzy}");
722    }
723
724    #[test]
725    fn abbreviations_match() {
726        assert!(total("refundproc", "RefundProcessor").is_some());
727        assert!(total("refproc", "RefundProcessor").is_some());
728        assert!(total("paymnt", "Payments").is_some());
729        assert!(total("perf", "perform").is_some());
730        assert!(total("usr", "User").is_some());
731        // a consonant run skipping a couple of chars (gap 2) still matches
732        assert!(total("ctrl", "Controller").is_some());
733    }
734
735    #[test]
736    fn rejects_scattered_midword_matches() {
737        // the trailing `s` of the query landed past `XYZ` mid-word — coincidence,
738        // not a match. The clean plural (boundary/contiguous `s`) still matches.
739        assert!(total("employeescontroller", "EmployeeXYZsController").is_none());
740        assert!(total("employeescontroller", "EmployeesController").is_some());
741        // a single skipped char off-boundary is tolerated (looks like a typo)
742        assert!(total("employescontroller", "EmployeesController").is_some());
743    }
744
745    #[test]
746    fn match_positions_report_what_matched() {
747        assert_eq!(match_positions("foo", "FooThing"), vec![0, 1, 2]);
748        assert_eq!(match_positions("ft", "FooThing"), vec![0, 3]); // F, T
749        // separator-insensitive: snake query highlights across CamelCase
750        assert_eq!(match_positions("wc", "WidgetController"), vec![0, 6]); // W, C
751        assert!(match_positions("xyz", "FooThing").is_empty());
752    }
753
754    #[test]
755    fn prefers_the_contiguous_run_over_an_earlier_scattered_match() {
756        // the bug: a greedy scan anchored on the first `e` (in `xxxe`) and lit up
757        // a scattered match; the best alignment is the contiguous `employee`.
758        assert_eq!(
759            match_positions("employee", "xxxe_employee"),
760            vec![5, 6, 7, 8, 9, 10, 11, 12]
761        );
762        // align to the `controller` word, not a stray earlier `c` in `calc`
763        assert_eq!(
764            match_positions("controller", "calc_controller"),
765            (5..15).collect::<Vec<_>>()
766        );
767        // and to the camelCase humps across the whole name
768        assert_eq!(
769            match_positions("widgetcontroller", "WidgetController"),
770            (0..16).collect::<Vec<_>>()
771        );
772    }
773
774    #[test]
775    fn matches_only_span_adjacent_words() {
776        // a query char may jump to the *next* word but not skip a whole one
777        assert_eq!(
778            match_positions("employeescontroller", "employees_controller"),
779            // employees (0-8) + controller (10-19); the `_` at 9 is skipped
780            vec![
781                0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19
782            ]
783        );
784        // the trailing `s` would have to skip the `x` word to reach `syy` — reject
785        assert!(subsequence_score("employees", "employee_x_syy").is_none());
786        // skipping a whole middle word isn't a match either
787        assert!(subsequence_score("rndsvc", "RefundProcessingService").is_none());
788        // adjacent-word abbreviations still match
789        assert!(subsequence_score("refproc", "RefundProcessor").is_some());
790        assert!(subsequence_score("refprocsvc", "RefundProcessingService").is_some());
791    }
792
793    #[test]
794    fn a_contiguous_match_beats_a_farther_boundary_jump() {
795        // both `r`s are reachable; the closer contiguous one wins, so the query
796        // doesn't straggle to a separated boundary `r` (e.g. a file extension)
797        assert_eq!(match_positions("car", "car_r"), vec![0, 1, 2]);
798    }
799
800    #[test]
801    fn acronyms_highlight_word_initials_across_adjacent_words() {
802        // crossing word boundaries IS correct for an acronym — each query char
803        // lands on a word start (`uc` → the U and C humps of UserController)
804        assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
805        assert_eq!(
806            match_positions("abc", "alpha_bravo_charlie"),
807            vec![0, 6, 12] // a, b, c — each a word initial
808        );
809        // but only *adjacent* words — skipping a whole word is not a match
810        assert!(subsequence_score("payrollcontroller", "payroll_runs_controller").is_none());
811        assert!(subsequence_score("apc", "alpha_bravo_charlie").is_none()); // alpha→charlie skips bravo
812    }
813
814    #[test]
815    fn a_gap_cannot_cross_a_word_boundary_into_a_mid_word_char() {
816        // the reported scatter: `employeescontroller` threaded its `ees` through
817        // employee → b[e]fore → [s]tarting (small gaps crossing word boundaries
818        // into mid-word chars). You enter a new word at its boundary, not mid-word.
819        assert!(
820            subsequence_score("employeescontroller", "employee_before_starting_controller")
821                .is_none()
822        );
823        // the clean target still matches
824        assert!(subsequence_score("employeescontroller", "employees_controller").is_some());
825        // and within-word vowel drops still match (the gap stays in one word)
826        assert!(subsequence_score("usr", "user").is_some());
827        assert!(subsequence_score("cfg", "config").is_some());
828    }
829
830    #[test]
831    fn a_contiguous_word_match_outranks_a_scattered_cross_word_one() {
832        // `test` scatters across `the`+`settings` (jump + dropped vowel — the same
833        // shape as a real abbreviation, so it still matches), but a clean
834        // contiguous match must rank well above it. Ranking, not rejection, is the
835        // defense against scatter.
836        let contiguous = total("test", "test_helper").unwrap(); // prefix
837        let scattered = total("test", "the_settings_store");
838        if let Some(s) = scattered {
839            assert!(contiguous > s, "contiguous {contiguous} > scattered {s}");
840        }
841    }
842
843    #[test]
844    fn score_and_positions_come_from_the_same_alignment() {
845        // a match yields a score and exactly one highlight per query char
846        assert!(subsequence_score("refproc", "RefundProcessor").is_some());
847        assert_eq!(match_positions("refproc", "RefundProcessor").len(), 7);
848        // a non-match yields neither
849        assert!(subsequence_score("xyz", "RefundProcessor").is_none());
850        assert!(match_positions("xyz", "RefundProcessor").is_empty());
851    }
852
853    #[test]
854    fn highlights_are_ordered_in_bounds_and_correct_across_varied_inputs() {
855        let cases = [
856            ("usr", "UserService"),
857            ("paymnt", "Payments"),
858            ("wc", "WidgetController"),
859            ("ctrl", "Controller"),
860            ("gp", "get_post"),
861            ("ab", "alpha_beta"),
862            ("refproc", "RefundProcessor"),
863            ("emp", "EmployeesController"),
864            ("http", "HTTPParser"),
865        ];
866        for (q, name) in cases {
867            let nchars: Vec<char> = name.chars().collect();
868            let qchars: Vec<char> = q.chars().filter(|c| c.is_alphanumeric()).collect();
869            let boundary = boundaries(&nchars);
870            let pos = match_positions(q, name);
871            assert!(
872                pos.windows(2).all(|w| w[0] < w[1]),
873                "strictly increasing: {q}/{name} {pos:?}"
874            );
875            // highlights are a subsequence of the query, each in bounds
876            let mut qi = 0;
877            for &p in &pos {
878                assert!(p < nchars.len(), "in bounds: {q}/{name}");
879                while qi < qchars.len() && !qchars[qi].eq_ignore_ascii_case(&nchars[p]) {
880                    qi += 1;
881                }
882                assert!(
883                    qi < qchars.len(),
884                    "highlight maps to a query char: {q}/{name}"
885                );
886                qi += 1;
887            }
888            // every highlight is part of a clump (>= 2 adjacent) or a boundary initial
889            for (idx, &p) in pos.iter().enumerate() {
890                let clumped = (idx > 0 && pos[idx - 1] + 1 == p)
891                    || (idx + 1 < pos.len() && p + 1 == pos[idx + 1]);
892                assert!(
893                    clumped || boundary[p],
894                    "no isolated mid-word highlight: {q}/{name} at {p} {pos:?}"
895                );
896            }
897        }
898    }
899
900    #[test]
901    fn highlights_avoid_isolated_single_chars() {
902        // a vowel-dropped abbreviation lights both clumps across the dark gap
903        assert_eq!(
904            match_positions("paymnt", "Payments"),
905            vec![0, 1, 2, 3, 5, 6]
906        );
907        // the straggling `r` of `usr` (mid-word, gap before it) is dropped, not lit
908        assert_eq!(match_positions("usr", "UserService"), vec![0, 1]);
909        // boundary initial `C` stays; the contiguous `tr` stays; the lone `l` drops
910        assert_eq!(match_positions("ctrl", "Controller"), vec![0, 3, 4]);
911        // two scattered mid-word singles leave nothing to highlight
912        assert!(match_positions("rp", "wrapper").is_empty());
913        // a pure boundary acronym is all single chars, but each is a real initial
914        assert_eq!(match_positions("uc", "UserController"), vec![0, 4]);
915    }
916
917    #[test]
918    fn an_acronym_at_boundaries_outranks_a_mid_word_alignment() {
919        // both letters on word boundaries (acronym) beats them landing mid-word
920        let acronym = subsequence_score("wc", "WidgetController").unwrap();
921        let midword = subsequence_score("wc", "switchcase").unwrap();
922        assert!(acronym > midword, "{acronym} > {midword}");
923    }
924
925    #[test]
926    fn a_far_path_straggler_never_outranks_a_prefix_match() {
927        // "employees" can match the stem `employee_x_syy` only via a trailing `s`
928        // straggling to a far word boundary — a weak match. The real target, where
929        // "employees" is a prefix, dominates via the prefix layer.
930        let mut straggler = row("Thing", "class", 1);
931        straggler.file = "app/employee_x_syy.rb".into();
932        let prefixed = row("EmployeesController", "class", 1);
933        let pre = score("employees", &prefixed, None, Boosts::default())
934            .unwrap()
935            .total;
936        if let Some(s) = score("employees", &straggler, None, Boosts::default()) {
937            assert!(pre > s.total, "prefix {pre} > path straggler {}", s.total);
938        }
939    }
940
941    #[test]
942    fn snake_case_query_matches_camelcase_name() {
943        // typed a snake_case query, want the CamelCase class — even when the
944        // class is plural and you forgot the `s`
945        assert!(total("widget_controller", "WidgetsController").is_some());
946        assert!(total("widget_controller", "WidgetController").is_some());
947        // unrelated controller still doesn't match
948        assert!(total("widget_controller", "AdminController").is_none());
949    }
950
951    #[test]
952    fn wildcard_star_spans_an_explicit_gap() {
953        // `*` bridges any run, so the scattered tail the fuzzy gate rejects is
954        // exactly what an explicit star asks for
955        assert!(total("find*controller", "FindController").is_some());
956        assert!(total("find*controller", "FindUserController").is_some());
957        assert!(total("find*controller", "FindUserAccountController").is_some());
958        // but the literals must still appear contiguously — `controller` is a
959        // literal, not an abbreviation
960        assert!(total("find*ctrlr", "FindController").is_none());
961        // and a name missing a literal segment doesn't match
962        assert!(total("find*controller", "FindService").is_none());
963    }
964
965    #[test]
966    fn wildcard_question_mark_matches_one_char() {
967        // `?` and `.` each consume exactly one char
968        assert!(total("find?controller", "FindXController").is_some());
969        assert!(total("find.controller", "Find1Controller").is_some());
970        // zero chars or two chars in the slot don't fit a single `?`
971        assert!(total("find?controller", "FindController").is_none());
972        assert!(total("find?controller", "FindXyController").is_none());
973    }
974
975    #[test]
976    fn wildcard_highlights_only_the_literals() {
977        // the gap chars aren't highlighted, only the literals the user typed
978        assert_eq!(
979            match_positions("find*er", "FindController"),
980            vec![0, 1, 2, 3, 12, 13] // Find + er
981        );
982    }
983
984    #[test]
985    fn wildcard_prefers_boundary_aligned_matches() {
986        // a star landing the second literal on a word boundary outranks one
987        // landing it mid-word
988        let boundary = total("a*b", "Alpha_Bravo").unwrap();
989        let midword = total("a*b", "Alphabet").unwrap();
990        assert!(boundary > midword, "{boundary} > {midword}");
991    }
992
993    #[test]
994    fn non_subsequence_does_not_match() {
995        assert!(total("xyz", "RefundProcessor").is_none());
996        assert!(total("zzz", "User").is_none());
997    }
998
999    #[test]
1000    fn confidence_reflects_quality_and_dominance() {
1001        let exact = vec![Feature {
1002            name: "exact",
1003            value: 1000.0,
1004        }];
1005        let fuzzy = vec![Feature {
1006            name: "fuzzy",
1007            value: 300.0,
1008        }];
1009        // a unique exact match is fully confident
1010        assert_eq!(confidence(1000.0, match_quality(&exact), None), 1.0);
1011        // a lone fuzzy match is mid/low even though it's the only result
1012        let f = confidence(300.0, match_quality(&fuzzy), None);
1013        assert!(f > 0.3 && f < 0.65, "fuzzy confidence {f}");
1014        // three evenly-tied exacts: the leader isn't dominant → ~0.5, well below a
1015        // unique exact
1016        let tied = confidence(1000.0, match_quality(&exact), Some(1000.0));
1017        assert!(tied < 0.6, "tied exact confidence {tied}");
1018        // a clear leader (big gap to #2) stays near the top
1019        let dominant = confidence(1000.0, match_quality(&exact), Some(300.0));
1020        assert!(dominant > 0.9, "dominant confidence {dominant}");
1021    }
1022
1023    #[test]
1024    fn parse_qualified_splits_on_scope_separators() {
1025        assert_eq!(parse_qualified("User"), ("User", None));
1026        assert_eq!(parse_qualified("Foo::Bar"), ("Bar", Some("Foo")));
1027        assert_eq!(parse_qualified("App::Foo::Bar"), ("Bar", Some("App::Foo")));
1028        // a `#` is the innermost separator (Ruby instance method)
1029        assert_eq!(parse_qualified("Foo::Bar#baz"), ("baz", Some("Foo::Bar")));
1030        // a leading or trailing separator is not a qualifier
1031        assert_eq!(parse_qualified("::Bar"), ("::Bar", None));
1032        assert_eq!(parse_qualified("Foo::"), ("Foo::", None));
1033    }
1034
1035    #[test]
1036    fn parent_boost_matches_the_innermost_scopes() {
1037        // exact parent, and a qualifier naming only the immediate scope
1038        assert!(parent_boost("Foo", Some("Foo")).is_some());
1039        assert!(parent_boost("Foo", Some("App::Foo")).is_some());
1040        assert!(parent_boost("App::Foo", Some("App::Foo")).is_some());
1041        // more matched segments → a stronger boost
1042        let one = parent_boost("Foo", Some("App::Foo")).unwrap();
1043        let two = parent_boost("App::Foo", Some("App::Foo")).unwrap();
1044        assert!(two > one, "{two} > {one}");
1045        // the qualifier must be a suffix, not just any ancestor or sibling
1046        assert!(parent_boost("App", Some("App::Foo")).is_none());
1047        assert!(parent_boost("Foo", Some("Foo::Inner")).is_none());
1048        assert!(parent_boost("Foo", None).is_none());
1049    }
1050
1051    #[test]
1052    fn qualifier_ranks_the_symbol_in_the_named_scope_first() {
1053        // two classes both named `Bar`; the qualifier picks the one inside `Foo`
1054        let in_foo = SymbolRow {
1055            parent: Some("Foo".into()),
1056            ..row("Bar", "class", 1)
1057        };
1058        let in_baz = SymbolRow {
1059            parent: Some("Baz".into()),
1060            ..row("Bar", "class", 1)
1061        };
1062        let foo = score("Foo::Bar", &in_foo, None, Boosts::default())
1063            .unwrap()
1064            .total;
1065        let baz = score("Foo::Bar", &in_baz, None, Boosts::default())
1066            .unwrap()
1067            .total;
1068        assert!(foo > baz, "{foo} > {baz}");
1069        // the unqualified `Bar` still matches both (qualifier only reorders)
1070        assert!(score("Bar", &in_baz, None, Boosts::default()).is_some());
1071        // a wrong leaf still doesn't match, qualifier or not
1072        assert!(score("Foo::Zzz", &in_foo, None, Boosts::default()).is_none());
1073    }
1074
1075    #[test]
1076    fn boundary_alignment_outranks_scattered() {
1077        // "rp" aligned to Refund/Processor humps should beat an incidental match
1078        let aligned = total("rp", "RefundProcessor").unwrap();
1079        let scattered = total("rp", "wrapper").unwrap();
1080        assert!(aligned > scattered, "{aligned} > {scattered}");
1081    }
1082
1083    #[test]
1084    fn path_only_match_surfaces_a_class_in_a_named_file() {
1085        // name "Invoice" doesn't match "billing", but the file does
1086        let mut cand = row("Invoice", "class", 1);
1087        cand.file = "app/models/billing.rb".into();
1088        let s = score("billing", &cand, None, Boosts::default()).expect("path match");
1089        assert!(s.features.iter().any(|f| f.name == "path"));
1090
1091        // a method (not a primary definition) in the same file does NOT surface
1092        let mut method = row("compute", "method", 1);
1093        method.file = "app/models/billing.rb".into();
1094        assert!(score("billing", &method, None, Boosts::default()).is_none());
1095    }
1096
1097    #[test]
1098    fn path_bonus_reinforces_a_name_match() {
1099        let mut named = row("User", "class", 1);
1100        named.file = "app/models/user.rb".into();
1101        let mut elsewhere = row("User", "class", 1);
1102        elsewhere.file = "app/lib/misc.rb".into();
1103        let with_path = score("user", &named, None, Boosts::default())
1104            .unwrap()
1105            .total;
1106        let without = score("user", &elsewhere, None, Boosts::default())
1107            .unwrap()
1108            .total;
1109        assert!(with_path > without, "{with_path} > {without}");
1110    }
1111
1112    #[test]
1113    fn current_repo_boost_applies() {
1114        let cand = row("User", "class", 7);
1115        let in_repo = score("user", &cand, Some(7), Boosts::default())
1116            .unwrap()
1117            .total;
1118        let out_repo = score("user", &cand, Some(99), Boosts::default())
1119            .unwrap()
1120            .total;
1121        assert!(in_repo > out_repo);
1122        assert_eq!(in_repo - out_repo, 200.0);
1123    }
1124
1125    #[test]
1126    fn learned_boost_adds_to_the_score() {
1127        let cand = row("User", "class", 1);
1128        let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1129        let boosted = score(
1130            "user",
1131            &cand,
1132            None,
1133            Boosts {
1134                learned: 150.0,
1135                ..Default::default()
1136            },
1137        )
1138        .unwrap();
1139        assert_eq!(boosted.total - base, 150.0);
1140        assert!(boosted.features.iter().any(|f| f.name == "learned"));
1141    }
1142
1143    #[test]
1144    fn recency_boost_adds_to_the_score() {
1145        let cand = row("User", "class", 1);
1146        let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1147        let boosted = score(
1148            "user",
1149            &cand,
1150            None,
1151            Boosts {
1152                recency: 80.0,
1153                ..Default::default()
1154            },
1155        )
1156        .unwrap();
1157        assert_eq!(boosted.total - base, 80.0);
1158        assert!(boosted.features.iter().any(|f| f.name == "recency"));
1159    }
1160
1161    #[test]
1162    fn branch_boost_adds_to_the_score() {
1163        let cand = row("User", "class", 1);
1164        let base = score("user", &cand, None, Boosts::default()).unwrap().total;
1165        let boosted = score(
1166            "user",
1167            &cand,
1168            None,
1169            Boosts {
1170                branch: 180.0,
1171                ..Default::default()
1172            },
1173        )
1174        .unwrap();
1175        assert_eq!(boosted.total - base, 180.0);
1176        assert!(boosted.features.iter().any(|f| f.name == "branch"));
1177    }
1178}