-
Notifications
You must be signed in to change notification settings - Fork 17
Expand file tree
/
Copy pathcode-chunker.ts
More file actions
338 lines (296 loc) · 12.7 KB
/
Copy pathcode-chunker.ts
File metadata and controls
338 lines (296 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
import * as fs from 'fs';
import * as path from 'path';
import { Language, Parser } from 'web-tree-sitter';
import type { SyntaxNode } from 'web-tree-sitter';
type TokenCounter = (text: string) => Promise<number>;
export interface CodeChunkerOptions {
lang: string;
chunkSize?: number;
tokenCounter?: TokenCounter;
}
export interface CodeChunk {
text: string;
tokenCount: number;
}
export class CodeChunker {
private readonly lang: string;
private readonly chunkSize: number;
private readonly tokenCounter: TokenCounter;
private static treeSitterInitialized = false;
private static parserCache: Map<string, Promise<Parser>> = new Map();
private static hostExports: Set<string> | null = null;
private static moduleDamaged = false;
/**
* Symbols a grammar only calls when it is already aborting. Emscripten
* resolves side-module imports lazily, so an import that never gets called
* costs nothing. Ignoring these two keeps python, html, cpp, ruby, php and
* vue usable — every one of them imports __assert_fail and none of them
* ever calls it. Any other unresolved import is a normal-operation symbol
* (bash calls isalpha on real scripts, yaml calls operator new), and those
* are the ones that bring the whole module down.
*/
private static readonly ABORT_ONLY_SYMBOLS = new Set(['__assert_fail', 'abort']);
private constructor(lang: string, chunkSize: number, tokenCounter: TokenCounter) {
this.lang = lang;
this.chunkSize = chunkSize;
this.tokenCounter = tokenCounter;
}
static async create(options: CodeChunkerOptions): Promise<CodeChunker> {
if (!CodeChunker.treeSitterInitialized && Parser.init) {
try {
await Parser.init();
CodeChunker.treeSitterInitialized = true;
} catch (error) {
console.warn('Failed to initialize tree-sitter parser:', error);
}
}
const chunkSize = options.chunkSize ?? 512;
if (chunkSize <= 0) {
throw new Error('chunkSize must be greater than 0');
}
const tokenCounter = options.tokenCounter ?? (async (text: string) => text.length);
const chunker = new CodeChunker(options.lang, chunkSize, tokenCounter);
return chunker;
}
async chunk(text: string): Promise<CodeChunk[]> {
if (!text.trim()) {
return [];
}
if (CodeChunker.moduleDamaged) {
throw new Error(
'tree-sitter module is damaged by an earlier parse failure; refusing to parse'
);
}
const parser = await CodeChunker.getParser(this.lang);
const source = Buffer.from(text, 'utf-8').toString();
// A grammar that traps inside parse unwinds out of WASM without letting
// tree-sitter clean up, and the damage is cumulative: after enough of
// them every later parse fails with "memory access out of bounds", on
// files and languages that are perfectly fine. One clear error beats
// thousands of misleading ones, so stop using the module.
let tree;
try {
tree = parser.parse(source);
} catch (error) {
CodeChunker.moduleDamaged = true;
throw error;
}
if (!tree) {
throw new Error('Failed to parse code');
}
// The syntax tree lives in the tree-sitter WASM heap, which the JS
// garbage collector cannot reach. Without this delete the heap grows by
// the size of every tree we ever parse, until an allocation fails with
// "memory access out of bounds" on some unrelated file.
try {
const chunks: CodeChunk[] = [];
await this.recursiveChunk(tree.rootNode, source, chunks);
return this.mergeChunks(chunks);
} finally {
tree.delete();
}
}
private static async getParser(lang: string): Promise<Parser> {
const formattedLang = lang.toLowerCase().replace(/-/g, '_');
const cached = this.parserCache.get(formattedLang);
if (cached) {
return cached;
}
const parserPromise = (async () => {
if (!CodeChunker.treeSitterInitialized && Parser.init) {
try {
await Parser.init();
CodeChunker.treeSitterInitialized = true;
} catch (error) {
console.warn('Failed to initialize tree-sitter parser:', error);
}
}
const wasmPath = CodeChunker.resolveWasmPath(formattedLang);
const wasmBuffer = fs.readFileSync(wasmPath);
CodeChunker.assertGrammarLinks(formattedLang, wasmBuffer);
const language = await Language.load(wasmBuffer);
const parser = new Parser();
parser.setLanguage(language);
return parser;
})();
this.parserCache.set(formattedLang, parserPromise);
return parserPromise;
}
/**
* Refuse a grammar whose imports the tree-sitter runtime cannot satisfy.
*
* The grammars ship separately from web-tree-sitter, so a grammar built
* against a different runtime can import a C symbol this runtime does not
* export. Emscripten binds side-module imports lazily, so the mismatch
* stays invisible until the grammar calls the symbol mid-parse — then it
* throws out of WASM and damages the shared module for every language.
* Checking the import table up front turns that into a clean load failure,
* and chunkCode falls back to token chunking as it does for any other
* unsupported language.
*/
private static assertGrammarLinks(formattedLang: string, wasmBuffer: Buffer): void {
if (!CodeChunker.hostExports) {
const hostWasm = path.join(path.dirname(require.resolve('web-tree-sitter')), 'tree-sitter.wasm');
CodeChunker.hostExports = CodeChunker.readWasmTables(fs.readFileSync(hostWasm)).exports;
}
// Emscripten resolves an import from the host's exports first, then
// from the side module's own exports.
const grammar = CodeChunker.readWasmTables(wasmBuffer);
const unresolved = grammar.functionImports.filter(name =>
!CodeChunker.hostExports!.has(name) &&
!grammar.exports.has(name) &&
!CodeChunker.ABORT_ONLY_SYMBOLS.has(name)
);
if (unresolved.length > 0) {
throw new Error(
`Tree-sitter grammar "${formattedLang}" is incompatible with the installed ` +
`web-tree-sitter runtime: unresolved imports ${unresolved.join(', ')}.`
);
}
}
/**
* Read a module's function imports and its export names straight out of the
* WASM binary.
*
* WebAssembly.Module.imports() would be shorter, but building a
* WebAssembly.Module compiles the whole binary, and doing that on top of
* the compile Language.load already does exhausts V8's compiler zone once a
* process loads a dozen grammars. Walking the two sections costs nothing.
*/
private static readWasmTables(buffer: Buffer): { functionImports: string[]; exports: Set<string> } {
const functionImports: string[] = [];
const exports = new Set<string>();
let offset = 8; // magic number and version
const readVarUint = (): number => {
let result = 0;
let shift = 0;
let byte: number;
do {
byte = buffer[offset++];
result |= (byte & 0x7f) << shift;
shift += 7;
} while (byte & 0x80);
return result >>> 0;
};
const readName = (): string => {
const length = readVarUint();
const name = buffer.toString('utf8', offset, offset + length);
offset += length;
return name;
};
const skipLimits = (): void => {
const flags = readVarUint();
readVarUint(); // minimum
if (flags & 0x01) readVarUint(); // maximum
};
while (offset < buffer.length) {
const sectionId = readVarUint();
const sectionSize = readVarUint();
const sectionEnd = offset + sectionSize;
if (sectionId === 2) { // import section
const count = readVarUint();
for (let i = 0; i < count; i++) {
readName(); // module
const field = readName();
const kind = buffer[offset++];
switch (kind) {
case 0x00: // function
readVarUint(); // type index
functionImports.push(field);
break;
case 0x01: // table
offset++; // element type
skipLimits();
break;
case 0x02: // memory
skipLimits();
break;
default: // global: value type + mutability
offset += 2;
break;
}
}
} else if (sectionId === 7) { // export section
const count = readVarUint();
for (let i = 0; i < count; i++) {
exports.add(readName());
offset++; // kind
readVarUint(); // index
}
}
offset = sectionEnd;
}
return { functionImports, exports };
}
private static resolveWasmPath(formattedLang: string): string {
const nodeModulesPath = CodeChunker.findNearestNodeModules(__dirname);
if (!nodeModulesPath) {
throw new Error('node_modules directory not found.');
}
const wasmPath = path.join(nodeModulesPath, `tree-sitter-wasms/out/tree-sitter-${formattedLang}.wasm`);
if (!fs.existsSync(wasmPath)) {
throw new Error(`Tree-sitter WASM file for language "${formattedLang}" not found at ${wasmPath}.`);
}
return wasmPath;
}
private static findNearestNodeModules(startDir: string): string | null {
let dir = path.resolve(startDir);
while (true) {
const candidate = path.join(dir, 'node_modules');
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
private async recursiveChunk(node: SyntaxNode, source: string, chunks: CodeChunk[]): Promise<void> {
const nodeText = source.substring(node.startIndex, node.endIndex);
const tokenCount = await this.tokenCounter(nodeText);
const children = (node.children || []).filter((child): child is SyntaxNode => Boolean(child));
if (tokenCount <= this.chunkSize || children.length === 0) {
if (nodeText.trim()) {
chunks.push({ text: nodeText, tokenCount });
}
return;
}
const beforeCount = chunks.length;
for (const child of children) {
await this.recursiveChunk(child, source, chunks);
}
if (chunks.length === beforeCount && nodeText.trim()) {
chunks.push({ text: nodeText, tokenCount });
}
}
private mergeChunks(chunks: CodeChunk[]): CodeChunk[] {
const merged: CodeChunk[] = [];
let currentText = '';
let currentTokens = 0;
const separatorTokens = 1; // Account for the '\n' separator between merged chunks
for (const chunk of chunks) {
if (!chunk.text.trim()) {
continue;
}
const nextTokens = currentTokens + separatorTokens + chunk.tokenCount;
if (currentTokens === 0) {
currentText = chunk.text;
currentTokens = chunk.tokenCount;
continue;
}
if (nextTokens <= this.chunkSize) {
currentText = `${currentText}\n${chunk.text}`;
currentTokens = nextTokens;
continue;
}
merged.push({ text: currentText, tokenCount: currentTokens });
currentText = chunk.text;
currentTokens = chunk.tokenCount;
}
if (currentTokens > 0) {
merged.push({ text: currentText, tokenCount: currentTokens });
}
return merged;
}
}