1// A search query, as every section of the search reads it: search.ts for2// commits, reviews, wiki pages, file paths and repos, and codesearch.ts for3// the lines of files. A query is terms split on whitespace, except that a4// quoted phrase ("like this") is one term, spaces and all, which must appear5// as written, except that a run of whitespace in it matches any run of6// whitespace ("x = 1" finds `x = 1`, and the other way round). A quote7// opens a phrase only at the start of a term (in `it's"` or `a"b` it is just8// a character), an unclosed one runs to the end of the query, and empty9// quotes are no term. Every term must match, ignoring case. Nothing here10// touches the page, so test/search-phrases.ts checks it in Node as the11// browser runs it.1213// A query's terms, lower case, each once; a run of whitespace in a phrase14// is kept as one space, which find() matches with any run.15export function terms(q: string): string[] {16 const all = [...q.toLowerCase().matchAll(/"([^"]*)"?|(\S+)/g)].map((m) => m[2] ?? m[1].replace(/\s+/g, ' ')).filter((t) => t.trim());17 return [...new Set(all)];18}1920// The terms a search within one line needs: a term inside another goes,21// since every line with the longer one has it.22export const narrow = (ts: string[]): string[] => ts.filter((t, i) => !ts.some((o, j) => j !== i && o.length > t.length && o.includes(t)));2324// A term's pieces: a phrase's parts between whitespace (a word is one).25export const pieces = (t: string): string[] => t.split(' ').filter(Boolean);2627// Where a term is in a lower case text, from `from` on: [start, end), or28// null. A space in a term (only phrases have them) matches any run of29// whitespace; terms without one are a plain indexOf.30const patterns = new Map<string, RegExp>();31export function find(low: string, t: string, from = 0): [number, number] | null {32 if (!t.includes(' ')) {33 const i = low.indexOf(t, from);34 return i < 0 ? null : [i, i + t.length];35 }36 let re = patterns.get(t);37 if (!re) {38 if (patterns.size > 200) patterns.clear();39 re = new RegExp(t.split(' ').map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('\\s+'), 'g');40 patterns.set(t, re);41 }42 re.lastIndex = from;43 const m = re.exec(low);44 return m ? [m.index, m.index + m[0].length] : null;45}4647// Whether a text has every term, ignoring case.48export function hasAll(text: string, ts: string[]): boolean {49 const low = text.toLowerCase();50 return ts.every((t) => find(low, t));51}5253const wordStart = (s: string, i: number): boolean => i === 0 || /[^\p{L}\p{N}]/u.test(s[i - 1]);5455// How well a record matches, from its title, meta line and text (lower56// case), and a key that matches by prefix (a commit's full sha); null unless57// every term is in one of them. Matches in titles rank above matches in58// text, and those at the start of a word above those inside one.59export function score(low: [string, string, string], key: string, ts: string[]): { s: number; inText: boolean } | null {60 let s = 0;61 let inText = false;62 for (const t of ts) {63 const at = find(low[0], t);64 if (at) s += wordStart(low[0], at[0]) ? 6 : 4;65 else if (t.length >= 4 && key.startsWith(t)) s += 5;66 else if (find(low[1], t)) s += 2;67 else if (find(low[2], t)) {68 s += 1;69 inText = true;70 } else return null;71 }72 return { s, inText };73}7475// A text cut into runs, each marked if it is part of a term's match: a76// phrase is marked whole, and overlapping matches make one run. Text whose77// lower case is another length (a few letters) stays one plain run.78export function marks(s: string, ts: string[]): [string, boolean][] {79 const low = s.toLowerCase();80 if (low.length !== s.length) return [[s, false]];81 const hit = new Uint8Array(s.length);82 for (const t of ts) for (let m = find(low, t); m; m = find(low, t, m[0] + 1)) hit.fill(1, m[0], m[1]);83 const out: [string, boolean][] = [];84 for (let i = 0, j = 0; i < s.length; i = j) {85 while (j < s.length && hit[j] === hit[i]) j++;86 out.push([s.slice(i, j), !!hit[i]]);87 }88 return out;89}9091// The part of a long text around its first match.92export function snippet(text: string, ts: string[]): string {93 const low = text.toLowerCase();94 const at = Math.min(...ts.map((t) => find(low, t)?.[0] ?? -1).filter((i) => i >= 0));95 const start = Math.max(0, at - 30);96 return (start ? '…' : '') + text.slice(start, start + 120) + (start + 120 < text.length ? '…' : '');97}