From e87bc6de1de976c1921b3aee91ab98f6999fa33d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:51:52 +0000 Subject: [PATCH] perf(search): optimize case-insensitive string search Replace `.split("\n")` and `.toLowerCase()` loop with a highly optimized `RegExp` iteration using `indexOf` for fast line number counting. This significantly reduces memory allocations and CPU cycles when searching large numbers of lines in the workspace. Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> --- benchmark/search_benchmark.ts | 45 +++++++++++++++++++ src/ui/search.ts | 81 ++++++++++++++++++++++++++++------- 2 files changed, 110 insertions(+), 16 deletions(-) create mode 100644 benchmark/search_benchmark.ts diff --git a/benchmark/search_benchmark.ts b/benchmark/search_benchmark.ts new file mode 100644 index 0000000..1a0487c --- /dev/null +++ b/benchmark/search_benchmark.ts @@ -0,0 +1,45 @@ +import { searchAllSheets } from "../src/ui/search.js"; +import { SumEngine } from "../src/engine/index.js"; +import { Workspace } from "../src/workspace.js"; +import Decimal from "decimal.js"; +import { performance } from "perf_hooks"; + +function generateDummyDoc(id: string, title: string, numLines: number): any { + const lines = []; + for (let i = 0; i < numLines; i++) { + lines.push(`Line ${i}: this is some dummy text to search. The quick brown fox jumps over the lazy dog.`); + } + lines.push(`Line target: looking for SPECIFIC_WORD here.`); + for (let i = 0; i < numLines; i++) { + lines.push(`Line ${i + numLines + 1}: more random text. A journey of a thousand miles begins with a single step.`); + } + return { id, title, text: lines.join("\n") }; +} + +const docs = []; +for (let i = 0; i < 500; i++) { // Increase size to 1M lines + docs.push(generateDummyDoc(`doc_${i}`, `Document ${i}`, 1000)); // ~2000 lines per doc, 500 docs = 1,000,000 lines +} + +const engine = new SumEngine(); +const workspace = new Workspace(engine); + +const deps = { + engine, + workspace, + docs: () => docs, + t: (key: string) => key, + onOpen: () => {} +}; + +console.log(`Starting benchmark with ${docs.length} documents...`); + +const start1 = performance.now(); +const res1 = searchAllSheets(deps, "specific_word"); +const end1 = performance.now(); +console.log(`Baseline Search (specific_word): ${(end1 - start1).toFixed(2)}ms, Hits: ${res1.length}`); + +const start2 = performance.now(); +const res2 = searchAllSheets(deps, "not_found_word_that_is_very_long"); +const end2 = performance.now(); +console.log(`Baseline Search (not_found_word): ${(end2 - start2).toFixed(2)}ms, Hits: ${res2.length}`); diff --git a/src/ui/search.ts b/src/ui/search.ts index df4ba4e..ca43b5c 100644 --- a/src/ui/search.ts +++ b/src/ui/search.ts @@ -30,9 +30,13 @@ export interface SearchController { open(): void; } -const $ = (sel: string): T => document.querySelector(sel) as T; +const $ = (sel: string): T => + document.querySelector(sel) as T; -export function parseResultQuery(engine: SumEngine, q: string): { op: string; threshold: Value } | null { +export function parseResultQuery( + engine: SumEngine, + q: string, +): { op: string; threshold: Value } | null { const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim()); if (!m) return null; const v = engine.evaluateExpression(m[2].trim()); @@ -57,30 +61,73 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] { const v = r.value.value; let match = false; switch (rq.op) { - case ">": match = v.gt(th); break; - case ">=": match = v.gte(th); break; - case "<": match = v.lt(th); break; - case "<=": match = v.lte(th); break; - case "=": match = v.eq(th); break; - case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break; + case ">": + match = v.gt(th); + break; + case ">=": + match = v.gte(th); + break; + case "<": + match = v.lt(th); + break; + case "<=": + match = v.lte(th); + break; + case "=": + match = v.eq(th); + break; + case "~": + match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); + break; } if (match) { - hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] ?? "", result: r.text ?? undefined }); + hits.push({ + docId: doc.id, + docTitle: doc.title, + line: i + 1, + text: lines[i] ?? "", + result: r.text ?? undefined, + }); if (hits.length >= 200) return hits; } } } return hits; } - const ql = q.toLowerCase(); const hits: SearchHit[] = []; + const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedQ, "ig"); + for (const doc of docs) { - const lines = doc.text.split("\n"); - for (let i = 0; i < lines.length; i++) { - if (lines[i].toLowerCase().includes(ql)) { - hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] }); - if (hits.length >= 200) return hits; + const text = doc.text; + let match; + let lastIndex = 0; + let lineNo = 1; + + regex.lastIndex = 0; + + while ((match = regex.exec(text)) !== null) { + let nIdx = text.indexOf("\n", lastIndex); + while (nIdx !== -1 && nIdx < match.index) { + lineNo++; + nIdx = text.indexOf("\n", nIdx + 1); } + + let lineStart = text.lastIndexOf("\n", match.index); + lineStart = lineStart === -1 ? 0 : lineStart + 1; + let lineEnd = text.indexOf("\n", match.index); + if (lineEnd === -1) lineEnd = text.length; + + hits.push({ + docId: doc.id, + docTitle: doc.title, + line: lineNo, + text: text.substring(lineStart, lineEnd), + }); + if (hits.length >= 200) return hits; + + regex.lastIndex = lineEnd; + lastIndex = lineEnd; } } return hits; @@ -113,7 +160,9 @@ export function initSearch(deps: SearchDeps): SearchController { docEl.textContent = hit.docTitle; const lineEl = document.createElement("div"); lineEl.className = "line"; - lineEl.textContent = hit.result ? `${hit.text} = ${hit.result}` : hit.text; + lineEl.textContent = hit.result + ? `${hit.text} = ${hit.result}` + : hit.text; item.append(docEl, lineEl); item.addEventListener("click", () => { deps.onOpen(hit.docId, hit.line);