Skip to main content

Autocomplete Search

src/search.ts implements the deterministic autocomplete ranking used by searchByName(). It is a pure function — no I/O, no external state. It can be tested and benchmarked in isolation.


Score tiers

Results are ranked by score descending. Each query character length unlocks additional tiers:

ScoreTierMin query length
1000Exact normalized name match1
900Full-name prefix (name starts with query)1
800 − nWord-prefix (a word in the name starts with query, minus 10 per word position)2
700 − nSubstring (query appears anywhere, minus 2 per character offset)3
200 − nFuzzy — Levenshtein edit distance (minus 50 per edit)4
< 100Excluded from results entirely

Tiebreak order within a score: match position (earlier is better) → name length (shorter is better) → alphabetical.

Example — query bar:

CardTierScore
BardFull-name prefix (bard starts with bar)900
Barrow StingerFull-name prefix (barrow stinger starts with bar)900 → tiebreak: shorter name wins
Cannon BarrageWord-prefix (barrage is word 2, starts with bar)790
SingularityNo matchexcluded

autocompleteSearch(cards, query, limit)

function autocompleteSearch(cards: Iterable<Card>, query: string, limit: number): Card[]

Parameters

ParameterTypeDescription
cardsIterable<Card>Full card list to search against (e.g. in-memory list for local ranking)
querystringRaw query string — normalized internally via normalizeCardName
limitnumberMax results to return

Returns

Up to limit cards, ranked by score descending.

Behaviour

  • Queries shorter than 3 characters only return prefix matches — Levenshtein fuzzy is never applied at that length.
  • Returns [] when the normalized query is empty.

Server-side card search in SupabaseCardProvider uses Postgres FTS instead of this function; autocompleteSearch remains useful for pure in-memory ranking (tests, clients).


scoreCard(card, normQuery, queryLen)

Exported for testing. Scores a single card against a pre-normalized query string.

function scoreCard(card: Card, normQuery: string, queryLen: number): ScoredCard | null

Returns null when the card does not meet the minimum score threshold. The ScoredCard result carries { card, score, position } where position is the character offset of the match in the normalized name (used for tiebreaking).


Fuzzy edit distance

The fuzzy tier uses a single-row Levenshtein DP implementation. Maximum edit distance is:

  • 1 edit for queries of length 4–5
  • 2 edits for queries of length 6+

The algorithm short-circuits early when the length difference alone exceeds maxDist, and prunes rows where the entire row exceeds maxDist, keeping allocations small.


Name normalization

All comparisons use normalized names produced by normalizeCardName():

// src/normalize.ts
export function normalizeCardName(name: string): string {
return name
.toLowerCase()
.replace(/['\u2019-]/g, "") // apostrophes, right-single-quote, hyphens
.replace(/[^\w\s]/g, "")
.replace(/\s+/g, " ")
.trim();
}

Cards are stored with a pre-computed name_normalized field. The query is normalized at search time using the same function so comparisons are always apples-to-apples.