* Fuzzy fallback: when zero FTS/LIKE hits, try an edit-distance * sweep over the distinct symbol-name set. Caps `maxDist` at 2 so * `getUssr` finds `getUser` but `process` doesn't match `prosody`. * Bounded edit distance keeps each comparison cheap; the per-query * scan is O(distinct-nam
(
text: string,
options: { kinds?: NodeKind[]; languages?: Language[]; limit: number }
)
| 1316 | * node count on any real codebase. |
| 1317 | */ |
| 1318 | private searchNodesFuzzy( |
| 1319 | text: string, |
| 1320 | options: { kinds?: NodeKind[]; languages?: Language[]; limit: number } |
| 1321 | ): SearchResult[] { |
| 1322 | const { kinds, languages, limit } = options; |
| 1323 | const lowered = text.toLowerCase(); |
| 1324 | const maxDist = lowered.length <= 4 ? 1 : 2; |
| 1325 | |
| 1326 | // Pull the distinct name list once. The set is cached on QueryBuilder |
| 1327 | // by getAllNodeNames(); even on a 200k-node project the distinct |
| 1328 | // name set is typically O(10k) because most names repeat. The |
| 1329 | // candidate-cap below bounds memory regardless. |
| 1330 | const allNames = this.getAllNodeNames(); |
| 1331 | const candidates: Array<{ name: string; dist: number }> = []; |
| 1332 | for (const name of allNames) { |
| 1333 | const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist); |
| 1334 | if (dist <= maxDist) candidates.push({ name, dist }); |
| 1335 | } |
| 1336 | candidates.sort((a, b) => a.dist - b.dist); |
| 1337 | |
| 1338 | // Cap the per-name follow-up queries. Each survivor triggers a |
| 1339 | // separate `SELECT * FROM nodes WHERE name = ?`; without this cap |
| 1340 | // a project with many similar names (`getUser1`, `getUser2`...) |
| 1341 | // could fan out far beyond `limit` queries before the inner-loop |
| 1342 | // limit kicks in. |
| 1343 | const FUZZY_FOLLOWUP_CAP = Math.max(limit * 2, 50); |
| 1344 | const cappedCandidates = candidates.slice(0, FUZZY_FOLLOWUP_CAP); |
| 1345 | |
| 1346 | const results: SearchResult[] = []; |
| 1347 | const seen = new Set<string>(); |
| 1348 | for (const c of cappedCandidates) { |
| 1349 | if (results.length >= limit) break; |
| 1350 | let sql = 'SELECT * FROM nodes WHERE name = ?'; |
| 1351 | const params: (string | number)[] = [c.name]; |
| 1352 | if (kinds && kinds.length > 0) { |
| 1353 | sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; |
| 1354 | params.push(...kinds); |
| 1355 | } |
| 1356 | if (languages && languages.length > 0) { |
| 1357 | sql += ` AND language IN (${languages.map(() => '?').join(',')})`; |
| 1358 | params.push(...languages); |
| 1359 | } |
| 1360 | sql += ' LIMIT 5'; |
| 1361 | const rows = this.db.prepare(sql).all(...params) as NodeRow[]; |
| 1362 | for (const row of rows) { |
| 1363 | if (seen.has(row.id)) continue; |
| 1364 | seen.add(row.id); |
| 1365 | // Lower the score for each edit step away from the query so |
| 1366 | // exact-match fallbacks (dist 0) outrank dist-2 typos. |
| 1367 | results.push({ node: rowToNode(row), score: 1 / (1 + c.dist) }); |
| 1368 | if (results.length >= limit) break; |
| 1369 | } |
| 1370 | } |
| 1371 | return results; |
| 1372 | } |
| 1373 | |
| 1374 | /** |
| 1375 | * FTS5 search with prefix matching |
no test coverage detected