pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
278 lines (277 loc) • 9.76 kB
JavaScript
/**
* Tree-sitter Tree Cache with Incremental Parsing Support
*
* Caches parsed ASTs and enables incremental updates for large files.
* This provides 10-100× speedup on edits to large files (>1000 lines).
*/
import * as crypto from "node:crypto";
import * as fs from "node:fs";
import { normalizeFilePath } from "./path-utils.js";
export class TreeCache {
cache = new Map();
maxSize;
debug;
constructor(maxSize = 50, debug = false) {
this.maxSize = maxSize;
this.debug = debug
? (msg) => console.error(`[tree-cache] ${msg}`)
: () => { };
}
/**
* Free a tree-sitter Tree's WASM-heap allocation.
*
* web-tree-sitter Trees live in the WASM heap; JS GC reclaims only the wrapper,
* so the underlying memory leaks unless `tree.delete()` is called explicitly
* (no FinalizationRegistry auto-free in 0.25). Guarded — a tree may already be
* deleted, or `delete()` may throw on a corrupt/aborted runtime. Safe because
* every consumer uses a returned tree transiently (parse → extract → discard
* within one call); the eviction target is always the OLDEST entry, never the
* just-parsed tree still in a caller's hand (#417).
*/
// biome-ignore lint/suspicious/noExplicitAny: web-tree-sitter Tree
freeTree(tree) {
try {
if (tree && typeof tree.delete === "function")
tree.delete();
}
catch {
// best-effort — a dead wasm runtime or double-delete must not throw
}
}
/** Remove a cache entry AND free its WASM tree. */
removeEntry(key) {
const cached = this.cache.get(key);
if (cached)
this.freeTree(cached.tree);
this.cache.delete(key);
}
/**
* Generate hash for file content
*/
hashContent(content) {
return crypto
.createHash("sha256")
.update(content)
.digest("hex")
.slice(0, 16);
}
/**
* Get cache key for a file
*/
getCacheKey(filePath, languageId) {
return `${languageId}:${normalizeFilePath(filePath)}`;
}
/**
* Check if tree is cached and valid
*/
get(filePath, content, languageId) {
const key = this.getCacheKey(filePath, languageId);
const cached = this.cache.get(key);
if (!cached) {
this.debug(`Cache miss: ${filePath}`);
return null;
}
// (No language-mismatch check needed: the cache key is prefixed with
// languageId, so a key hit already implies the language matches.)
// Check content hash
const contentHash = this.hashContent(content);
if (cached.contentHash !== contentHash) {
this.debug(`Content changed: ${filePath} (${cached.lineCount} → ${content.split("\n").length} lines)`);
// Keep old tree for potential incremental update, but mark as stale
return null;
}
// Check if file was modified on disk (mtime changed)
try {
const stats = fs.statSync(filePath);
if (stats.mtimeMs !== cached.lastModified) {
this.debug(`File modified on disk: ${filePath}`);
this.removeEntry(key);
return null;
}
}
catch {
// File might be deleted, invalidate cache
this.removeEntry(key);
return null;
}
this.debug(`Cache hit: ${filePath} (${cached.lineCount} lines)`);
return cached.tree;
}
/**
* Store parsed tree in cache
*/
set(filePath, content, languageId, tree) {
const key = this.getCacheKey(filePath, languageId);
// Free the tree we're about to replace at this key (re-parse of the same
// file) so it doesn't leak its WASM heap.
if (this.cache.has(key)) {
this.removeEntry(key);
}
else if (this.cache.size >= this.maxSize) {
// Evict + free the oldest entry when the cache is full.
const firstKey = this.cache.keys().next().value;
if (firstKey) {
this.removeEntry(firstKey);
this.debug(`Evicted: ${firstKey}`);
}
}
let mtime = 0;
try {
mtime = fs.statSync(filePath).mtimeMs;
}
catch {
// File deleted between parse and cache — cache with mtime=0;
// next get() will miss on mtime check and re-parse
}
this.cache.set(key, {
tree,
contentHash: this.hashContent(content),
languageId,
fileSize: content.length,
lineCount: content.split("\n").length,
lastModified: mtime,
});
this.debug(`Cached: ${filePath} (${content.split("\n").length} lines)`);
}
/**
* Calculate the diff between old and new content
* Returns edit information for incremental parsing
*/
calculateEdit(oldContent, newContent) {
// Find the first difference
let startIndex = 0;
while (startIndex < oldContent.length &&
startIndex < newContent.length &&
oldContent[startIndex] === newContent[startIndex]) {
startIndex++;
}
// Find the last difference (working backwards)
let oldEndIndex = oldContent.length;
let newEndIndex = newContent.length;
while (oldEndIndex > startIndex &&
newEndIndex > startIndex &&
oldContent[oldEndIndex - 1] === newContent[newEndIndex - 1]) {
oldEndIndex--;
newEndIndex--;
}
// No change detected
if (startIndex === oldContent.length && startIndex === newContent.length) {
return null;
}
// Calculate positions
const startPosition = this.indexToPosition(oldContent, startIndex);
const oldEndPosition = this.indexToPosition(oldContent, oldEndIndex);
const newEndPosition = this.indexToPosition(newContent, newEndIndex);
return {
startIndex,
oldEndIndex,
newEndIndex,
startPosition,
oldEndPosition,
newEndPosition,
};
}
/**
* Convert byte index to row/column position
*/
indexToPosition(content, index) {
const lines = content.slice(0, index).split("\n");
return {
row: lines.length - 1,
column: lines[lines.length - 1].length,
};
}
/**
* Attempt incremental update using tree.edit()
* Returns updated tree or null if incremental update failed
*/
async incrementalUpdate(filePath, oldContent, newContent, languageId, parser) {
const key = this.getCacheKey(filePath, languageId);
const cached = this.cache.get(key);
if (!cached) {
this.debug(`No cached tree for incremental update: ${filePath}`);
return null;
}
// Only use incremental for large files (>100 lines)
const lineCount = oldContent.split("\n").length;
if (lineCount < 100) {
this.debug(`File too small for incremental: ${filePath} (${lineCount} lines)`);
return null;
}
// Calculate edit
const edit = this.calculateEdit(oldContent, newContent);
if (!edit) {
this.debug(`No edit detected for: ${filePath}`);
return null;
}
this.debug(`Incremental update: ${filePath} (lines ${edit.startPosition.row}-${edit.oldEndPosition.row})`);
try {
// Apply edit to tree
cached.tree.edit({
startIndex: edit.startIndex,
oldEndIndex: edit.oldEndIndex,
newEndIndex: edit.newEndIndex,
startPosition: edit.startPosition,
oldEndPosition: edit.oldEndPosition,
newEndPosition: edit.newEndPosition,
});
// Re-parse only changed region
const newTree = parser.parse(newContent, cached.tree);
// Update cache
this.set(filePath, newContent, languageId, newTree);
this.debug(`Incremental update successful: ${filePath}`);
return newTree;
}
catch (err) {
this.debug(`Incremental update failed: ${err}`);
return null;
}
}
/**
* Clear cache for a specific file
*/
invalidate(filePath, languageId) {
if (languageId) {
const key = this.getCacheKey(filePath, languageId);
this.removeEntry(key);
this.debug(`Invalidated: ${key}`);
}
else {
// Invalidate all entries for this file
for (const [key, value] of this.cache.entries()) {
if (key.includes(filePath)) {
this.freeTree(value.tree);
this.cache.delete(key);
this.debug(`Invalidated: ${key}`);
}
}
}
}
/**
* Clear entire cache
*/
clear() {
for (const entry of this.cache.values()) {
this.freeTree(entry.tree);
}
this.cache.clear();
this.debug("Cache cleared");
}
/**
* Get cache statistics
*/
getStats() {
let totalLines = 0;
let totalBytes = 0;
for (const entry of this.cache.values()) {
totalLines += entry.lineCount;
totalBytes += entry.fileSize;
}
return {
size: this.cache.size,
maxSize: this.maxSize,
totalLines,
totalBytes,
};
}
}