diff --git a/src/main/fd.js b/src/main/fd.js index 01758b1..47b9b23 100644 --- a/src/main/fd.js +++ b/src/main/fd.js @@ -138,31 +138,33 @@ class FdSearchSession { const onDebug = (msg) => this.emit('debug', { msg }); const t0 = Date.now(); - const perKwSets = []; - const union = new Set(); const stderrAny = []; - let stopped = false; - for (const kw of keywords) { - if (this.stopRequested) { - stopped = true; - break; - } - // eslint-disable-next-line no-await-in-loop - const r = await runSingleFd(exe, folder, kw, caseSensitive, respectIgnore, inc, exdList, this, onDebug); + // Run every keyword concurrently - fd spends most of its time walking the + // filesystem, so launching the scans in parallel overlaps that I/O instead + // of paying for it once per keyword. + const results = await Promise.all( + keywords.map((kw) => + runSingleFd(exe, folder, kw, caseSensitive, respectIgnore, inc, exdList, this, onDebug) + .then((r) => ({ kw, r }))), + ); + + for (const { r } of results) { if (r.error && r.error.code === 'ENOENT') { this.emit('error', { msg: `fd not found: ${exe}` }); return; } + } + + const perKwSets = []; + const union = new Set(); + for (const { kw, r } of results) { if (r.stderr) stderrAny.push(`[${kw}] ${r.stderr}`); for (const f of r.files) union.add(f); perKwSets.push(r.files); onDebug(`fd done kw='${kw}' | files=${r.files.size}`); - if (this.stopRequested) { - stopped = true; - break; - } } + const stopped = this.stopRequested; let final = union; if (mode === 'AND' && perKwSets.length) { diff --git a/src/main/ipc.js b/src/main/ipc.js index 20c53ad..f1261b2 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -359,11 +359,11 @@ function registerIpc({ openCscopeWindow, getInitialFolder, getStartupLogs }) { }); // ---- preview ---- - ipcMain.handle('preview:build', (_e, { filePath, keywords, caseSensitive, contextLines }) => { + ipcMain.handle('preview:build', async (_e, { filePath, keywords, caseSensitive, contextLines }) => { try { const kws = Array.isArray(keywords) ? keywords : parseKeywords(keywords); const lines = (typeof contextLines === 'number' && contextLines > 0) ? contextLines : PreviewConfig.CONTEXT_LINES; - const text = buildPreviewText(filePath, kws, !!caseSensitive, lines); + const text = await buildPreviewText(filePath, kws, !!caseSensitive, lines); return { ok: true, text }; } catch (err) { return { ok: false, text: `(Preview build failed)\n${err}\n` }; diff --git a/src/main/preview.js b/src/main/preview.js index fb89935..ef24f60 100644 --- a/src/main/preview.js +++ b/src/main/preview.js @@ -18,17 +18,6 @@ const fs = require('fs'); const { PreviewConfig } = require('./config'); -function getMatchLineNumbers(allLines, keyword, caseSensitive) { - const out = []; - const needle = caseSensitive ? keyword : keyword.toLowerCase(); - if (!needle) return out; - for (let i = 0; i < allLines.length; i += 1) { - const hay = caseSensitive ? allLines[i] : allLines[i].toLowerCase(); - if (hay.includes(needle)) out.push(i + 1); - } - return out; -} - function mergeRanges(ranges) { if (!ranges.length) return []; const sorted = ranges.slice().sort((a, b) => (a[0] - b[0]) || (a[1] - b[1])); @@ -47,10 +36,10 @@ function mergeRanges(ranges) { // Build the preview text for a file given keywords. Returns a string with // "N: line" prefixes and optional block separators. -function buildPreviewText(filePath, keywords, caseSensitive, contextLines) { +async function buildPreviewText(filePath, keywords, caseSensitive, contextLines) { let raw; try { - raw = fs.readFileSync(filePath, 'utf8'); + raw = await fs.promises.readFile(filePath, 'utf8'); } catch (e) { return `(Failed to open file)\n${e}\n`; } @@ -58,12 +47,31 @@ function buildPreviewText(filePath, keywords, caseSensitive, contextLines) { const total = allLines.length; if (total === 0) return '(Empty file)\n'; - const matchUnion = new Set(); + // Normalise the keyword needles once. For case-insensitive matching we also + // lowercase each line a single time (instead of once per keyword) below. + const needles = []; for (const kw of keywords) { - for (const ln of getMatchLineNumbers(allLines, kw, caseSensitive)) matchUnion.add(ln); + const n = caseSensitive ? kw : (kw || '').toLowerCase(); + if (n) needles.push(n); + } + + // Single pass over the file, testing every keyword per line. Pushing line + // numbers in ascending order yields an already-sorted, duplicate-free list, + // so no Set or sort is needed afterwards. + const matchLines = []; + if (needles.length) { + for (let i = 0; i < total; i += 1) { + const hay = caseSensitive ? allLines[i] : allLines[i].toLowerCase(); + for (let k = 0; k < needles.length; k += 1) { + if (hay.includes(needles[k])) { + matchLines.push(i + 1); + break; + } + } + } } - if (matchUnion.size === 0) { + if (matchLines.length === 0) { const headN = 10; const n = Math.min(headN, total); const out = ['(No matches in this file with current keyword(s). Show file head)', '']; @@ -73,7 +81,7 @@ function buildPreviewText(filePath, keywords, caseSensitive, contextLines) { } let ranges = []; - for (const ln of [...matchUnion].sort((a, b) => a - b)) { + for (const ln of matchLines) { const s = Math.max(1, ln - contextLines); const e = Math.min(total, ln + contextLines); ranges.push([s, e]); diff --git a/src/main/rg.js b/src/main/rg.js index aee7880..19c44eb 100644 --- a/src/main/rg.js +++ b/src/main/rg.js @@ -18,7 +18,11 @@ const { SearchConfig } = require('./config'); // Build the ripgrep argv (without the exe). Emits --json --stats and uses // fixed-strings literal matching, matching M2_SEEK behavior. -function rgSearchArgs(folder, keyword, inc, exd, exf, caseSensitive, respectIgnoreFiles) { +// +// `keywords` may be a single string or an array. When several patterns are +// passed they are emitted as multiple `-e` patterns so ripgrep matches their +// union (OR) in a SINGLE filesystem scan instead of one process per keyword. +function rgSearchArgs(folder, keywords, inc, exd, exf, caseSensitive, respectIgnoreFiles) { const args = ['--json', '--stats', '--fixed-strings']; if (!respectIgnoreFiles) { @@ -34,7 +38,11 @@ function rgSearchArgs(folder, keyword, inc, exd, exf, caseSensitive, respectIgno args.push('-g', g); } - args.push(keyword, folder); + // `-e` also protects patterns that start with '-' from being parsed as flags. + const kws = Array.isArray(keywords) ? keywords : [keywords]; + for (const kw of kws) args.push('-e', kw); + + args.push(folder); return args; } diff --git a/src/main/search.js b/src/main/search.js index 526ee2d..2e71d8d 100644 --- a/src/main/search.js +++ b/src/main/search.js @@ -22,12 +22,13 @@ const { SearchConfig, LiveUpdateConfig } = require('./config'); const WINDOWS = process.platform === 'win32'; -// Run a single ripgrep keyword search, streaming match counts. -// onMatch(path) is called for every match line. Returns a promise resolving to -// { counts: Map, filesSearched, stderr, stopped }. -function runSingleRg(rgExe, folder, keyword, inc, exd, exf, caseSensitive, respectIgnore, ctx, onMatch, onDebug) { +// Run a single ripgrep invocation, streaming match counts. `keywords` may be a +// single string or an array; passing several keywords matches their union (OR) +// in one scan. onMatch(path) is called for every match line. Returns a promise +// resolving to { counts: Map, filesSearched, stderr, stopped }. +function runSingleRg(rgExe, folder, keywords, inc, exd, exf, caseSensitive, respectIgnore, ctx, onMatch, onDebug) { return new Promise((resolve) => { - const args = rgSearchArgs(folder, keyword, inc, exd, exf, caseSensitive, respectIgnore); + const args = rgSearchArgs(folder, keywords, inc, exd, exf, caseSensitive, respectIgnore); if (onDebug) onDebug(`RUN (cmdline): ${formatCmdline([rgExe, ...args])}`); const counts = new Map(); @@ -197,7 +198,21 @@ class SearchSession { const filesSearchedSeen = []; let stopped = false; - if (useParallelAnd) { + if (mode !== 'AND') { + // OR: match the union of all keywords in ONE ripgrep process, so the + // filesystem is traversed a single time instead of once per keyword. + const r = await runSingleRg(exe, folder, keywords, inc, exd, exf, caseSensitive, respectIgnore, this, onMatch, onDebug); + if (r.error && r.error.code === 'ENOENT') { + this.emit('error', { msg: `rg not found: ${exe}` }); + return; + } + if (r.filesSearched !== null && r.filesSearched !== undefined) filesSearchedSeen.push(r.filesSearched); + if (r.stderr) stderrAny.push(r.stderr); + if (r.stopped) stopped = true; + perKwCounts.push(r.counts); + perKwFiles.push(new Set(r.counts.keys())); + onDebug(`[OR] done | files=${r.counts.size} matches=${sumMap(r.counts)}`); + } else if (useParallelAnd) { const results = await Promise.all( keywords.map((kw) => runSingleRg(exe, folder, kw, inc, exd, exf, caseSensitive, respectIgnore, this, onMatch, onDebug)