diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e300b8cd..3fd692bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599) - `codegraph_explore`, the CLI `codegraph explore` and the prompt hook no longer stall for a minute or more on a large project after a full index; the step that finds the project's busiest file no longer slows to a crawl when a few files are huge, like a generated Android `R.java` (#2571). Thanks @yangfengfan2011. - `codegraph_explore`, the CLI `codegraph explore` and the Claude Code prompt hook now answer a long query, like a pasted log, document or source file, in seconds on a large project instead of minutes. Every word of such a query used to search the whole index several times over, so the prompt hook ran out of time on long prompts and added nothing. Those searches now reuse what they read, and a very long query is searched by its code names and its opening words rather than by every word in it. Answers to ordinary questions and lists of symbol names are unchanged. Thanks @retsamekohC for the report and the first fix. (#2620) +- A search with a `path:` or `name:` filter, in `codegraph query`, the `codegraph_search` tool or the library's `searchNodes()`, now finds the symbols it asks for at any limit, and an offset pages through those symbols alone. Before, the filter was applied only after the results had been cut to the limit, so `codegraph query "authenticate path:src/api"` could find nothing while a much larger `--limit` showed the match. Thanks @rudycelekli. - The Claude Code prompt hook now goes by what you typed. Claude Code's desktop app puts status notes, like which other sessions are linked, in front of your prompt, and the hook read them as part of it, so every turn, even a one-word "yes", could get code context about the note's words, and so could a task notification, a subagent's report or a message from another session that came after one. The notes are now skipped, and a message from another session gets no context, as task notifications and subagent reports already didn't. Thanks @WonderMr for the report and @yang0228 for the first fix. (#2635) - `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578) - In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585). diff --git a/__tests__/search-field-filters.test.ts b/__tests__/search-field-filters.test.ts new file mode 100644 index 000000000..340d982bf --- /dev/null +++ b/__tests__/search-field-filters.test.ts @@ -0,0 +1,134 @@ +/** + * `path:` and `name:` are hard filters on a search (see query-parser.ts): they + * narrow the result set, and the search ranks within it. They used to be + * applied only after every candidate source had cut its rows to the limit, so + * whenever more than `limit` symbols outside the filters ranked first, the + * match the filters asked for was never shown, and only a much larger limit + * found it (#2254). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { ToolHandler } from '../src/mcp/tools'; + +const KELVIN = 'Kelvin'; + +describe('search path: and name: filters apply before the limit (#2254)', () => { + let root: string; + let cg: CodeGraph; + const names = (query: string, limit: number, offset?: number): string[] => + cg.searchNodes(query, { limit, offset }).map((r) => r.node.name); + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'search-field-filters-')); + const write = (file: string, source: string): void => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), source + '\n'); + }; + // Outside product/: more symbols than any limit below, each ranked ahead + // of product/'s by the source that finds it. FTS and LIKE rank the 120 + // `authenticate…` and `aaaNeedle…` names first, a filter-only search + // orders by name, and the fuzzy fallback meets the 64 `getUser` spellings + // (each as far from "getUsar" as `getUser` is) before `getUser` itself. + const getUserSpellings = Array.from({ length: 64 }, (_, mask) => + 'G' + [...'etUser'].map((c, i) => (mask & (1 << i) ? c.toUpperCase() : c.toLowerCase())).join(''), + ); + write('aaa/noise.ts', [ + ...Array.from({ length: 120 }, (_, i) => `export function authenticate${i}(): number { return ${i}; }`), + 'export function authenticate(): void {}', + ...Array.from({ length: 120 }, (_, i) => `export function aaaNeedle${i}(): number { return ${i}; }`), + ...getUserSpellings.map((name) => `export function ${name}(): void {}`), + // Non-ASCII names, which reach the filters' JavaScript test whatever the + // filter says; ordered by name, they come ahead of the Kelvin sign below. + ...Array.from({ length: 6 }, (_, i) => `export function Ärger${i}(): void {}`), + ].join('\n')); + write('product/handler.ts', [ + 'export function authenticateUserHandler(): void {}', + 'export function zzNeedleHandler(): void {}', + 'export function zzzRareHandler(): void {}', + 'export function getUser(): void {}', + ].join('\n')); + // Non-ASCII case, which SQLite's lower() and LIKE do not fold. The Kelvin + // sign (U+212A) lowercases to an ASCII "k", so `name:kelvin` matches it. + write('Ünicode/straße.ts', 'export function Öffnen(): void {}'); + write('Ünicode/kelvin.ts', `export function ${KELVIN}(): void {}`); + // `%` and `_` are literal in a filter: LIKE would let these decoys match. + write('percent%/under_score/literal.ts', 'export function percent_name(): void {}'); + write('percentX/underXscore/decoy.ts', 'export function percentXname(): void {}'); + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each([1, 2, 10])('finds what the filters ask for at limit %i, whichever source finds it', (limit) => { + // Full-text search. + expect(names('authenticate path:product/', limit)).toEqual(['authenticateUserHandler']); + expect(names('authenticate name:userhandler', limit)).toEqual(['authenticateUserHandler']); + // Substring search, when the full-text search finds nothing. + expect(names('needle path:product/', limit)).toEqual(['zzNeedleHandler']); + // The typo fallback. + expect(names('getUsar path:product/', limit)).toEqual(['getUser']); + // Filters alone, with no words to search for. + expect(names('kind:function name:zzzRareHandler', limit)).toEqual(['zzzRareHandler']); + }); + + it('pages with an offset through what the filters let through', () => { + const all = names('kind:function path:product/', 10); + expect([...all].sort()).toEqual(['authenticateUserHandler', 'getUser', 'zzNeedleHandler', 'zzzRareHandler']); + const pages = [0, 1, 2, 3].map((offset) => names('kind:function path:product/', 1, offset)); + expect(pages.every((page) => page.length === 1)).toBe(true); + expect(pages.flat().sort()).toEqual([...all].sort()); + expect(names('kind:function path:product/', 1, 4)).toEqual([]); + // A search by kind alone pages too; before, every offset gave the first page. + expect(names('kind:function', 1, 1)).not.toEqual(names('kind:function', 1, 0)); + }); + + it('matches non-ASCII case-insensitively and takes % and _ literally', () => { + expect(names('kind:function path:ünicode/ name:öffnen', 10)).toEqual(['Öffnen']); + expect(names('kind:function name:kelvin', 1)).toEqual([KELVIN]); + expect(names('kind:function path:percent%/', 10)).toEqual(['percent_name']); + expect(names('kind:function path:under_score/', 10)).toEqual(['percent_name']); + expect(names('kind:function name:percent_', 10)).toEqual(['percent_name']); + }); + + it('ORs the values of one filter and ANDs path: with name:', () => { + expect(names('kind:function path:product/ path:ünicode/ name:zzzrare name:öffnen', 10).sort()) + .toEqual(['zzzRareHandler', 'Öffnen'].sort()); + expect(names('kind:function path:product/ name:öffnen', 10)).toEqual([]); + }); + + it('returns nothing when nothing passes the filters, at any limit', () => { + // Filters no symbol passes at all. + expect(names('authenticate path:absent/', 100)).toEqual([]); + expect(names('authenticate name:nosuchhandler', 100)).toEqual([]); + expect(names('kind:function path:absent/', 100)).toEqual([]); + // Filters some symbols pass, though none the words find: every source runs. + expect(names('authenticate path:ünicode/', 100)).toEqual([]); + expect(names('authenticate name:öffnen', 100)).toEqual([]); + expect(names('needle path:percent%/', 100)).toEqual([]); + expect(names('getUsar path:percent%/', 100)).toEqual([]); + }); + + it('leaves a search without these filters as it was', () => { + expect(names('authenticate', 3)).toEqual(['authenticate', 'authenticate0', 'authenticate1']); + }); + + it('codegraph_search shows the match at limit 1 instead of "No results found"', async () => { + const handler = new ToolHandler(cg); + try { + const found = await handler.execute('codegraph_search', { query: 'authenticate path:product/', limit: 1 }); + expect(found.isError).not.toBe(true); + expect(JSON.stringify(found.content)).toContain('authenticateUserHandler'); + const absent = await handler.execute('codegraph_search', { query: 'authenticate path:absent/', limit: 1 }); + expect(absent.isError).not.toBe(true); + expect(JSON.stringify(absent.content)).toContain('No results found'); + } finally { + await handler.closeAll(); + } + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index b7c6afa68..1cd3b7576 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -19,7 +19,7 @@ import { } from '../types'; import { safeJsonParse } from '../utils'; import { isTestFile, kindBonus, nameMatchBonus, scorePathRelevance } from '../search/query-utils'; -import { parseQuery, boundedEditDistance } from '../search/query-parser'; +import { parseQuery, boundedEditDistance, type ParsedQuery } from '../search/query-parser'; import { isGeneratedFile } from '../extraction/generated-detection'; import { splitIdentifierSegments } from '../search/identifier-segments'; import { isModuleKey, referenceNameTail, MEMBER_TAIL_SQL, MODULE_PARKED_SQL } from './reference-tail'; @@ -220,6 +220,70 @@ function rowToNode(row: NodeRow): Node { }; } +/** + * A search's `path:` and `name:` filters, lowercased once. The values of one + * family are OR'd and the two families AND'd, and each value is a literal, + * case-insensitive substring of the file path or the symbol's name, as the + * query parser documents. JavaScript decides each row a statement returns, + * since `toLowerCase` folds Unicode, where SQLite's `lower()` and LIKE fold + * ASCII only, and LIKE would read a `%` or `_` in the value as a wildcard; the + * statement's own SQL only narrows the rows (see fieldFilterSql). + */ +interface FieldFilters { + paths: string[]; + names: string[]; +} + +/** The query's `path:`/`name:` filters, or undefined when it has none. */ +function fieldFiltersOf(parsed: ParsedQuery): FieldFilters | undefined { + if (parsed.pathFilters.length === 0 && parsed.nameFilters.length === 0) return undefined; + return { + paths: parsed.pathFilters.map((p) => p.toLowerCase()), + names: parsed.nameFilters.map((n) => n.toLowerCase()), + }; +} + +function passesNameFilters(name: string, filters: FieldFilters): boolean { + if (filters.names.length === 0) return true; + const lowered = name.toLowerCase(); + return filters.names.some((n) => lowered.includes(n)); +} + +function passesFieldFilters(name: string, filePath: string, filters: FieldFilters): boolean { + if (filters.paths.length > 0) { + const lowered = filePath.toLowerCase(); + if (!filters.paths.some((p) => lowered.includes(p))) return false; + } + return passesNameFilters(name, filters); +} + +const PRINTABLE_ASCII = /^[ -~]*$/; + +/** + * WHERE terms (each starting with ` AND `) that keep every `nodes` row the + * filters could pass, so that SQLite drops most of the others before they + * reach JavaScript, which still decides (see passesFieldFilters). On text of + * printable ASCII alone, SQLite's `lower()` and `instr()` agree exactly with + * `toLowerCase()` and `includes()`, so a printable ASCII value is tested in + * SQL. Text holding any other character goes on to JavaScript whatever it + * holds, and that is the only text a value with another character can match. + */ +function fieldFilterSql(filters: FieldFilters, params: (string | number)[]): string { + let sql = ''; + for (const [column, values] of [['nodes.file_path', filters.paths], ['nodes.name', filters.names]] as const) { + if (values.length === 0) continue; + const terms: string[] = []; + for (const value of values) { + if (!PRINTABLE_ASCII.test(value)) continue; + terms.push(`instr(lower(${column}), ?) > 0`); + params.push(value); + } + terms.push(`${column} GLOB '*[^ -~]*'`); + sql += ` AND (${terms.join(' OR ')})`; + } + return sql; +} + /** * Convert database row to Edge object */ @@ -1726,8 +1790,15 @@ export class QueryBuilder { parsed.languages.length > 0 ? Array.from(new Set([...(options.languages ?? []), ...parsed.languages])) : options.languages; - const pathFilters = parsed.pathFilters; - const nameFilters = parsed.nameFilters; + // `path:` and `name:` are hard filters, and every candidate source below + // applies them before its own limit and offset. Applied only to the final + // list, they found nothing whenever the rows that filled the limit all + // lay elsewhere, so a match was shown only at a larger limit (#2254). + const filters = fieldFiltersOf(parsed); + // When no symbol's name passes `name:`, or no symbol's file passes `path:`, + // nothing can: answer now, rather than let every source below read all + // its candidates to find none. + if (filters && !this.fieldFiltersCanMatch(filters)) return []; // The text portion drives FTS/LIKE; if all the user typed was // filters (`kind:function`), we still need *some* candidate set, // so synthesise an empty-text path that returns everything matching @@ -1738,16 +1809,14 @@ export class QueryBuilder { // First try FTS5 with prefix matching (skip if FTS5 not available, #1532) let results = text - ? (this._fts5Available !== false ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) : []) - // Over-fetch by 5× when running filter-only (no text). The - // post-scoring path: + name: filters can be very selective, so - // a smaller multiplier risks returning fewer than `limit` - // results despite the DB having plenty of matches. - : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); + ? (this._fts5Available !== false ? this.searchNodesFTS(text, { kinds, languages, limit, offset }, filters) : []) + // Over-fetch by 5× when running filter-only (no text): the pool is + // re-ranked below, and the extra rows give that ranking room to work. + : this.searchAllByFilters({ kinds, languages, limit: limit * 5, offset }, filters); // If no FTS results, try LIKE-based substring search if (results.length === 0 && text.length >= 2) { - results = this.searchNodesLike(text, { kinds, languages, limit, offset }); + results = this.searchNodesLike(text, { kinds, languages, limit, offset }, filters); } // Final fuzzy fallback: scan all known names and keep those within @@ -1755,7 +1824,7 @@ export class QueryBuilder { // returned nothing AND there's a text portion long enough to be // worth fuzzing (1-char queries would match too much). if (results.length === 0 && text.length >= 3) { - results = this.searchNodesFuzzy(text, { kinds, languages, limit }); + results = this.searchNodesFuzzy(text, { kinds, languages, limit }, filters); } // Supplement: ensure exact name matches are always candidates. @@ -1792,8 +1861,10 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' LIMIT 20'; - const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + if (filters) sql += fieldFilterSql(filters, params); + const rows = filters + ? this.readFilteredPage(sql, params, filters, 20, 0) + : this.db.prepare(`${sql} LIMIT 20`).all(...params) as NodeRow[]; for (const row of rows) { if (!existingIds.has(row.id)) { results.push({ node: rowToNode(row), score: maxFtsScore }); @@ -1803,6 +1874,13 @@ export class QueryBuilder { } } + // The filters once more, before the cut below: every source above has + // applied them already, so this only guards against a candidate source + // that someday doesn't. + if (filters) { + results = results.filter((r) => passesFieldFilters(r.node.name, r.node.filePath, filters)); + } + // Apply multi-signal scoring if (results.length > 0 && (text || query)) { const scoringQuery = text || query; @@ -1833,40 +1911,51 @@ export class QueryBuilder { } } - // Apply path: + name: filters AFTER scoring. Scoring already uses - // path/name as a soft signal; the explicit filters here are a hard - // gate. Done last so the FTS limit fetched plenty of candidates to - // narrow from. - if (pathFilters.length > 0) { - const lowered = pathFilters.map((p) => p.toLowerCase()); - results = results.filter((r) => { - const fp = r.node.filePath.toLowerCase(); - return lowered.some((p) => fp.includes(p)); - }); - } - if (nameFilters.length > 0) { - const lowered = nameFilters.map((n) => n.toLowerCase()); - results = results.filter((r) => { - const nm = r.node.name.toLowerCase(); - return lowered.some((n) => nm.includes(n)); - }); - } - return results; } + /** + * One page of the rows `sql` returns that pass `filters`: up to `limit` of + * them, after skipping the first `offset` that pass, so a source's limit and + * offset count only what the filters let through. `sql` has no LIMIT or + * OFFSET of its own. Its rows are streamed and the read stops once the page + * is full, so a selective filter holds none of the rows it rejects. Searches + * without these filters keep SQLite's LIMIT and OFFSET instead, whose + * reading of a negative limit (no limit) this follows. + */ + private readFilteredPage( + sql: string, + params: (string | number)[], + filters: FieldFilters, + limit: number, + offset: number, + ): T[] { + const page: T[] = []; + if (limit === 0) return page; + let skipped = 0; + for (const row of this.db.prepare(sql).iterate(...params) as IterableIterator) { + if (!passesFieldFilters(row.name, row.file_path, filters)) continue; + if (skipped < offset) { + skipped++; + continue; + } + page.push(row); + if (page.length === limit) break; + } + return page; + } + /** * Match-everything path used when the user supplied only field * filters (`kind:function lang:typescript`) with no text. Returns - * candidates ordered by name; the caller's filter pass narrows to - * what was asked for. - */ - private searchAllByFilters(options: { - kinds?: NodeKind[]; - languages?: Language[]; - limit: number; - }): SearchResult[] { - const { kinds, languages, limit } = options; + * candidates ordered by name, a page of those the `path:`/`name:` + * filters let through when there are any. + */ + private searchAllByFilters( + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number; offset: number }, + filters?: FieldFilters, + ): SearchResult[] { + const { kinds, languages, limit, offset } = options; let sql = 'SELECT * FROM nodes WHERE 1=1'; const params: (string | number)[] = []; if (kinds && kinds.length > 0) { @@ -1877,9 +1966,11 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' ORDER BY name LIMIT ?'; - params.push(limit); - const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + if (filters) sql += fieldFilterSql(filters, params); + sql += ' ORDER BY name'; + const rows = filters + ? this.readFilteredPage(sql, params, filters, limit, offset) + : this.db.prepare(`${sql} LIMIT ? OFFSET ?`).all(...params, limit, offset) as NodeRow[]; return rows.map((row) => ({ node: rowToNode(row), score: 1 })); } @@ -1893,7 +1984,8 @@ export class QueryBuilder { */ private searchNodesFuzzy( text: string, - options: { kinds?: NodeKind[]; languages?: Language[]; limit: number } + options: { kinds?: NodeKind[]; languages?: Language[]; limit: number }, + filters?: FieldFilters, ): SearchResult[] { const { kinds, languages, limit } = options; const lowered = text.toLowerCase(); @@ -1920,14 +2012,19 @@ export class QueryBuilder { // separate `SELECT * FROM nodes WHERE name = ?`; without this cap // a project with many similar names (`getUser1`, `getUser2`...) // could fan out far beyond `limit` queries before the inner-loop - // limit kicks in. + // limit kicks in. With `path:`/`name:` filters the cap could be spent on + // names whose symbols all lie outside them, ahead of the one that + // matches (#2254), so a filtered search looks names up until it has + // `limit` results, skipping without a lookup each name `name:` rules out. + // Every lookup is an index seek on one name. const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50); - const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP); + const cappedCandidates = filters ? candidates : candidates.slice(0, FUZZY_FOLLOWUP_CAP); const results: SearchResult[] = []; const seen = new Set(); for (const c of cappedCandidates) { if (results.length >= limit) break; + if (filters && !passesNameFilters(c.name, filters)) continue; let sql = 'SELECT * FROM nodes WHERE name = ?'; const params: (string | number)[] = [c.name]; if (kinds && kinds.length > 0) { @@ -1938,8 +2035,10 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' LIMIT 5'; - const rows = this.db.prepare(sql).all(...params) as NodeRow[]; + if (filters) sql += fieldFilterSql(filters, params); + const rows = filters + ? this.readFilteredPage(sql, params, filters, 5, 0) + : this.db.prepare(`${sql} LIMIT 5`).all(...params) as NodeRow[]; for (const row of rows) { if (seen.has(row.id)) continue; seen.add(row.id); @@ -2038,7 +2137,7 @@ export class QueryBuilder { /** * FTS5 search with prefix matching */ - private searchNodesFTS(query: string, options: SearchOptions): SearchResult[] { + private searchNodesFTS(query: string, options: SearchOptions, filters?: FieldFilters): SearchResult[] { const { kinds, languages, limit = 100, offset = 0 } = options; // Add prefix wildcard for better matching (e.g., "auth" matches "AuthService", "authenticate") @@ -2096,11 +2195,18 @@ export class QueryBuilder { params.push(...languages); } - sql += ' ORDER BY score LIMIT ? OFFSET ?'; - params.push(ftsLimit, offset); + if (filters) sql += fieldFilterSql(filters, params); + sql += ' ORDER BY score'; try { - const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + let rows: (NodeRow & { score: number })[]; + if (filters) { + rows = this.readFilteredPage(sql, params, filters, ftsLimit, offset); + } else { + sql += ' LIMIT ? OFFSET ?'; + params.push(ftsLimit, offset); + rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + } return rows.map((row) => ({ node: rowToNode(row), score: Math.abs(row.score), // bm25 returns negative scores @@ -2115,7 +2221,7 @@ export class QueryBuilder { * LIKE-based substring search for cases where FTS doesn't match * Useful for camelCase matching (e.g., "signIn" finds "signInWithGoogle") */ - private searchNodesLike(query: string, options: SearchOptions): SearchResult[] { + private searchNodesLike(query: string, options: SearchOptions, filters?: FieldFilters): SearchResult[] { const { kinds, languages, limit = 100, offset = 0 } = options; // This runs when FTS found nothing, and a word FTS can't find is usually @@ -2175,10 +2281,17 @@ export class QueryBuilder { params.push(...languages); } - sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?'; - params.push(limit, offset); + if (filters) sql += fieldFilterSql(filters, params); + sql += ' ORDER BY score DESC, length(name) ASC'; - const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + let rows: (NodeRow & { score: number })[]; + if (filters) { + rows = this.readFilteredPage(sql, params, filters, limit, offset); + } else { + sql += ' LIMIT ? OFFSET ?'; + params.push(limit, offset); + rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; + } return rows.map((row) => ({ node: rowToNode(row), @@ -4320,6 +4433,37 @@ export class QueryBuilder { return this.memoizedRead('fuzzyNames', read) ?? read(); } + /** + * The distinct file paths of the nodes, lowercased and newline-joined. Read + * once until the database changes (fresh inside a transaction), as + * {@link fuzzyNames} is. + */ + private loweredNodePaths(): string { + const read = (): string => { + const rows = this.db.prepare('SELECT DISTINCT file_path FROM nodes').all() as Array<{ file_path: string }>; + return rows.map((row) => row.file_path.toLowerCase()).join('\n') + '\n'; + }; + return this.memoizedRead('loweredNodePaths', read) ?? read(); + } + + /** + * False when no symbol's name passes the `name:` filters, or no symbol's + * file passes the `path:` ones, so that no symbol can pass both. A value + * found anywhere in the joined lists counts, so true only means a match + * may exist. + */ + private fieldFiltersCanMatch(filters: FieldFilters): boolean { + if (filters.names.length > 0) { + const names = this.fuzzyNames().lowered; + if (!filters.names.some((n) => names.includes(n))) return false; + } + if (filters.paths.length > 0) { + const paths = this.loweredNodePaths(); + if (!filters.paths.some((p) => paths.includes(p))) return false; + } + return true; + } + /** * The runs of ASCII letters and digits in the distinct qualified names, * lowercased and newline-joined: where a word LIKE could find in a