clast-ai 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +11 -9
- package/README.md +447 -401
- package/bench/ab/onair-concept.json +22 -0
- package/bench/ab/onair-deadcode.json +7 -0
- package/bench/ab/onair-hard.json +32 -0
- package/bench/ab/onair-impact.json +17 -0
- package/bench/ab/onair-refactors.json +18 -0
- package/bench/ab/onair-survey.json +17 -0
- package/bench/ab/onair-tasks.json +20 -0
- package/bench/ab/onair.json +52 -0
- package/bench/ab/vscode-concept.json +22 -0
- package/bench/ab/vscode-hard.json +32 -0
- package/bench/ab/vscode-impact.json +17 -0
- package/bench/ab/vscode-refactors.json +18 -0
- package/bench/ab/vscode-tasks.json +20 -0
- package/bench/ab/vscode.json +52 -0
- package/bench/ab.mjs +213 -0
- package/bench/bench.mjs +98 -0
- package/bench/resolution.mjs +75 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/defaults.d.ts +12 -2
- package/dist/config/defaults.js +38 -31
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +26 -14
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +73 -13
- package/dist/db/index.js +481 -39
- package/dist/db/index.js.map +1 -1
- package/dist/engine.d.ts +15 -0
- package/dist/engine.js +28 -0
- package/dist/engine.js.map +1 -0
- package/dist/indexer/imports.d.ts +6 -0
- package/dist/indexer/imports.js +254 -0
- package/dist/indexer/imports.js.map +1 -0
- package/dist/indexer/index.d.ts +63 -0
- package/dist/indexer/index.js +360 -0
- package/dist/indexer/index.js.map +1 -0
- package/dist/indexer/process.d.ts +22 -0
- package/dist/indexer/process.js +42 -0
- package/dist/indexer/process.js.map +1 -0
- package/dist/indexer/scan.d.ts +43 -0
- package/dist/indexer/scan.js +223 -0
- package/dist/indexer/scan.js.map +1 -0
- package/dist/indexer/worker.d.ts +1 -0
- package/dist/indexer/worker.js +23 -0
- package/dist/indexer/worker.js.map +1 -0
- package/dist/llm/comment-generator.d.ts +17 -19
- package/dist/llm/comment-generator.js +46 -80
- package/dist/llm/comment-generator.js.map +1 -1
- package/dist/llm/index.d.ts +0 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/prompts.d.ts +0 -1
- package/dist/llm/prompts.js +16 -16
- package/dist/parser/extractor.d.ts +21 -16
- package/dist/parser/extractor.js +700 -228
- package/dist/parser/extractor.js.map +1 -1
- package/dist/parser/index.d.ts +17 -21
- package/dist/parser/index.js +115 -171
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/langs/c.d.ts +3 -0
- package/dist/parser/langs/c.js +879 -0
- package/dist/parser/langs/c.js.map +1 -0
- package/dist/parser/langs/csharp.d.ts +2 -0
- package/dist/parser/langs/csharp.js +548 -0
- package/dist/parser/langs/csharp.js.map +1 -0
- package/dist/parser/langs/dart.d.ts +2 -0
- package/dist/parser/langs/dart.js +893 -0
- package/dist/parser/langs/dart.js.map +1 -0
- package/dist/parser/langs/go.d.ts +2 -0
- package/dist/parser/langs/go.js +455 -0
- package/dist/parser/langs/go.js.map +1 -0
- package/dist/parser/langs/java.d.ts +2 -0
- package/dist/parser/langs/java.js +390 -0
- package/dist/parser/langs/java.js.map +1 -0
- package/dist/parser/langs/php.d.ts +2 -0
- package/dist/parser/langs/php.js +486 -0
- package/dist/parser/langs/php.js.map +1 -0
- package/dist/parser/langs/python.d.ts +2 -0
- package/dist/parser/langs/python.js +216 -0
- package/dist/parser/langs/python.js.map +1 -0
- package/dist/parser/langs/ruby.d.ts +2 -0
- package/dist/parser/langs/ruby.js +602 -0
- package/dist/parser/langs/ruby.js.map +1 -0
- package/dist/parser/langs/rust.d.ts +2 -0
- package/dist/parser/langs/rust.js +931 -0
- package/dist/parser/langs/rust.js.map +1 -0
- package/dist/parser/langs/swift.d.ts +2 -0
- package/dist/parser/langs/swift.js +616 -0
- package/dist/parser/langs/swift.js.map +1 -0
- package/dist/parser/langs/typescript.d.ts +4 -0
- package/dist/parser/langs/typescript.js +535 -0
- package/dist/parser/langs/typescript.js.map +1 -0
- package/dist/parser/registry.d.ts +16 -0
- package/dist/parser/registry.js +87 -0
- package/dist/parser/registry.js.map +1 -0
- package/dist/parser/returns.d.ts +9 -0
- package/dist/parser/returns.js +152 -0
- package/dist/parser/returns.js.map +1 -0
- package/dist/parser/spec.d.ts +165 -0
- package/dist/parser/spec.js +188 -0
- package/dist/parser/spec.js.map +1 -0
- package/dist/query/arity.d.ts +27 -0
- package/dist/query/arity.js +187 -0
- package/dist/query/arity.js.map +1 -0
- package/dist/query/callgraph.d.ts +113 -0
- package/dist/query/callgraph.js +332 -0
- package/dist/query/callgraph.js.map +1 -0
- package/dist/query/format.d.ts +36 -0
- package/dist/query/format.js +136 -0
- package/dist/query/format.js.map +1 -0
- package/dist/query/hierarchy.d.ts +44 -0
- package/dist/query/hierarchy.js +441 -0
- package/dist/query/hierarchy.js.map +1 -0
- package/dist/query/resolve.d.ts +265 -0
- package/dist/query/resolve.js +1957 -0
- package/dist/query/resolve.js.map +1 -0
- package/dist/query/search.d.ts +35 -0
- package/dist/query/search.js +379 -0
- package/dist/query/search.js.map +1 -0
- package/dist/query/words.d.ts +30 -0
- package/dist/query/words.js +201 -0
- package/dist/query/words.js.map +1 -0
- package/dist/server/index.d.ts +1 -3
- package/dist/server/index.js +313 -73
- package/dist/server/index.js.map +1 -1
- package/dist/tools/context.d.ts +35 -0
- package/dist/tools/context.js +79 -0
- package/dist/tools/context.js.map +1 -0
- package/dist/tools/explore.d.ts +51 -0
- package/dist/tools/explore.js +779 -0
- package/dist/tools/explore.js.map +1 -0
- package/dist/tools/impact.d.ts +21 -0
- package/dist/tools/impact.js +743 -0
- package/dist/tools/impact.js.map +1 -0
- package/dist/tools/search.d.ts +59 -0
- package/dist/tools/search.js +843 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/stats.d.ts +26 -0
- package/dist/tools/stats.js +604 -0
- package/dist/tools/stats.js.map +1 -0
- package/dist/types.d.ts +95 -40
- package/dist/types.js +9 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/watcher/index.d.ts +18 -16
- package/dist/watcher/index.js +94 -77
- package/dist/watcher/index.js.map +1 -1
- package/package.json +21 -24
- package/plugin/server.mjs +50 -0
- package/skills/audit/SKILL.md +37 -0
- package/skills/deadcode/SKILL.md +33 -0
- package/skills/explore/SKILL.md +54 -0
- package/skills/impact/SKILL.md +24 -0
- package/skills/status/SKILL.md +11 -0
- package/src/cli.ts +256 -0
- package/src/config/defaults.ts +50 -32
- package/src/config/index.ts +23 -14
- package/src/db/index.ts +540 -43
- package/src/engine.ts +30 -0
- package/src/indexer/imports.ts +241 -0
- package/src/indexer/index.ts +369 -0
- package/src/indexer/process.ts +63 -0
- package/src/indexer/scan.ts +226 -0
- package/src/indexer/worker.ts +24 -0
- package/src/llm/comment-generator.ts +52 -103
- package/src/llm/index.ts +80 -80
- package/src/llm/prompts.ts +95 -95
- package/src/parser/extractor.ts +643 -256
- package/src/parser/index.ts +105 -205
- package/src/parser/langs/c.ts +860 -0
- package/src/parser/langs/csharp.ts +519 -0
- package/src/parser/langs/dart.ts +820 -0
- package/src/parser/langs/go.ts +437 -0
- package/src/parser/langs/java.ts +361 -0
- package/src/parser/langs/php.ts +450 -0
- package/src/parser/langs/python.ts +206 -0
- package/src/parser/langs/ruby.ts +554 -0
- package/src/parser/langs/rust.ts +904 -0
- package/src/parser/langs/swift.ts +590 -0
- package/src/parser/langs/typescript.ts +499 -0
- package/src/parser/registry.ts +88 -0
- package/src/parser/returns.ts +137 -0
- package/src/parser/spec.ts +309 -0
- package/src/query/arity.ts +173 -0
- package/src/query/callgraph.ts +473 -0
- package/src/query/format.ts +136 -0
- package/src/query/hierarchy.ts +447 -0
- package/src/query/resolve.ts +1972 -0
- package/src/query/search.ts +430 -0
- package/src/query/words.ts +183 -0
- package/src/server/index.ts +390 -79
- package/src/tools/context.ts +82 -0
- package/src/tools/explore.ts +860 -0
- package/src/tools/impact.ts +748 -0
- package/src/tools/search.ts +886 -0
- package/src/tools/stats.ts +633 -0
- package/src/types.ts +113 -45
- package/src/version.ts +2 -0
- package/src/watcher/index.ts +82 -88
- package/tsconfig.json +7 -4
- package/.mcp.json +0 -8
- package/commands/clast-status.md +0 -9
- package/dist/config/defaults.d.ts.map +0 -1
- package/dist/config/index.d.ts.map +0 -1
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/queries.d.ts +0 -44
- package/dist/db/queries.d.ts.map +0 -1
- package/dist/db/queries.js +0 -251
- package/dist/db/queries.js.map +0 -1
- package/dist/db/schema.d.ts +0 -3
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/db/schema.js +0 -94
- package/dist/db/schema.js.map +0 -1
- package/dist/db/types.d.ts +0 -44
- package/dist/db/types.d.ts.map +0 -1
- package/dist/db/types.js +0 -2
- package/dist/db/types.js.map +0 -1
- package/dist/llm/comment-generator.d.ts.map +0 -1
- package/dist/llm/index.d.ts.map +0 -1
- package/dist/llm/prompts.d.ts.map +0 -1
- package/dist/parser/call-resolver.d.ts +0 -15
- package/dist/parser/call-resolver.d.ts.map +0 -1
- package/dist/parser/call-resolver.js +0 -16
- package/dist/parser/call-resolver.js.map +0 -1
- package/dist/parser/extractor.d.ts.map +0 -1
- package/dist/parser/index.d.ts.map +0 -1
- package/dist/parser/languages.d.ts +0 -11
- package/dist/parser/languages.d.ts.map +0 -1
- package/dist/parser/languages.js +0 -74
- package/dist/parser/languages.js.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/register-tools.d.ts +0 -7
- package/dist/server/register-tools.d.ts.map +0 -1
- package/dist/server/register-tools.js +0 -17
- package/dist/server/register-tools.js.map +0 -1
- package/dist/server/tools/clast-call-graph.d.ts +0 -4
- package/dist/server/tools/clast-call-graph.d.ts.map +0 -1
- package/dist/server/tools/clast-call-graph.js +0 -70
- package/dist/server/tools/clast-call-graph.js.map +0 -1
- package/dist/server/tools/clast-comment.d.ts +0 -5
- package/dist/server/tools/clast-comment.d.ts.map +0 -1
- package/dist/server/tools/clast-comment.js +0 -64
- package/dist/server/tools/clast-comment.js.map +0 -1
- package/dist/server/tools/clast-file-summary.d.ts +0 -4
- package/dist/server/tools/clast-file-summary.d.ts.map +0 -1
- package/dist/server/tools/clast-file-summary.js +0 -42
- package/dist/server/tools/clast-file-summary.js.map +0 -1
- package/dist/server/tools/clast-get-context.d.ts +0 -4
- package/dist/server/tools/clast-get-context.d.ts.map +0 -1
- package/dist/server/tools/clast-get-context.js +0 -80
- package/dist/server/tools/clast-get-context.js.map +0 -1
- package/dist/server/tools/clast-reindex.d.ts +0 -5
- package/dist/server/tools/clast-reindex.d.ts.map +0 -1
- package/dist/server/tools/clast-reindex.js +0 -36
- package/dist/server/tools/clast-reindex.js.map +0 -1
- package/dist/server/tools/clast-search.d.ts +0 -4
- package/dist/server/tools/clast-search.d.ts.map +0 -1
- package/dist/server/tools/clast-search.js +0 -29
- package/dist/server/tools/clast-search.js.map +0 -1
- package/dist/server/tools/clast-status.d.ts +0 -5
- package/dist/server/tools/clast-status.d.ts.map +0 -1
- package/dist/server/tools/clast-status.js +0 -23
- package/dist/server/tools/clast-status.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/watcher/gitignore.d.ts +0 -9
- package/dist/watcher/gitignore.d.ts.map +0 -1
- package/dist/watcher/gitignore.js +0 -38
- package/dist/watcher/gitignore.js.map +0 -1
- package/dist/watcher/index.d.ts.map +0 -1
- package/skills/clast-explore/SKILL.md +0 -41
- package/src/db/queries.ts +0 -332
- package/src/db/schema.ts +0 -106
- package/src/db/types.ts +0 -47
- package/src/parser/call-resolver.ts +0 -17
- package/src/parser/languages.ts +0 -86
- package/src/server/register-tools.ts +0 -30
- package/src/server/tools/clast-call-graph.ts +0 -82
- package/src/server/tools/clast-comment.ts +0 -76
- package/src/server/tools/clast-file-summary.ts +0 -59
- package/src/server/tools/clast-get-context.ts +0 -97
- package/src/server/tools/clast-reindex.ts +0 -52
- package/src/server/tools/clast-search.ts +0 -38
- package/src/server/tools/clast-status.ts +0 -37
- package/src/watcher/gitignore.ts +0 -42
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import type { ClastDb } from '../db/index.js';
|
|
2
|
+
import { SYMBOL_COLUMNS, SYMBOL_FROM } from '../db/index.js';
|
|
3
|
+
import type { SymbolRow } from '../types.js';
|
|
4
|
+
import { isTestPath, isVendoredPath } from './format.js';
|
|
5
|
+
import { KIND_WEIGHT, parseSymbolRef, pathMatches } from './resolve.js';
|
|
6
|
+
import { editDistance, queryTerms, splitIdentifier, stem, subwordMatch, wordMatches } from './words.js';
|
|
7
|
+
|
|
8
|
+
export interface SearchOptions {
|
|
9
|
+
kind?: string | string[];
|
|
10
|
+
/** Path filter: directory prefix, file suffix or substring. */
|
|
11
|
+
path?: string;
|
|
12
|
+
lang?: string;
|
|
13
|
+
limit?: number;
|
|
14
|
+
/** Only exported / public symbols. */
|
|
15
|
+
exported?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchHit {
|
|
19
|
+
sym: SymbolRow;
|
|
20
|
+
score: number;
|
|
21
|
+
/** How the hit matched: exact, prefix, words, substring, text, fuzzy. */
|
|
22
|
+
match: string;
|
|
23
|
+
/** Number of files that mention the symbol's name. */
|
|
24
|
+
refs: number;
|
|
25
|
+
/** True when no other symbol shares this name (so `refs` really is about this symbol). */
|
|
26
|
+
unique: boolean;
|
|
27
|
+
/** Further overloads / declarations of the same qualified name in the same file, folded into this hit. */
|
|
28
|
+
overloads?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SearchResult {
|
|
32
|
+
hits: SearchHit[];
|
|
33
|
+
/** True when more candidates existed than were returned. */
|
|
34
|
+
truncated: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const CANDIDATE_CAP = 400;
|
|
38
|
+
|
|
39
|
+
function likeEscape(s: string): string {
|
|
40
|
+
return s.replace(/[\\%_]/g, c => '\\' + c);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BODY_IDS_PER_TERM = 600;
|
|
44
|
+
const BODY_ROWS_PER_TERM = 40000;
|
|
45
|
+
const BODY_FILES = 60;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Functions and methods whose bodies mention the query terms: identifiers used
|
|
49
|
+
* in the code are matched word by word (`tokensMatch`, `csrf_token`, `verifyToken`
|
|
50
|
+
* all count for "token"), files are ranked by how many distinct, rare terms they
|
|
51
|
+
* contain, and inside the best files each callable is scored by the terms that
|
|
52
|
+
* occur within its line range.
|
|
53
|
+
*/
|
|
54
|
+
function bodyMatches(db: ClastDb, terms: string[]): Array<{ sym: SymbolRow; score: number; coverage: number }> {
|
|
55
|
+
// Identifiers containing each term. One pass over the identifier table for all terms.
|
|
56
|
+
const keys = terms.map(t => {
|
|
57
|
+
const s = stem(t);
|
|
58
|
+
return s.length > 4 ? s.slice(0, s.length - 1) : s;
|
|
59
|
+
});
|
|
60
|
+
const where = keys.map(() => `name LIKE ? ESCAPE '\\'`).join(' OR ');
|
|
61
|
+
const names = db.all<{ id: number; name: string }>(
|
|
62
|
+
`SELECT id, name FROM names WHERE ${where}`,
|
|
63
|
+
...keys.map(k => '%' + likeEscape(k) + '%')
|
|
64
|
+
);
|
|
65
|
+
const idsByTerm: number[][] = terms.map(() => []);
|
|
66
|
+
for (const n of names) {
|
|
67
|
+
if (n.name.length > 60) continue;
|
|
68
|
+
const words = splitIdentifier(n.name);
|
|
69
|
+
for (let t = 0; t < terms.length; t++) {
|
|
70
|
+
if (idsByTerm[t]!.length < BODY_IDS_PER_TERM && words.some(w => wordMatches(w, terms[t]!))) idsByTerm[t]!.push(n.id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Occurrences per file and term.
|
|
75
|
+
const perFile = new Map<number, Array<number[] | undefined>>();
|
|
76
|
+
const fileCount: number[] = terms.map(() => 0);
|
|
77
|
+
for (let t = 0; t < terms.length; t++) {
|
|
78
|
+
const ids = idsByTerm[t]!;
|
|
79
|
+
let rows = 0;
|
|
80
|
+
const seenFiles = new Set<number>();
|
|
81
|
+
for (let i = 0; i < ids.length && rows < BODY_ROWS_PER_TERM; i += 200) {
|
|
82
|
+
const chunk = ids.slice(i, i + 200);
|
|
83
|
+
for (const r of db.all<{ file_id: number; lines: string }>(
|
|
84
|
+
`SELECT file_id, lines FROM occ WHERE name_id IN (${chunk.map(() => '?').join(',')}) LIMIT ${BODY_ROWS_PER_TERM}`,
|
|
85
|
+
...chunk
|
|
86
|
+
)) {
|
|
87
|
+
rows++;
|
|
88
|
+
let slots = perFile.get(r.file_id);
|
|
89
|
+
if (!slots) perFile.set(r.file_id, (slots = new Array(terms.length)));
|
|
90
|
+
const list = slots[t] ?? (slots[t] = []);
|
|
91
|
+
if (list.length < 400) for (const l of r.lines.split(',')) list.push(Number(l));
|
|
92
|
+
seenFiles.add(r.file_id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
fileCount[t] = seenFiles.size;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Rare terms say more than common ones.
|
|
99
|
+
const totalFiles = Math.max(1, db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM files`)?.n ?? 1);
|
|
100
|
+
const weight = fileCount.map(n => (n === 0 ? 0 : Math.log(1 + totalFiles / n)));
|
|
101
|
+
const weightSum = weight.reduce((a, b) => a + b, 0);
|
|
102
|
+
const present = weight.filter(w => w > 0).length;
|
|
103
|
+
if (present < 2 || weightSum === 0) return [];
|
|
104
|
+
const need = present <= 2 ? 2 : Math.ceil(present * 0.6);
|
|
105
|
+
|
|
106
|
+
const files: Array<{ id: number; score: number; slots: Array<number[] | undefined> }> = [];
|
|
107
|
+
for (const [id, slots] of perFile) {
|
|
108
|
+
let n = 0;
|
|
109
|
+
let score = 0;
|
|
110
|
+
for (let t = 0; t < terms.length; t++) {
|
|
111
|
+
if (slots[t]) { n++; score += weight[t]!; }
|
|
112
|
+
}
|
|
113
|
+
if (n >= need) files.push({ id, score, slots });
|
|
114
|
+
}
|
|
115
|
+
files.sort((a, b) => b.score - a.score);
|
|
116
|
+
|
|
117
|
+
const out: Array<{ sym: SymbolRow; score: number; coverage: number }> = [];
|
|
118
|
+
for (const f of files.slice(0, BODY_FILES)) {
|
|
119
|
+
const syms = db.all<SymbolRow>(
|
|
120
|
+
`SELECT ${SYMBOL_COLUMNS} FROM ${SYMBOL_FROM} WHERE s.file_id = ? AND s.kind IN ('function', 'method')`, f.id
|
|
121
|
+
);
|
|
122
|
+
for (const s of syms) {
|
|
123
|
+
if (s.end_line <= s.start_line) continue; // no body to speak of
|
|
124
|
+
let n = 0;
|
|
125
|
+
let covered = 0;
|
|
126
|
+
for (let t = 0; t < terms.length; t++) {
|
|
127
|
+
const lines = f.slots[t];
|
|
128
|
+
if (lines && lines.some(l => l >= s.start_line && l <= s.end_line)) { n++; covered += weight[t]!; }
|
|
129
|
+
}
|
|
130
|
+
if (n < need) continue;
|
|
131
|
+
const span = s.end_line - s.start_line + 1;
|
|
132
|
+
// Full coverage in a focused function beats the same words scattered over a 500-line one.
|
|
133
|
+
const coverage = covered / weightSum;
|
|
134
|
+
out.push({ sym: s, score: 24 + coverage * 32 - Math.min(8, Math.log2(span) * 0.9), coverage });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out.sort((a, b) => b.score - a.score);
|
|
138
|
+
return out.slice(0, 80);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Ranked symbol search. Accepts an identifier (`parseAndStore`), a partial or
|
|
143
|
+
* abbreviated name (`parseSt`, `pas`), a qualified name (`Indexer.indexAll`),
|
|
144
|
+
* or free text (`resolve import path`) which is matched against names,
|
|
145
|
+
* signatures and doc comments.
|
|
146
|
+
*/
|
|
147
|
+
export function searchSymbols(db: ClastDb, query: string, opts: SearchOptions = {}): SearchResult {
|
|
148
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 20, 200));
|
|
149
|
+
const q = query.trim();
|
|
150
|
+
if (!q) return { hits: [], truncated: false };
|
|
151
|
+
|
|
152
|
+
const kinds = opts.kind ? new Set(Array.isArray(opts.kind) ? opts.kind : [opts.kind]) : null;
|
|
153
|
+
const accept = (s: SymbolRow): boolean =>
|
|
154
|
+
(!kinds || kinds.has(s.kind)) &&
|
|
155
|
+
(!opts.lang || s.lang === opts.lang) &&
|
|
156
|
+
(!opts.exported || s.exported === 1) &&
|
|
157
|
+
(!opts.path || pathMatches(s.path, opts.path));
|
|
158
|
+
|
|
159
|
+
const found = new Map<number, SearchHit>();
|
|
160
|
+
const add = (rows: SymbolRow[], base: number, match: string, bonus?: (s: SymbolRow) => number): void => {
|
|
161
|
+
for (const s of rows) {
|
|
162
|
+
if (!accept(s)) continue;
|
|
163
|
+
const score = base + (bonus ? bonus(s) : 0);
|
|
164
|
+
const prev = found.get(s.id);
|
|
165
|
+
if (!prev || prev.score < score) found.set(s.id, { sym: s, score, match, refs: 0, unique: false });
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const select = `SELECT ${SYMBOL_COLUMNS} FROM ${SYMBOL_FROM}`;
|
|
170
|
+
const isMultiWord = /\s/.test(q);
|
|
171
|
+
const ref = parseSymbolRef(q);
|
|
172
|
+
const name = isMultiWord ? q : ref.name;
|
|
173
|
+
const nameLc = name.toLowerCase();
|
|
174
|
+
const qLc = q.toLowerCase().replace(/[.?!]+$/, '');
|
|
175
|
+
const wantTests = /test|spec|mock|fixture|bench|example/i.test(q) || (opts.path ? isTestPath(opts.path) : false);
|
|
176
|
+
|
|
177
|
+
if (!isMultiWord && name) {
|
|
178
|
+
// 1. Exact name (case-sensitive, then insensitive).
|
|
179
|
+
add(db.all<SymbolRow>(`${select} WHERE s.name = ? LIMIT ${CANDIDATE_CAP}`, name), 100, 'exact');
|
|
180
|
+
add(db.all<SymbolRow>(`${select} WHERE s.name_lc = ? LIMIT ${CANDIDATE_CAP}`, nameLc), 92, 'exact');
|
|
181
|
+
|
|
182
|
+
// Qualified query: keep only hits whose container matches, when any do.
|
|
183
|
+
if (ref.container.length) {
|
|
184
|
+
const want = ref.container.map(c => c.toLowerCase());
|
|
185
|
+
const matches = (s: SymbolRow): boolean => {
|
|
186
|
+
const parts = s.qname.toLowerCase().split('.');
|
|
187
|
+
parts.pop();
|
|
188
|
+
return want.every((w, i) => parts[parts.length - want.length + i] === w);
|
|
189
|
+
};
|
|
190
|
+
const qualified = [...found.values()].filter(h => matches(h.sym));
|
|
191
|
+
if (qualified.length) {
|
|
192
|
+
for (const h of found.values()) {
|
|
193
|
+
if (matches(h.sym)) h.score += 8;
|
|
194
|
+
else h.score -= 30;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 2. Prefix.
|
|
200
|
+
if (found.size < CANDIDATE_CAP && nameLc.length >= 2) {
|
|
201
|
+
add(
|
|
202
|
+
db.all<SymbolRow>(
|
|
203
|
+
`${select} WHERE s.name_lc >= ? AND s.name_lc < ? ORDER BY length(s.name_lc) LIMIT ${CANDIDATE_CAP}`,
|
|
204
|
+
nameLc, nameLc + '\uffff'
|
|
205
|
+
),
|
|
206
|
+
78, 'prefix',
|
|
207
|
+
s => -Math.min(12, (s.name.length - name.length) * 0.5)
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2b. Abbreviations of the name's words: "KProd" -> KafkaProducer, "recAccum" -> RecordAccumulator.
|
|
213
|
+
const queryWords = !isMultiWord ? splitIdentifier(name) : [];
|
|
214
|
+
if (queryWords.length >= 2 && found.size < CANDIDATE_CAP) {
|
|
215
|
+
const pattern = queryWords.map(likeEscape).join('%') + '%';
|
|
216
|
+
add(
|
|
217
|
+
db.all<SymbolRow>(
|
|
218
|
+
`${select} WHERE s.name_lc LIKE ? ESCAPE '\\' ORDER BY length(s.name_lc) LIMIT 300`,
|
|
219
|
+
pattern
|
|
220
|
+
).filter(s => subwordMatch(name, s.name) > 0),
|
|
221
|
+
66, 'abbrev',
|
|
222
|
+
s => subwordMatch(name, s.name) * 8 - Math.min(8, (s.name.length - name.length) * 0.2)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
// 2c. Initials: "gcn" -> getContextNode, "qs" -> QuerySet.
|
|
226
|
+
if (!isMultiWord && /^[a-z]{2,6}$/.test(nameLc) && found.size < CANDIDATE_CAP) {
|
|
227
|
+
add(db.all<SymbolRow>(`${select} WHERE s.abbr = ? LIMIT 200`, nameLc), 60, 'initials');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 3. Word / concept match through FTS (names, signatures, docs, paths).
|
|
231
|
+
const terms = queryTerms(q);
|
|
232
|
+
if (terms.length && found.size < CANDIDATE_CAP) {
|
|
233
|
+
const ftsRows = (expr: string, max: number): Array<SymbolRow & { rank: number }> => {
|
|
234
|
+
try {
|
|
235
|
+
return db.all<SymbolRow & { rank: number }>(
|
|
236
|
+
`SELECT ${SYMBOL_COLUMNS}, bm25(symbols_fts, 10.0, 3.0, 4.0, 1.0) AS rank
|
|
237
|
+
FROM symbols_fts JOIN symbols s ON s.id = symbols_fts.rowid JOIN files f ON f.id = s.file_id
|
|
238
|
+
WHERE symbols_fts MATCH ? ORDER BY rank LIMIT ?`,
|
|
239
|
+
expr, max
|
|
240
|
+
);
|
|
241
|
+
} catch {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
// Prefix match on the singular so that "aliases" also finds `writeDestAlias`.
|
|
246
|
+
const singular = (t: string): string =>
|
|
247
|
+
/(ss|us|is)$/.test(t) || t.length < 4 ? t : /(s|x|ch|sh|z)es$/.test(t) ? t.slice(0, -2) : t.endsWith('s') ? t.slice(0, -1) : t;
|
|
248
|
+
const quoted = terms.map(t => `"${singular(t).replace(/"/g, '')}"*`);
|
|
249
|
+
const nameCols = (ts: string[]): string => `{words} : (${ts.join(' AND ')})`;
|
|
250
|
+
|
|
251
|
+
// All terms in the name: strongest concept signal.
|
|
252
|
+
const inName = ftsRows(nameCols(quoted), CANDIDATE_CAP);
|
|
253
|
+
add(inName, isMultiWord ? 74 : 64, 'words', s => {
|
|
254
|
+
// Terms found in the symbol's own name count fully; terms that only match
|
|
255
|
+
// the enclosing class name make the class, not each member, the best hit.
|
|
256
|
+
const words = splitIdentifier(s.name);
|
|
257
|
+
const own = terms.filter(t => words.some(w => wordMatches(w, t))).length;
|
|
258
|
+
const coverage = own / Math.max(words.length, terms.length);
|
|
259
|
+
return coverage * 14 - (terms.length - own) * 9;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Terms spread over name, signature, doc and path. Score by how many
|
|
263
|
+
// terms the *name* covers first, then overall coverage, then bm25.
|
|
264
|
+
const textBonus = (s: SymbolRow): number => {
|
|
265
|
+
const own = splitIdentifier(s.name);
|
|
266
|
+
const container = splitIdentifier(s.qname.slice(0, Math.max(0, s.qname.length - s.name.length)));
|
|
267
|
+
const hay = splitIdentifier(s.signature + ' ' + s.doc + ' ' + s.path);
|
|
268
|
+
let inName = 0;
|
|
269
|
+
let inContainer = 0;
|
|
270
|
+
let anywhere = 0;
|
|
271
|
+
for (const t of terms) {
|
|
272
|
+
if (own.some(w => wordMatches(w, t))) { inName++; anywhere++; }
|
|
273
|
+
else if (container.some(w => wordMatches(w, t))) { inContainer++; anywhere++; }
|
|
274
|
+
else if (hay.some(w => wordMatches(w, t))) anywhere++;
|
|
275
|
+
}
|
|
276
|
+
const rank = (s as SymbolRow & { rank: number }).rank;
|
|
277
|
+
// The description is (nearly) the doc comment itself: "convert a string to snake case".
|
|
278
|
+
const phrase = isMultiWord && s.doc && s.doc.toLowerCase().includes(qLc) ? 16 : 0;
|
|
279
|
+
return (
|
|
280
|
+
phrase +
|
|
281
|
+
(inName / terms.length) * 28 +
|
|
282
|
+
(inContainer / terms.length) * 9 +
|
|
283
|
+
(anywhere / terms.length) * 14 +
|
|
284
|
+
Math.min(6, -rank / 2)
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
if (found.size < limit * 3 || isMultiWord) {
|
|
288
|
+
const all = ftsRows(quoted.join(' AND '), 400);
|
|
289
|
+
add(all, 30, 'text', textBonus);
|
|
290
|
+
// Free-text queries degrade to "any term"; identifier queries do not
|
|
291
|
+
// (a partial identifier should fall through to substring / fuzzy).
|
|
292
|
+
if (isMultiWord && terms.length > 1 && all.length < limit * 2) {
|
|
293
|
+
add(ftsRows(quoted.join(' OR '), 500), 22, 'text', textBonus);
|
|
294
|
+
}
|
|
295
|
+
// A name that covers most of the description is a good answer even when one word of the
|
|
296
|
+
// description appears nowhere near it ("aliases written to disk" -> writeDestAlias).
|
|
297
|
+
if (isMultiWord && terms.length >= 3) {
|
|
298
|
+
const most = ftsRows(nameCols([`(${quoted.join(' OR ')})`]), 300).filter(s => {
|
|
299
|
+
const words = splitIdentifier(s.name);
|
|
300
|
+
return terms.filter(t => words.some(w => wordMatches(w, t))).length >= 2;
|
|
301
|
+
});
|
|
302
|
+
add(most, 26, 'text', textBonus);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3b. Bodies: a description often names what a function *uses* rather than what it is called
|
|
308
|
+
// ("where is the csrf token verified" -> a method whose body compares tokens). Find the
|
|
309
|
+
// functions whose code mentions most of the query's words.
|
|
310
|
+
// Skipped when some name already says it all (`debounce events` -> debounceEvent): the scan of
|
|
311
|
+
// identifier occurrences costs about 100 ms on a 15k-file repository.
|
|
312
|
+
let named = false;
|
|
313
|
+
for (const h of found.values()) if (h.match === 'words' || h.match === 'exact') { named = true; break; }
|
|
314
|
+
if (isMultiWord && terms.length >= 2 && !named) {
|
|
315
|
+
for (const hit of bodyMatches(db, terms.slice(0, 8))) {
|
|
316
|
+
if (!accept(hit.sym)) continue;
|
|
317
|
+
const prev = found.get(hit.sym.id);
|
|
318
|
+
// Named or documented like the description *and* doing it in the body: the best kind of answer.
|
|
319
|
+
if (prev) prev.score = Math.max(prev.score + hit.coverage * 20, hit.score);
|
|
320
|
+
else found.set(hit.sym.id, { sym: hit.sym, score: hit.score, match: 'body', refs: 0, unique: false });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 4. Substring anywhere in the name.
|
|
325
|
+
if (!isMultiWord && found.size < limit * 2 && nameLc.length >= 3) {
|
|
326
|
+
add(
|
|
327
|
+
db.all<SymbolRow>(
|
|
328
|
+
`${select} WHERE s.name_lc LIKE ? ESCAPE '\\' ORDER BY length(s.name_lc) LIMIT 200`,
|
|
329
|
+
'%' + likeEscape(nameLc) + '%'
|
|
330
|
+
),
|
|
331
|
+
52, 'substring',
|
|
332
|
+
s => -Math.min(10, (s.name.length - name.length) * 0.3)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 5. Fuzzy: abbreviations ("gcn" -> getContextNode) and loose subsequences.
|
|
337
|
+
let best = 0;
|
|
338
|
+
for (const h of found.values()) if (h.score > best) best = h.score;
|
|
339
|
+
if (!isMultiWord && (found.size < limit || best < 55) && nameLc.length >= 2 && nameLc.length <= 40 && /^[a-z0-9_$]+$/.test(nameLc)) {
|
|
340
|
+
const pattern = '%' + nameLc.split('').map(likeEscape).join('%') + '%';
|
|
341
|
+
const rows = db.all<SymbolRow>(
|
|
342
|
+
`${select} WHERE s.name_lc LIKE ? ESCAPE '\\' AND length(s.name_lc) <= ? ORDER BY length(s.name_lc) LIMIT 300`,
|
|
343
|
+
pattern, Math.max(12, nameLc.length * 6)
|
|
344
|
+
);
|
|
345
|
+
add(rows, 30, 'fuzzy', s => {
|
|
346
|
+
const sw = subwordMatch(name, s.name);
|
|
347
|
+
// Abbreviations of the name's words beat arbitrary subsequences; tighter matches beat looser ones.
|
|
348
|
+
return (sw > 0 ? 16 + sw * 10 : 0) - Math.min(14, (s.name.length - name.length) * 0.5);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 6. Typos: edit distance against names that share the first two characters.
|
|
353
|
+
best = 0;
|
|
354
|
+
for (const h of found.values()) if (h.score > best) best = h.score;
|
|
355
|
+
let nameHit = false;
|
|
356
|
+
for (const h of found.values()) if (h.match === 'exact' || h.match === 'prefix') { nameHit = true; break; }
|
|
357
|
+
if (!isMultiWord && !nameHit && nameLc.length >= 4 && nameLc.length <= 40) {
|
|
358
|
+
const bound = nameLc.length >= 9 ? 3 : nameLc.length >= 6 ? 2 : 1;
|
|
359
|
+
const prefix = nameLc.slice(0, 2);
|
|
360
|
+
const names = db.all<{ name_lc: string }>(
|
|
361
|
+
`SELECT DISTINCT s.name_lc AS name_lc FROM symbols s
|
|
362
|
+
WHERE s.name_lc >= ? AND s.name_lc < ? AND length(s.name_lc) BETWEEN ? AND ? LIMIT 20000`,
|
|
363
|
+
prefix, prefix + '\uffff', nameLc.length - bound, nameLc.length + bound
|
|
364
|
+
);
|
|
365
|
+
const close: Array<[string, number]> = [];
|
|
366
|
+
for (const row of names) {
|
|
367
|
+
const d = editDistance(nameLc, row.name_lc, bound);
|
|
368
|
+
if (d <= bound) close.push([row.name_lc, d]);
|
|
369
|
+
}
|
|
370
|
+
close.sort((a, b) => a[1] - b[1]);
|
|
371
|
+
for (const [candidate, d] of close.slice(0, 12)) {
|
|
372
|
+
add(db.all<SymbolRow>(`${select} WHERE s.name_lc = ? LIMIT 40`, candidate), 84 - d * 9, 'typo');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// --- final ranking --------------------------------------------------------
|
|
377
|
+
const hits = [...found.values()];
|
|
378
|
+
const refCache = new Map<string, number>();
|
|
379
|
+
const defCache = new Map<string, number>();
|
|
380
|
+
for (const h of hits) {
|
|
381
|
+
const s = h.sym;
|
|
382
|
+
let refs = refCache.get(s.name);
|
|
383
|
+
if (refs === undefined) {
|
|
384
|
+
refs = db.refFileCount(s.name);
|
|
385
|
+
refCache.set(s.name, refs);
|
|
386
|
+
}
|
|
387
|
+
h.refs = refs;
|
|
388
|
+
let defs = defCache.get(s.name);
|
|
389
|
+
if (defs === undefined) {
|
|
390
|
+
defs = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM symbols WHERE name = ?`, s.name)?.n ?? 1;
|
|
391
|
+
defCache.set(s.name, defs);
|
|
392
|
+
}
|
|
393
|
+
h.unique = defs === 1;
|
|
394
|
+
h.score += KIND_WEIGHT[s.kind] ?? 0;
|
|
395
|
+
if (s.exported) h.score += 2;
|
|
396
|
+
h.score += Math.min(8, Math.log2(1 + refs) * 1.4);
|
|
397
|
+
// Tests and examples mention every concept; for free-text queries they must not bury the implementation.
|
|
398
|
+
if (!wantTests && isTestPath(s.path)) h.score -= h.match === 'text' || h.match === 'words' || h.match === 'body' ? 16 : 9;
|
|
399
|
+
if (isVendoredPath(s.path)) h.score -= 6;
|
|
400
|
+
if (s.doc) h.score += 1;
|
|
401
|
+
// Nested helpers rank below top-level definitions of the same name.
|
|
402
|
+
h.score -= Math.min(3, (s.qname.split('.').length - 1) * 0.5);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Production code always comes before tests / examples / docs (unless the query asks for those):
|
|
406
|
+
// tests mention every concept by name and would otherwise bury the implementation.
|
|
407
|
+
const testRank = (h: SearchHit): number => (!wantTests && isTestPath(h.sym.path) ? 1 : 0);
|
|
408
|
+
hits.sort(
|
|
409
|
+
(a, b) =>
|
|
410
|
+
testRank(a) - testRank(b) ||
|
|
411
|
+
b.score - a.score ||
|
|
412
|
+
a.sym.path.length - b.sym.path.length ||
|
|
413
|
+
a.sym.path.localeCompare(b.sym.path) ||
|
|
414
|
+
a.sym.start_line - b.sym.start_line
|
|
415
|
+
);
|
|
416
|
+
// Overloads of one function are one answer: show the best and count the rest.
|
|
417
|
+
const folded: SearchHit[] = [];
|
|
418
|
+
const firstOf = new Map<string, SearchHit>();
|
|
419
|
+
for (const h of hits) {
|
|
420
|
+
const key = h.sym.file_id + '\0' + h.sym.qname + '\0' + h.sym.kind;
|
|
421
|
+
const first = firstOf.get(key);
|
|
422
|
+
if (first) {
|
|
423
|
+
first.overloads = (first.overloads ?? 0) + 1;
|
|
424
|
+
} else {
|
|
425
|
+
firstOf.set(key, h);
|
|
426
|
+
folded.push(h);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return { hits: folded.slice(0, limit), truncated: folded.length > limit };
|
|
430
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identifier tokenisation shared by indexing (FTS) and search.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const WORD_RE = /[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+|[^\x00-\x7F]+/g;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Split an identifier or path into lowercase sub-words:
|
|
9
|
+
* `parseAndStore` -> [parse, and, store], `HTTPServer2` -> [http, server, 2],
|
|
10
|
+
* `snake_case_name` -> [snake, case, name].
|
|
11
|
+
*/
|
|
12
|
+
const splitCache = new Map<string, string[]>();
|
|
13
|
+
|
|
14
|
+
export function splitIdentifier(text: string): string[] {
|
|
15
|
+
// Identifiers repeat endlessly during resolution and ranking; short ones are cached.
|
|
16
|
+
const cacheable = text.length <= 48;
|
|
17
|
+
if (cacheable) {
|
|
18
|
+
const hit = splitCache.get(text);
|
|
19
|
+
if (hit) return hit;
|
|
20
|
+
}
|
|
21
|
+
const out: string[] = [];
|
|
22
|
+
const matches = text.match(WORD_RE);
|
|
23
|
+
if (matches) for (const m of matches) out.push(m.toLowerCase());
|
|
24
|
+
if (cacheable) {
|
|
25
|
+
if (splitCache.size > 50_000) splitCache.clear();
|
|
26
|
+
splitCache.set(text, out);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const IDENT_RE = /[A-Za-z_$][A-Za-z0-9_$]*|[^\x00-\x7F]+/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Text for an FTS column: every identifier is emitted as its sub-words plus
|
|
35
|
+
* the whole lowercased identifier, de-duplicated, so both `parse store` and
|
|
36
|
+
* `parseandstore` match.
|
|
37
|
+
*/
|
|
38
|
+
export function ftsWords(text: string): string {
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
const idents = text.match(IDENT_RE);
|
|
41
|
+
if (!idents) return '';
|
|
42
|
+
for (const ident of idents) {
|
|
43
|
+
const parts = splitIdentifier(ident);
|
|
44
|
+
for (const p of parts) seen.add(p);
|
|
45
|
+
if (parts.length > 1) seen.add(parts.join(''));
|
|
46
|
+
if (seen.size > 80) break;
|
|
47
|
+
}
|
|
48
|
+
return [...seen].join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Lowercase query terms for free-text search (stop words removed). */
|
|
52
|
+
const STOP = new Set([
|
|
53
|
+
'the', 'a', 'an', 'of', 'to', 'in', 'for', 'on', 'is', 'are', 'and', 'or', 'that', 'this', 'it',
|
|
54
|
+
'with', 'as', 'by', 'at', 'from', 'how', 'what', 'where', 'which', 'does', 'do', 'be', 'when',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** Irregular verb forms people use in descriptions, mapped to the form code is named with. */
|
|
58
|
+
const IRREGULAR: Record<string, string> = {
|
|
59
|
+
written: 'write', wrote: 'write', sent: 'send', built: 'build', ran: 'run', made: 'make', took: 'take',
|
|
60
|
+
taken: 'take', thrown: 'throw', threw: 'throw', chosen: 'choose', chose: 'choose', held: 'hold', kept: 'keep',
|
|
61
|
+
began: 'begin', begun: 'begin', broken: 'break', broke: 'break', drawn: 'draw', drew: 'draw', hidden: 'hide',
|
|
62
|
+
shown: 'show', read: 'read', sought: 'seek', caught: 'catch', bought: 'buy', paid: 'pay', said: 'say',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function queryTerms(query: string): string[] {
|
|
66
|
+
const terms: string[] = [];
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
const multi = /\s/.test(query.trim());
|
|
69
|
+
for (const raw of query.split(/[\s,;:(){}\[\]<>"'`=+*/\\|&!?#@^~%-]+/)) {
|
|
70
|
+
if (!raw) continue;
|
|
71
|
+
for (let w of splitIdentifier(raw)) {
|
|
72
|
+
// Only in free text: `sent` inside an identifier (`bytesSent`) is a name, not a tense.
|
|
73
|
+
if (multi && raw.toLowerCase() === w && IRREGULAR[w]) w = IRREGULAR[w]!;
|
|
74
|
+
if (w.length < 2 || STOP.has(w) || seen.has(w)) continue;
|
|
75
|
+
seen.add(w);
|
|
76
|
+
terms.push(w);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return terms;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True if `needle` is a subsequence of `hay` (both lowercase). */
|
|
83
|
+
export function isSubsequence(needle: string, hay: string): boolean {
|
|
84
|
+
let i = 0;
|
|
85
|
+
for (let j = 0; j < hay.length && i < needle.length; j++) {
|
|
86
|
+
if (hay.charCodeAt(j) === needle.charCodeAt(i)) i++;
|
|
87
|
+
}
|
|
88
|
+
return i === needle.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Match a query against the sub-words of a name the way editors do
|
|
93
|
+
* ("gcn" / "getCtxNode" -> getContextNode). Returns a 0..1 quality or 0.
|
|
94
|
+
*/
|
|
95
|
+
export function subwordMatch(query: string, name: string): number {
|
|
96
|
+
const words = splitIdentifier(name);
|
|
97
|
+
if (words.length === 0) return 0;
|
|
98
|
+
const q = query.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
99
|
+
if (!q) return 0;
|
|
100
|
+
// Greedy: consume query chars as prefixes of successive words.
|
|
101
|
+
let qi = 0;
|
|
102
|
+
let matchedWords = 0;
|
|
103
|
+
for (const w of words) {
|
|
104
|
+
if (qi >= q.length) break;
|
|
105
|
+
let k = 0;
|
|
106
|
+
while (k < w.length && qi + k < q.length && w.charCodeAt(k) === q.charCodeAt(qi + k)) k++;
|
|
107
|
+
if (k > 0) {
|
|
108
|
+
qi += k;
|
|
109
|
+
matchedWords++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (qi < q.length) return 0;
|
|
113
|
+
return matchedWords / words.length;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Very light stemmer for matching query words against identifier words
|
|
118
|
+
* (dependency ~ dependencies, parsing ~ parse, resolved ~ resolve). Not
|
|
119
|
+
* linguistically exact; it only has to agree with itself.
|
|
120
|
+
*/
|
|
121
|
+
export function stem(word: string): string {
|
|
122
|
+
let w = word.toLowerCase();
|
|
123
|
+
if (w.length <= 3) return w;
|
|
124
|
+
if (w.endsWith('ies') && w.length > 4) return w.slice(0, -3) + 'i';
|
|
125
|
+
if (w.endsWith('sses')) return w.slice(0, -2);
|
|
126
|
+
if (w.endsWith('s') && !w.endsWith('ss') && !w.endsWith('us') && !w.endsWith('is')) w = w.slice(0, -1);
|
|
127
|
+
if (w.endsWith('ing') && w.length > 5) w = w.slice(0, -3);
|
|
128
|
+
else if (w.endsWith('ed') && w.length > 4) w = w.slice(0, -2);
|
|
129
|
+
else if (w.endsWith('er') && w.length > 5) w = w.slice(0, -2);
|
|
130
|
+
if (w.endsWith('e') && w.length > 4) w = w.slice(0, -1);
|
|
131
|
+
// dependency / dependenc(ies): unify the y / i variants
|
|
132
|
+
if (w.endsWith('y') && w.length > 4) w = w.slice(0, -1) + 'i';
|
|
133
|
+
return w;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** True if a name word matches a query term, allowing prefixes and inflections. */
|
|
137
|
+
export function wordMatches(nameWord: string, term: string): boolean {
|
|
138
|
+
if (nameWord.startsWith(term)) return true;
|
|
139
|
+
const a = stem(nameWord);
|
|
140
|
+
const b = stem(term);
|
|
141
|
+
return a === b || (b.length >= 4 && a.startsWith(b));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Initials of an identifier's sub-words: getContextNode -> gcn. Empty for single-word names. */
|
|
145
|
+
export function initials(name: string): string {
|
|
146
|
+
const words = splitIdentifier(name);
|
|
147
|
+
if (words.length < 2) return '';
|
|
148
|
+
let out = '';
|
|
149
|
+
for (const w of words) out += w[0];
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Damerau-Levenshtein distance with an upper bound (returns bound + 1 when exceeded). */
|
|
154
|
+
export function editDistance(a: string, b: string, bound: number): number {
|
|
155
|
+
const n = a.length;
|
|
156
|
+
const m = b.length;
|
|
157
|
+
if (Math.abs(n - m) > bound) return bound + 1;
|
|
158
|
+
let prev2: number[] = [];
|
|
159
|
+
let prev: number[] = new Array(m + 1);
|
|
160
|
+
let cur: number[] = new Array(m + 1);
|
|
161
|
+
for (let j = 0; j <= m; j++) prev[j] = j;
|
|
162
|
+
for (let i = 1; i <= n; i++) {
|
|
163
|
+
cur[0] = i;
|
|
164
|
+
let rowMin = cur[0]!;
|
|
165
|
+
for (let j = 1; j <= m; j++) {
|
|
166
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
167
|
+
let v = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + cost);
|
|
168
|
+
if (
|
|
169
|
+
i > 1 && j > 1 &&
|
|
170
|
+
a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && a.charCodeAt(i - 2) === b.charCodeAt(j - 1)
|
|
171
|
+
) {
|
|
172
|
+
v = Math.min(v, prev2[j - 2]! + 1);
|
|
173
|
+
}
|
|
174
|
+
cur[j] = v;
|
|
175
|
+
if (v < rowMin) rowMin = v;
|
|
176
|
+
}
|
|
177
|
+
if (rowMin > bound) return bound + 1;
|
|
178
|
+
prev2 = prev;
|
|
179
|
+
prev = cur;
|
|
180
|
+
cur = new Array(m + 1);
|
|
181
|
+
}
|
|
182
|
+
return prev[m]!;
|
|
183
|
+
}
|