/** Workspace text search shared by the editor API (head tree) and the editor extension (live room files). */ export type TextQuery = { pattern: string; isRegExp?: boolean; isCaseSensitive?: boolean; isWordMatch?: boolean; }; /** One matching line: `ranges` are [start, end) columns of each match within `text`. */ export type LineMatch = { line: number; text: string; ranges: Array<[number, number]> }; export const MAX_PATTERN_LENGTH = 1000; /** Long lines (minified bundles) are cut for the preview; matches past the cut are dropped. */ const MAX_PREVIEW_CHARS = 1000; function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** The query as a global RegExp; throws SyntaxError for an invalid regular expression. */ export function compileQuery(query: TextQuery): RegExp { let source = query.isRegExp ? query.pattern : escapeRegExp(query.pattern); if (query.isWordMatch) source = `\\b(?:${source})\\b`; return new RegExp(source, query.isCaseSensitive ? "g" : "gi"); } /** Matching lines of `content`, at most `limit` of them. */ export function matchLines(content: string, regex: RegExp, limit: number): LineMatch[] { const out: LineMatch[] = []; const lines = content.split(/\r?\n/); for (let index = 0; index < lines.length && out.length < limit; index++) { const line = lines[index]; const text = line.length > MAX_PREVIEW_CHARS ? line.slice(0, MAX_PREVIEW_CHARS) : line; const ranges: Array<[number, number]> = []; regex.lastIndex = 0; for (let match = regex.exec(text); match; match = regex.exec(text)) { if (match[0].length === 0) { regex.lastIndex++; continue; } ranges.push([match.index, match.index + match[0].length]); } if (ranges.length) out.push({ line: index, text, ranges }); } return out; } /** Shortest literal an index prefilter can use: trigram FTS needs three characters. */ export const MIN_INDEX_LITERAL = 3; const CLASS_ESCAPES = new Set([..."dDwWsSbBpPkuxc0123456789"]); /** * A substring every match must contain, for a trigram prefilter (case-insensitive, so a superset), or null when * none of at least MIN_INDEX_LITERAL characters can be proven. Conservative for regexes: alternation gives up, * groups and classes break the literal, and a quantified character is dropped. */ export function requiredLiteral(query: TextQuery): string | null { if (!query.isRegExp) return query.pattern.length >= MIN_INDEX_LITERAL ? query.pattern : null; const source = query.pattern; if (source.includes("|")) return null; const segments: string[] = []; let current = ""; const cut = () => { if (current) segments.push(current); current = ""; }; let depth = 0; for (let index = 0; index < source.length; index++) { const char = source[index]; if (char === "\\") { const next = source[index + 1] ?? ""; index++; if (depth > 0) continue; if (CLASS_ESCAPES.has(next) || /[a-zA-Z]/.test(next)) cut(); else current += next; continue; } if (char === "[") { cut(); while (index < source.length && source[index] !== "]") index += source[index] === "\\" ? 2 : 1; continue; } if (char === "(") { depth++; cut(); continue; } if (char === ")") { depth = Math.max(0, depth - 1); continue; } if (depth > 0) continue; if (char === "*" || char === "?" || char === "{") { current = current.slice(0, -1); cut(); if (char === "{") while (index < source.length && source[index] !== "}") index++; continue; } if (char === "+" || char === "." || char === "^" || char === "$") { cut(); continue; } current += char; } cut(); const longest = segments.reduce((best, segment) => (segment.length > best.length ? segment : best), ""); return longest.length >= MIN_INDEX_LITERAL ? longest : null; }