clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / site / assets / codesearch.ts
codesearch.ts 25.5K
1// Code search: a repo's code index (lib/code-index.ts) is a SQLite FTS52// trigram index of the default branch, a static file the browser never3// downloads whole. sqlite-wasm opens it through a read-only VFS whose reads4// are HTTP range requests, kept for the session, so a query fetches only the5// pages it touches. search.ts starts this file as a module worker the first6// time someone searches code; test/code-search-bench.ts imports it in Node to7// measure the same code path.8//9// SQLite reads synchronously, and a worker may block on XHR, so each page10// SQLite misses is a request and a round trip. Most of a query's pages can be11// named before SQLite asks for them, though, and those are fetched at once:12// the index leaf of each term (from FTS5's small %_idx table), then the page13// of each matching chunk (from the chunk table's root page).1415import { find, narrow, pieces, terms } from './query.ts';1617// Bytes [from, to) of url (from < 0: the last -from bytes), or the whole18// file if the server ignores ranges; where they start, the file's size, and19// its validator, which must not change while it is open.20export interface Ranged {21 whole: boolean;22 data: Uint8Array;23 start: number;24 size: number;25 tag: string;26}27export type GetRange = (url: string, from: number, to: number) => Ranged;28export type FetchRange = (url: string, from: number, to: number) => Promise<Ranged>;2930const bytes = (from: number, to: number): string => (from < 0 ? `bytes=${from}` : `bytes=${from}-${to - 1}`);3132function ranged(url: string, status: number, header: (h: string) => string | null, data: Uint8Array): Ranged {33 if (status !== 200 && status !== 206) throw new Error(`${url}: HTTP ${status}`);34 // Ranges of a compressed body aren't ranges of the file.35 const enc = header('Content-Encoding');36 if (enc && enc !== 'identity') throw new Error(`${url} is served with Content-Encoding: ${enc}`);37 const whole = status === 200;38 const m = /(\d+)-\d+\/(\d+)\s*$/.exec(header('Content-Range') ?? '');39 if (!whole && !m) throw new Error(`${url}: no Content-Range`);40 return { whole, data, start: whole ? 0 : Number(m![1]), size: whole ? data.length : Number(m![2]), tag: header('ETag') ?? header('Last-Modified') ?? '' };41}4243// What SQLite's reads cost: a blocking XHR, which only workers may make.44export const xhrRange: GetRange = (url, from, to) => {45 const x = new XMLHttpRequest();46 x.open('GET', url, false);47 x.responseType = 'arraybuffer';48 x.setRequestHeader('Range', bytes(from, to));49 x.send();50 return ranged(url, x.status, (h) => x.getResponseHeader(h), new Uint8Array(x.response as ArrayBuffer));51};5253// What prefetches cost: fetches, several at a time.54export const fetchRange: FetchRange = async (url, from, to) => {55 const r = await fetch(url, { headers: { Range: bytes(from, to) } });56 return ranged(url, r.status, (h) => r.headers.get(h), new Uint8Array(await r.arrayBuffer()));57};5859// The file is read in blocks of one page (lib/code-index.ts writes 16K60// pages). A read that continues the last one doubles the next, up to 3261// blocks, since FTS5 walks long doclists in page order. Opening reads both62// ends at once: 8 blocks of head (the schema, every table's root page and63// the file table, which VACUUM puts first) and 4 of tail (FTS5's %_idx,64// which it puts last). An index of up to 256K is read whole.65export const BLOCK = 16 << 10;66const RUN = 32;67const HEAD = 8;68const TAIL = 4;69const WHOLE = 256 << 10;7071// The rebuilt index replaced the file under an open database.72export class Changed extends Error {}7374export class RangeFile {75 url: string;76 get: GetRange;77 getAsync: FetchRange;78 block: number;79 size = -1;80 tag = '';81 // HTTP requests, bytes, and round trips (a batch of prefetches is one).82 requests = 0;83 bytes = 0;84 rounds = 0;85 blocks = new Map<number, Uint8Array>();86 whole: Uint8Array | null = null;87 private next = -1;88 private run = 1;8990 constructor(url: string, get: GetRange, getAsync: FetchRange, block = BLOCK) {91 this.url = url;92 this.get = get;93 this.getAsync = getAsync;94 this.block = block;95 }9697 // Both ends of the file, which also say how big it is.98 async start(): Promise<void> {99 if (this.size >= 0) return;100 this.rounds++;101 const [head, tail] = await Promise.all([this.getAsync(this.url, 0, HEAD * this.block), this.getAsync(this.url, -TAIL * this.block, 0)]);102 this.keep(head);103 if (!head.whole) this.keep(tail);104 await this.rest();105 }106107 // Without start(), just the head.108 open(): void {109 if (this.size >= 0) return;110 this.fetch(0, HEAD);111 }112113 // A small index, whole.114 private async rest(): Promise<void> {115 if (this.whole || this.size > WHOLE) return;116 const n = Math.ceil(this.size / this.block);117 await this.prefetch(Array.from({ length: n }, (_, i) => i * this.block));118 }119120 private keep(r: Ranged): void {121 this.requests++;122 this.bytes += r.data.length;123 if (this.tag && r.tag !== this.tag) throw new Changed(`${this.url} changed`);124 this.tag = r.tag;125 this.size = r.size;126 if (r.whole) {127 this.whole = r.data;128 return;129 }130 // Whole blocks only: a tail may start inside one.131 let i = (this.block - (r.start % this.block)) % this.block;132 for (; i < r.data.length; i += this.block) this.blocks.set((r.start + i) / this.block, r.data.subarray(i, i + this.block));133 }134135 private span(first: number, count: number): [number, number] {136 const from = first * this.block;137 return [from, this.size < 0 ? from + count * this.block : Math.min(from + count * this.block, this.size)];138 }139140 private fetch(first: number, count: number): void {141 this.rounds++;142 this.keep(this.get(this.url, ...this.span(first, count)));143 this.next = first + count;144 }145146 has(offset: number): boolean {147 return !!this.whole || this.blocks.has(Math.floor(offset / this.block));148 }149150 // Fetch the blocks holding these offsets, all at once.151 async prefetch(offsets: number[]): Promise<void> {152 const want = [...new Set(offsets.filter((o) => o >= 0 && o < this.size && !this.has(o)).map((o) => Math.floor(o / this.block)))].sort((a, b) => a - b);153 if (!want.length) return;154 // Neighbouring blocks go in one request.155 const runs: [number, number][] = [];156 for (const b of want) {157 const r = runs.at(-1);158 if (r && r[0] + r[1] === b) r[1]++;159 else runs.push([b, 1]);160 }161 this.rounds++;162 const got = await Promise.all(runs.map(([b, n]) => this.getAsync(this.url, ...this.span(b, n))));163 for (const r of got) this.keep(r);164 }165166 // Fill dest from offset; returns how many bytes there were.167 read(dest: Uint8Array, offset: number): number {168 this.open();169 const end = Math.min(offset + dest.length, this.size);170 if (this.whole) {171 dest.set(this.whole.subarray(offset, Math.max(offset, end)));172 return Math.max(0, end - offset);173 }174 const last = Math.floor((end - 1) / this.block);175 for (let b = Math.floor(offset / this.block); b <= last; b++) {176 if (!this.blocks.has(b)) {177 let n = 1;178 while (b + n <= last && !this.blocks.has(b + n)) n++;179 this.run = b === this.next ? Math.min(this.run * 2, RUN) : 1;180 this.fetch(b, Math.max(n, this.run));181 }182 const data = this.blocks.get(b);183 if (!data) throw new Error(`${this.url}: no bytes at ${b * this.block}`);184 const start = b * this.block;185 const lo = Math.max(offset, start);186 const hi = Math.min(end, start + data.length);187 dest.set(data.subarray(lo - start, hi - start), lo - offset);188 }189 return Math.max(0, end - offset);190 }191}192193// The parts of sqlite-wasm this file uses.194type Ptr = number | bigint;195interface Struct {196 pointer: Ptr;197 $iVersion: number;198 $szOsFile: number;199 $mxPathname: number;200 $pMethods: Ptr;201 $xRandomness: Ptr;202 $xSleep: Ptr;203 dispose(): void;204}205interface Stmt {206 bind(values: unknown[]): Stmt;207 step(): boolean;208 get(row: unknown[]): unknown[];209 finalize(): void;210}211export interface Db {212 prepare(sql: string): Stmt;213 close(): void;214}215export interface Sqlite3 {216 capi: Record<string, number> & {217 sqlite3_vfs: new (p?: Ptr) => Struct;218 sqlite3_io_methods: new () => Struct;219 sqlite3_file: (new (p: Ptr) => Struct) & { structInfo: { sizeof: number } };220 sqlite3_vfs_find(name: string | null): Ptr;221 };222 wasm: {223 heap8u(): Uint8Array;224 poke32(p: Ptr, v: number): void;225 poke64(p: Ptr, v: bigint): void;226 poke(p: Ptr, v: number, type: string): void;227 cstrToJs(p: Ptr): string;228 cstrncpy(to: Ptr, from: Ptr, n: number): number;229 };230 vfs: { installVfs(opt: { io?: { struct: Struct; methods: object }; vfs?: { struct: Struct; methods: object; name?: string } }): void };231 oo1: { DB: new (opt: { filename: string; flags: string; vfs: string }) => Db };232}233234export const VFS = 'forge-range';235236// The files SQLite may open, by name, and the last error a read hit.237export interface RangeVfs {238 files: Map<string, RangeFile>;239 error(): unknown;240}241242// A read-only VFS over RangeFiles. Nothing else exists: no journal, no WAL,243// and the file says it is immutable, so SQLite takes no locks and checks for244// neither.245export function installVfs(sqlite3: Sqlite3): RangeVfs {246 const { capi, wasm } = sqlite3;247 const files = new Map<string, RangeFile>();248 const open = new Map<number, RangeFile>();249 let last: unknown = null;250 const io = new capi.sqlite3_io_methods();251 io.$iVersion = 1;252 const readOnly = (): number => capi.SQLITE_READONLY;253 sqlite3.vfs.installVfs({254 io: {255 struct: io,256 methods: {257 xClose: (f: Ptr) => (open.delete(Number(f)), 0),258 xRead(f: Ptr, dest: Ptr, n: number, at: number | bigint): number {259 const out = wasm.heap8u().subarray(Number(dest), Number(dest) + n);260 try {261 const got = open.get(Number(f))!.read(out, Number(at));262 if (got === n) return 0;263 out.fill(0, got);264 return capi.SQLITE_IOERR_SHORT_READ;265 } catch (e) {266 last = e;267 return capi.SQLITE_IOERR_READ;268 }269 },270 xWrite: readOnly,271 xTruncate: readOnly,272 xSync: () => 0,273 xFileSize(f: Ptr, out: Ptr): number {274 const file = open.get(Number(f))!;275 try {276 file.open();277 } catch (e) {278 last = e;279 return capi.SQLITE_IOERR_FSTAT;280 }281 wasm.poke64(out, BigInt(file.size));282 return 0;283 },284 xLock: () => 0,285 xUnlock: () => 0,286 xCheckReservedLock: (_f: Ptr, out: Ptr) => (wasm.poke32(out, 0), 0),287 xFileControl: () => capi.SQLITE_NOTFOUND,288 xSectorSize: () => 4096,289 xDeviceCharacteristics: () => capi.SQLITE_IOCAP_IMMUTABLE,290 },291 },292 });293 const vfs = new capi.sqlite3_vfs();294 const base = capi.sqlite3_vfs_find(null);295 vfs.$iVersion = 2;296 vfs.$szOsFile = capi.sqlite3_file.structInfo.sizeof;297 vfs.$mxPathname = 1024;298 if (base) {299 const d = new capi.sqlite3_vfs(base);300 vfs.$xRandomness = d.$xRandomness;301 vfs.$xSleep = d.$xSleep;302 d.dispose();303 }304 sqlite3.vfs.installVfs({305 vfs: {306 struct: vfs,307 name: VFS,308 methods: {309 xOpen(_v: Ptr, name: Ptr, f: Ptr, _flags: number, outFlags: Ptr): number {310 const file = name ? files.get(wasm.cstrToJs(name)) : undefined;311 if (!file) return capi.SQLITE_CANTOPEN;312 open.set(Number(f), file);313 const s = new capi.sqlite3_file(f);314 s.$pMethods = io.pointer;315 s.dispose();316 if (outFlags) wasm.poke32(outFlags, capi.SQLITE_OPEN_READONLY);317 return 0;318 },319 xDelete: readOnly,320 xAccess: (_v: Ptr, _name: Ptr, _flags: number, out: Ptr) => (wasm.poke32(out, 0), 0),321 xFullPathname: (_v: Ptr, name: Ptr, n: number, out: Ptr) => (wasm.cstrncpy(out, name, n) < n ? 0 : capi.SQLITE_CANTOPEN),322 xGetLastError: () => 0,323 xCurrentTime: (_v: Ptr, out: Ptr) => (wasm.poke(out, 2440587.5 + Date.now() / 864e5, 'double'), 0),324 xCurrentTimeInt64: (_v: Ptr, out: Ptr) => (wasm.poke(out, 210866760000000 + Date.now(), 'i64'), 0),325 },326 },327 });328 return {329 files,330 error: () => {331 const e = last;332 last = null;333 return e;334 },335 };336}337338// The child pages of a table b-tree's interior page that hold these rowids,339// or null if the page is a leaf (https://sqlite.org/fileformat2.html).340function childPages(page: Uint8Array, header: number, rowids: number[]): number[] | null {341 if (page[header] !== 5) return null;342 const u32 = (at: number): number => page[at] * 2 ** 24 + (page[at + 1] << 16) + (page[at + 2] << 8) + page[at + 3];343 // Cells are a child's page number, then a varint: the largest rowid under it.344 const cells: [number, number][] = [];345 const n = (page[header + 3] << 8) | page[header + 4];346 for (let i = 0; i < n; i++) {347 const at = (page[header + 12 + 2 * i] << 8) | page[header + 13 + 2 * i];348 let key = 0;349 for (let j = at + 4, k = 0; k < 9; j++, k++) {350 if (k === 8) {351 key = key * 256 + page[j];352 break;353 }354 key = key * 128 + (page[j] & 0x7f);355 if (page[j] < 0x80) break;356 }357 cells.push([key, u32(at)]);358 }359 const right = u32(header + 8);360 return rowids.map((r) => {361 let lo = 0;362 let hi = cells.length;363 while (lo < hi) {364 const mid = (lo + hi) >> 1;365 if (cells[mid][0] < r) lo = mid + 1;366 else hi = mid;367 }368 return lo < cells.length ? cells[lo][1] : right;369 });370}371372export interface Hit {373 path: string;374 line: number;375 text: string;376 // Line anchors live on the source page (markdown renders as a document).377 source: boolean;378}379380export interface Result {381 hits: Hit[];382 // More chunks matched than were read.383 more: boolean;384 // How many chunks it would read: the limit asked for, or MORE when a385 // first look found chunks with every word but no line with them all.386 limit: number;387 // What this query fetched.388 requests: number;389 bytes: number;390 rounds: number;391}392393// Trigrams can't find anything shorter.394export const MIN = 3;395// Chunks read for a first look (the overlay shows six lines until asked for396// more), and when asked.397export const FIRST = 12;398export const MORE = 100;399// A line is shown around its match, at most this long.400const WIDTH = 160;401// FTS5 leaves (about 4K each) of a term's doclist fetched ahead.402const DOCLIST = 32;403404// An FTS5 string, quotes doubled.405const str = (s: string): string => `"${s.replaceAll('"', '""')}"`;406407// The trigrams a query looks up. As a phrase, every trigram of the string is408// a term, and each term is a lookup in another part of the index. Those that409// just cover it (every third one, and the last) at NEAR distance find the410// same chunks with a third of the lookups; lines are checked for the exact411// string afterwards anyway.412export function cover(q: string): string[] {413 const c = [...q];414 const at: number[] = [];415 // Up to 5 characters, the phrase itself: every trigram, at most 3.416 for (let i = 0; i + 3 < c.length; i += c.length <= 5 ? 1 : 3) at.push(i);417 at.push(c.length - 3);418 return at.map((i) => c.slice(i, i + 3).join(''));419}420421// The strings the index looks up for a word: the word itself, but for a422// phrase its pieces between whitespace of MIN characters or more, since a423// space in a phrase stands for any run of whitespace and no trigram may span424// one. A phrase without such a piece (`"x = 1"`) is looked up as written,425// single spaces and all, so it finds only lines spaced that way.426function lookups(w: string): string[] {427 const ps = pieces(w).filter((p) => [...p].length >= MIN);428 return ps.length ? ps : [w];429}430431// Extra whitespace characters allowed per gap in a phrase: its pieces must432// be near each other in the index, with this much room for runs of spaces433// longer than one (alignment). Lines are checked afterwards anyway.434const GAP = 32;435436// The FTS5 query for a literal substring, ignoring case.437function literal(q: string): string {438 const n = [...q].length;439 // NEAR counts the tokens between the first phrase and the last.440 return n <= 5 ? str(q) : `NEAR(${cover(q).map(str).join(' ')}, ${n - 4})`;441}442443// The FTS5 query for a word: a literal substring, ignoring case, but in a444// phrase the trigrams of each piece, all within NEAR distance of the phrase445// with GAP more characters per gap.446export function match(w: string): string {447 const ps = lookups(w);448 if (ps.length === 1) return literal(ps[0]);449 const gaps = pieces(w).length - 1;450 return `NEAR(${ps.flatMap(cover).map(str).join(' ')}, ${[...w].length - 4 + GAP * gaps})`;451}452453// A query's words, lower case, its terms as query.ts reads them: split on454// whitespace, except that a quoted phrase ("like this") is one literal455// string, spaces and all, a run of whitespace in it matching any run (an456// unclosed quote runs to the end).457// A word that is part of another goes, since every line with the longer one458// has it, and so do NULs, which an FTS5 string can't hold.459export const words = (q: string): string[] => narrow(terms(q.replaceAll('\0', '')));460461// The words the trigram index can look up; shorter ones only filter lines.462export const indexed = (ws: string[]): string[] => ws.filter((w) => [...w].length >= MIN);463464// The FTS5 query for chunks that have every indexed word.465export const matchAll = (ws: string[]): string => indexed(ws).map(match).join(' AND ');466467// Where the first word starts in a line that has every word, ignoring case, or -1.468export function lineMatch(line: string, ws: string[]): number {469 const low = line.toLowerCase();470 let first = -1;471 for (const w of ws) {472 const at = find(low, w);473 if (!at) return -1;474 if (first < 0 || at[0] < first) first = at[0];475 }476 return first;477}478479export class CodeIndex {480 sqlite3: Sqlite3;481 vfs: RangeVfs;482 url: string;483 get: GetRange;484 getAsync: FetchRange;485 block: number;486 file!: RangeFile;487 db: Db | null = null;488 private n = 0;489 // What the file had fetched when the last query ended; opening reads too.490 private seen = { requests: 0, bytes: 0, rounds: 0 };491 // From the schema, for prefetching: page size, root pages, FTS5 segments.492 private layout: { page: number; chunk: number; data: number; segs: number[] } | null = null;493494 constructor(sqlite3: Sqlite3, vfs: RangeVfs, url: string, get: GetRange = xhrRange, getAsync: FetchRange = fetchRange, block = BLOCK) {495 this.sqlite3 = sqlite3;496 this.vfs = vfs;497 this.url = url;498 this.get = get;499 this.getAsync = getAsync;500 this.block = block;501 this.open();502 }503504 private open(): void {505 this.file = new RangeFile(this.url, this.get, this.getAsync, this.block);506 this.seen = { requests: 0, bytes: 0, rounds: 0 };507 this.layout = null;508 this.db = null;509 }510511 // The database, once both ends of the file are in.512 private async ready(): Promise<void> {513 if (this.db) return;514 await this.file.start();515 const name = `code-${++this.n}.db`;516 this.vfs.files.set(name, this.file);517 this.db = new this.sqlite3.oo1.DB({ filename: name, flags: 'r', vfs: VFS });518 }519520 close(): void {521 this.db?.close();522 this.db = null;523 this.vfs.files.delete(`code-${this.n}.db`);524 }525526 private all(sql: string, bind: unknown[] = []): unknown[][] {527 const st = this.db!.prepare(sql);528 const rows: unknown[][] = [];529 try {530 if (bind.length) st.bind(bind);531 while (st.step()) rows.push(st.get([]));532 } finally {533 st.finalize();534 }535 return rows;536 }537538 // Lines containing every word of q (words), ignoring case, by path. A539 // query needs a word of MIN characters for the index to look up. If the540 // index was rebuilt meanwhile, start over on the new one, once.541 async search(q: string, limit = FIRST): Promise<Result> {542 const ws = words(q);543 if (!indexed(ws).length) return { hits: [], more: false, limit, requests: 0, bytes: 0, rounds: 0 };544 try {545 return await this.query(ws, limit);546 } catch (e) {547 const why = e instanceof Changed ? e : this.vfs.error();548 if (!(why instanceof Changed)) throw why ?? e;549 this.close();550 this.open();551 return await this.query(ws, limit);552 }553 }554555 // The pages of a table's rows, from its root page: exact for a table of556 // two levels, which 16K pages give up to some 25M.557 private pages(root: number, rowids: number[]): number[] {558 const { page } = this.layout!;559 const buf = new Uint8Array(page);560 this.file.read(buf, (root - 1) * page);561 return childPages(buf, root === 1 ? 100 : 0, rowids) ?? [];562 }563564 private async prefetch(root: number, rowids: number[]): Promise<void> {565 const { page } = this.layout!;566 if (rowids.length) await this.file.prefetch(this.pages(root, rowids).map((p) => (p - 1) * page));567 }568569 private async query(ws: string[], limit: number): Promise<Result> {570 await this.ready();571 this.layout ??= {572 page: this.all('pragma page_size')[0][0] as number,573 chunk: this.all(`select rootpage from sqlite_schema where name = 'chunk'`)[0][0] as number,574 data: this.all(`select rootpage from sqlite_schema where name = 'code_data'`)[0][0] as number,575 segs: this.all('select distinct segid from code_idx').map((r) => r[0] as number),576 };577 // The index rows the query will read, from %_idx: each term's doclist,578 // from the leaf it starts on to the one the next term starts on (its579 // first DOCLIST leaves), and its doclist index. %_idx keys a leaf by its580 // first term, with '0' in front for the main index, and stores its page581 // number shifted left once. A leaf's row is segid << 37 | its page582 // number; a doclist index's has bit 36 set too. Row 10 is the structure.583 const enc = new TextEncoder();584 const wanted = [1, 10];585 for (const t of new Set(indexed(ws).flatMap(lookups).flatMap(cover))) {586 const key = enc.encode('0' + t);587 for (const seg of this.layout.segs) {588 const first = (this.all('select pgno >> 1 from code_idx where segid = ? and term <= ? order by term desc limit 1', [seg, key])[0]?.[0] as number | undefined) ?? 1;589 const next = (this.all('select pgno >> 1 from code_idx where segid = ? and term > ? order by term limit 1', [seg, key])[0]?.[0] as number | undefined) ?? first;590 const id = seg * 2 ** 37;591 for (let p = first; p <= Math.min(next, first + DOCLIST - 1); p++) wanted.push(id + p);592 if (next > first) wanted.push(id + 2 ** 36 + first);593 }594 }595 await this.prefetch(this.layout.data, wanted);596597 // Chunks with every indexed word, then their lines with every word. The598 // words of a chunk can be on different lines, so a first look that finds599 // chunks but no line reads on, once, up to MORE chunks.600 const hits: Hit[] = [];601 const fts = matchAll(ws);602 let read = 0;603 let more = false;604 for (;;) {605 const ids = this.all('select rowid from code where code match ? limit ? offset ?', [fts, limit + 1 - read, read]).map((r) => r[0] as number);606 more = ids.length > limit - read;607 ids.length = Math.min(ids.length, limit - read);608 read += ids.length;609 await this.prefetch(this.layout.chunk, ids);610 const rows = ids.length ? this.all(`select f.path, f.source, c.line, c.text from chunk c join file f on f.id = c.file where c.id in (${ids.map(() => '?').join(',')})`, ids) : [];611 for (const [path, source, line, text] of rows as [string, number, number, string][]) {612 text.split('\n').forEach((l, i) => {613 const at = lineMatch(l, ws);614 if (at >= 0) hits.push({ path, line: line + i, text: around(l, at), source: !!source });615 });616 }617 if (hits.length || !more || limit >= MORE) break;618 limit = MORE;619 }620 // Rows come in index order, which an incremental build doesn't keep in path order.621 hits.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : a.line - b.line));622 const { requests, bytes, rounds } = this.file;623 const r = { hits, more, limit, requests: requests - this.seen.requests, bytes: bytes - this.seen.bytes, rounds: rounds - this.seen.rounds };624 this.seen = { requests, bytes, rounds };625 return r;626 }627}628629// The line without its indent, cut to WIDTH around the match at `at`.630function around(line: string, at: number): string {631 const text = line.trim();632 if (text.length <= WIDTH) return text;633 const i = at - (line.length - line.trimStart().length);634 const start = Math.max(0, Math.min(i - 40, text.length - WIDTH));635 return (start ? '…' : '') + text.slice(start, start + WIDTH) + (start + WIDTH < text.length ? '…' : '');636}637638// As a worker: {sqlite, wasm, url} first, then {id, q, limit}; each query639// answers {id, ...Result} or {id, error}, in order.640interface Scope {641 postMessage(m: unknown): void;642 addEventListener(type: 'message', fn: (e: MessageEvent) => void): void;643}644if (typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope === 'function') {645 const scope = globalThis as unknown as Scope;646 let index: Promise<CodeIndex> | null = null;647 let queue: Promise<void> = Promise.resolve();648 scope.addEventListener('message', (e) => {649 const m = e.data as { sqlite?: string; wasm?: string; url?: string; id?: number; q?: string; limit?: number };650 if (m.sqlite && m.wasm && m.url) {651 const { sqlite, wasm, url } = m;652 index = (async () => {653 const init = (await import(sqlite)).default as (opt: object) => Promise<Sqlite3>;654 const sqlite3 = await init({ locateFile: () => wasm, print: () => {}, printErr: () => {} });655 return new CodeIndex(sqlite3, installVfs(sqlite3), url);656 })();657 return;658 }659 // One query at a time: they share the database and its prefetches.660 queue = queue.then(async () => {661 try {662 if (!index) throw new Error('code search was not started');663 scope.postMessage({ id: m.id, ...(await (await index).search(m.q ?? '', m.limit)) });664 } catch (err) {665 scope.postMessage({ id: m.id, error: String((err as Error)?.message ?? err) });666 }667 });668 });669}