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>
This commit is contained in:
google-labs-jules[bot] 2026-08-19 11:51:52 +00:00
parent 868a34497b
commit e87bc6de1d
2 changed files with 110 additions and 16 deletions

View file

@ -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}`);

View file

@ -30,9 +30,13 @@ export interface SearchController {
open(): void; open(): void;
} }
const $ = <T extends HTMLElement>(sel: string): T => document.querySelector(sel) as T; const $ = <T extends HTMLElement>(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()); const m = /^(>=|<=|>|<|=|~)\s*(.+)$/.exec(q.trim());
if (!m) return null; if (!m) return null;
const v = engine.evaluateExpression(m[2].trim()); const v = engine.evaluateExpression(m[2].trim());
@ -57,30 +61,73 @@ export function searchAllSheets(deps: SearchDeps, query: string): SearchHit[] {
const v = r.value.value; const v = r.value.value;
let match = false; let match = false;
switch (rq.op) { switch (rq.op) {
case ">": match = v.gt(th); break; case ">":
case ">=": match = v.gte(th); break; match = v.gt(th);
case "<": match = v.lt(th); break; break;
case "<=": match = v.lte(th); break; case ">=":
case "=": match = v.eq(th); break; match = v.gte(th);
case "~": match = !th.isZero() && v.minus(th).abs().div(th.abs()).lte(0.01); break; 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) { 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; if (hits.length >= 200) return hits;
} }
} }
} }
return hits; return hits;
} }
const ql = q.toLowerCase();
const hits: SearchHit[] = []; const hits: SearchHit[] = [];
const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(escapedQ, "ig");
for (const doc of docs) { for (const doc of docs) {
const lines = doc.text.split("\n"); const text = doc.text;
for (let i = 0; i < lines.length; i++) { let match;
if (lines[i].toLowerCase().includes(ql)) { let lastIndex = 0;
hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] }); let lineNo = 1;
if (hits.length >= 200) return hits;
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; return hits;
@ -113,7 +160,9 @@ export function initSearch(deps: SearchDeps): SearchController {
docEl.textContent = hit.docTitle; docEl.textContent = hit.docTitle;
const lineEl = document.createElement("div"); const lineEl = document.createElement("div");
lineEl.className = "line"; 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.append(docEl, lineEl);
item.addEventListener("click", () => { item.addEventListener("click", () => {
deps.onOpen(hit.docId, hit.line); deps.onOpen(hit.docId, hit.line);