|
| 1 | +import { spawn } from "child_process" |
| 2 | +import { dirname, resolve as pathResolve, relative } from "path" |
| 3 | + |
| 4 | +// Simplified tree structure - files represented by true, directories by nested objects |
| 5 | +export type SimpleTreeNode = { |
| 6 | + [key: string]: true | SimpleTreeNode |
| 7 | +} |
| 8 | + |
| 9 | +/** |
| 10 | + * Ripgrep result cache class |
| 11 | + * Provides file tree caching functionality with incremental updates |
| 12 | + */ |
| 13 | +export class RipgrepResultCache { |
| 14 | + private rgPath: string |
| 15 | + private _targetPath: string |
| 16 | + private cachedTree: SimpleTreeNode | null = null |
| 17 | + private invalidatedDirectories = new Set<string>() |
| 18 | + private rgArgs: string[] |
| 19 | + private currentBuildPromise: Promise<SimpleTreeNode> | null = null |
| 20 | + private fileLimit: number |
| 21 | + |
| 22 | + constructor(rgPath: string, targetPath: string, rgArgs: string[] = [], fileLimit: number = 5000) { |
| 23 | + this.rgPath = rgPath |
| 24 | + this._targetPath = pathResolve(targetPath) |
| 25 | + this.fileLimit = fileLimit |
| 26 | + this.rgArgs = rgArgs.length > 0 ? rgArgs : ["--files"] |
| 27 | + } |
| 28 | + |
| 29 | + get targetPath(): string { |
| 30 | + return this._targetPath |
| 31 | + } |
| 32 | + |
| 33 | + /** |
| 34 | + * Asynchronously get file tree |
| 35 | + * - If there's valid cache and no invalid directories, return cache |
| 36 | + * - If currently building, wait for current build result |
| 37 | + * - Otherwise trigger new build |
| 38 | + */ |
| 39 | + async getTree(): Promise<SimpleTreeNode> { |
| 40 | + // If there's valid cache, return directly |
| 41 | + if (this.cachedTree && this.invalidatedDirectories.size === 0) { |
| 42 | + return this.cachedTree |
| 43 | + } |
| 44 | + |
| 45 | + // If already building, wait for current build result |
| 46 | + if (this.currentBuildPromise) { |
| 47 | + return this.currentBuildPromise |
| 48 | + } |
| 49 | + |
| 50 | + // Start new build |
| 51 | + try { |
| 52 | + this.currentBuildPromise = this.buildTree() |
| 53 | + const result = await this.currentBuildPromise |
| 54 | + return result |
| 55 | + } finally { |
| 56 | + // Clear Promise cache after build completion |
| 57 | + this.currentBuildPromise = null |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + /** |
| 62 | + * Internal method: build or update tree |
| 63 | + */ |
| 64 | + private async buildTree(): Promise<SimpleTreeNode> { |
| 65 | + try { |
| 66 | + if (this.cachedTree && this.invalidatedDirectories.size > 0) { |
| 67 | + // Has cache but has invalid directories, perform incremental update |
| 68 | + await this.updateInvalidatedDirectories() |
| 69 | + } else { |
| 70 | + // No cache, complete rebuild |
| 71 | + this.cachedTree = await this.buildTreeStreaming() |
| 72 | + } |
| 73 | + |
| 74 | + // Clear invalid directory markers |
| 75 | + this.invalidatedDirectories.clear() |
| 76 | + return this.cachedTree |
| 77 | + } catch (error) { |
| 78 | + // Clear cache state on error |
| 79 | + this.cachedTree = null |
| 80 | + this.invalidatedDirectories.clear() |
| 81 | + throw error |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + /** |
| 86 | + * Called when file is added |
| 87 | + * Mark parent directory as invalid and remove corresponding subtree from tree |
| 88 | + */ |
| 89 | + fileAdded(filePath: string): void { |
| 90 | + this.fileAddedOrRemoved(filePath) |
| 91 | + } |
| 92 | + |
| 93 | + /** |
| 94 | + * Called when file is removed |
| 95 | + * Mark parent directory as invalid and remove corresponding subtree from tree |
| 96 | + */ |
| 97 | + fileRemoved(filePath: string): void { |
| 98 | + this.fileAddedOrRemoved(filePath) |
| 99 | + } |
| 100 | + |
| 101 | + private fileAddedOrRemoved(filePath: string): void { |
| 102 | + const relativePath = relative(this._targetPath, pathResolve(this._targetPath, filePath)) |
| 103 | + const parentDir = dirname(relativePath) |
| 104 | + |
| 105 | + if (parentDir !== "." && parentDir !== "") { |
| 106 | + this.invalidateDirectory(parentDir) |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + /** |
| 111 | + * Mark directory as invalid |
| 112 | + * Check containment relationship with existing invalid directories to avoid duplicate marking |
| 113 | + */ |
| 114 | + private invalidateDirectory(dirPath: string): void { |
| 115 | + if (!this.cachedTree) { |
| 116 | + return |
| 117 | + } |
| 118 | + |
| 119 | + const normalizedPath = dirPath.replace(/\\/g, "/") |
| 120 | + |
| 121 | + // Check if already contained by larger scope invalid directory |
| 122 | + for (const invalidDir of this.invalidatedDirectories) { |
| 123 | + if (normalizedPath.startsWith(invalidDir + "/") || normalizedPath === invalidDir) { |
| 124 | + // Current directory already contained in invalid directory, no need to mark again |
| 125 | + return |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + // Remove existing invalid directories contained by current directory |
| 130 | + const toRemove: string[] = [] |
| 131 | + for (const invalidDir of this.invalidatedDirectories) { |
| 132 | + if (invalidDir.startsWith(normalizedPath + "/")) { |
| 133 | + toRemove.push(invalidDir) |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + // Remove contained invalid directories |
| 138 | + for (const dir of toRemove) { |
| 139 | + this.invalidatedDirectories.delete(dir) |
| 140 | + } |
| 141 | + |
| 142 | + // Mark current directory as invalid |
| 143 | + this.invalidatedDirectories.add(normalizedPath) |
| 144 | + |
| 145 | + // Remove corresponding subtree from cache tree |
| 146 | + this.removeDirectoryFromTree(normalizedPath) |
| 147 | + } |
| 148 | + |
| 149 | + /** |
| 150 | + * Remove specified directory subtree from simplified tree |
| 151 | + */ |
| 152 | + private removeDirectoryFromTree(dirPath: string): void { |
| 153 | + if (!this.cachedTree) { |
| 154 | + return |
| 155 | + } |
| 156 | + |
| 157 | + const pathParts = dirPath.split("/").filter(Boolean) |
| 158 | + this.removeNodeByPath(this.cachedTree, pathParts, 0) |
| 159 | + } |
| 160 | + |
| 161 | + /** |
| 162 | + * Recursively remove simplified tree node |
| 163 | + */ |
| 164 | + private removeNodeByPath(tree: SimpleTreeNode, pathParts: string[], depth: number): boolean { |
| 165 | + if (depth >= pathParts.length) { |
| 166 | + return false |
| 167 | + } |
| 168 | + |
| 169 | + const currentPart = pathParts[depth] |
| 170 | + |
| 171 | + if (!(currentPart in tree)) { |
| 172 | + return false |
| 173 | + } |
| 174 | + |
| 175 | + if (depth === pathParts.length - 1) { |
| 176 | + // Found target node, remove it |
| 177 | + delete tree[currentPart] |
| 178 | + return true |
| 179 | + } |
| 180 | + |
| 181 | + // Continue searching in child nodes |
| 182 | + const childNode = tree[currentPart] |
| 183 | + if (childNode !== true && typeof childNode === "object") { |
| 184 | + const removed = this.removeNodeByPath(childNode, pathParts, depth + 1) |
| 185 | + |
| 186 | + // If child node is removed and current node is empty object, remove current node |
| 187 | + if (removed && Object.keys(childNode).length === 0) { |
| 188 | + delete tree[currentPart] |
| 189 | + return true |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + return false |
| 194 | + } |
| 195 | + |
| 196 | + /** |
| 197 | + * Update directories marked as invalid |
| 198 | + * Use ripgrep's multi-path support to update all invalid directories at once |
| 199 | + */ |
| 200 | + private async updateInvalidatedDirectories(): Promise<void> { |
| 201 | + if (this.invalidatedDirectories.size === 0) { |
| 202 | + return |
| 203 | + } |
| 204 | + |
| 205 | + try { |
| 206 | + // Stream build subtrees for all invalid directories (pass directory paths directly) |
| 207 | + const invalidDirectories = Array.from(this.invalidatedDirectories) |
| 208 | + const subtree = await this.buildTreeStreaming(invalidDirectories) |
| 209 | + |
| 210 | + // Merge subtrees into main tree (replace original invalid parts) |
| 211 | + this.mergeInvalidatedSubtrees(subtree) |
| 212 | + } catch (error) { |
| 213 | + console.warn("Error updating invalid directories:", error) |
| 214 | + // If incremental update fails, fallback to complete rebuild |
| 215 | + this.cachedTree = await this.buildTreeStreaming() |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + /** |
| 220 | + * Unified streaming tree building method (simplified version, builds SimpleTreeNode) |
| 221 | + * @param targetPaths Array of target paths to scan, scans entire targetPath when empty |
| 222 | + */ |
| 223 | + private async buildTreeStreaming(targetPaths: string[] = []): Promise<SimpleTreeNode> { |
| 224 | + return new Promise((resolve, reject) => { |
| 225 | + // Build ripgrep arguments |
| 226 | + const args = [...this.rgArgs] |
| 227 | + |
| 228 | + // If target paths specified, use relative paths directly (ripgrep supports multiple paths) |
| 229 | + if (targetPaths.length > 0) { |
| 230 | + args.push(...targetPaths) |
| 231 | + } |
| 232 | + |
| 233 | + const child = spawn(this.rgPath, args, { |
| 234 | + cwd: this._targetPath, |
| 235 | + stdio: ["pipe", "pipe", "pipe"], |
| 236 | + }) |
| 237 | + |
| 238 | + const tree: SimpleTreeNode = {} |
| 239 | + let buffer = "" |
| 240 | + let fileCount = 0 |
| 241 | + |
| 242 | + // Stream add file paths to simplified tree structure |
| 243 | + const addFileToTree = (filePath: string) => { |
| 244 | + // ripgrep output is already relative path, use directly |
| 245 | + const parts = filePath.split("/").filter(Boolean) |
| 246 | + let currentNode: SimpleTreeNode = tree |
| 247 | + |
| 248 | + for (let i = 0; i < parts.length; i++) { |
| 249 | + const part = parts[i] |
| 250 | + const isFile = i === parts.length - 1 // Last part is file |
| 251 | + |
| 252 | + if (isFile) { |
| 253 | + // Files represented by true |
| 254 | + currentNode[part] = true |
| 255 | + fileCount++ |
| 256 | + |
| 257 | + // Check if file limit reached |
| 258 | + if (fileCount >= this.fileLimit) { |
| 259 | + child.kill() |
| 260 | + return true // Indicate limit reached |
| 261 | + } |
| 262 | + } else { |
| 263 | + // Directories represented by nested objects |
| 264 | + if (!currentNode[part] || currentNode[part] === true) { |
| 265 | + currentNode[part] = {} |
| 266 | + } |
| 267 | + currentNode = currentNode[part] as SimpleTreeNode |
| 268 | + } |
| 269 | + } |
| 270 | + return false // Limit not reached |
| 271 | + } |
| 272 | + |
| 273 | + child.stdout.on("data", (data: Buffer) => { |
| 274 | + buffer += data.toString() |
| 275 | + const lines = buffer.split("\n") |
| 276 | + buffer = lines.pop() || "" |
| 277 | + |
| 278 | + for (const line of lines) { |
| 279 | + const trimmedLine = line.trim() |
| 280 | + if (trimmedLine) { |
| 281 | + const limitReached = addFileToTree(trimmedLine) |
| 282 | + if (limitReached) { |
| 283 | + break |
| 284 | + } |
| 285 | + } |
| 286 | + } |
| 287 | + }) |
| 288 | + |
| 289 | + let errorOutput = "" |
| 290 | + |
| 291 | + child.stderr.on("data", (data: Buffer) => { |
| 292 | + errorOutput += data.toString() |
| 293 | + }) |
| 294 | + |
| 295 | + child.on("close", (code: number | null) => { |
| 296 | + // Process final buffer content |
| 297 | + if (buffer.trim() && fileCount < this.fileLimit) { |
| 298 | + addFileToTree(buffer.trim()) |
| 299 | + } |
| 300 | + |
| 301 | + if (errorOutput && Object.keys(tree).length === 0) { |
| 302 | + reject(new Error(`ripgrep process error: ${errorOutput}`)) |
| 303 | + } else { |
| 304 | + resolve(tree) |
| 305 | + } |
| 306 | + }) |
| 307 | + |
| 308 | + child.on("error", (error: Error) => { |
| 309 | + reject(error) |
| 310 | + }) |
| 311 | + }) |
| 312 | + } |
| 313 | + |
| 314 | + /** |
| 315 | + * Merge invalidated subtrees into main tree |
| 316 | + * subtree already contains complete content of all invalid directories, merge directly |
| 317 | + */ |
| 318 | + private mergeInvalidatedSubtrees(subtree: SimpleTreeNode): void { |
| 319 | + if (!this.cachedTree) { |
| 320 | + this.cachedTree = subtree |
| 321 | + return |
| 322 | + } |
| 323 | + |
| 324 | + // Modify original object directly to avoid new object creation overhead |
| 325 | + this.mergeSimpleTreeNodesInPlace(this.cachedTree, subtree) |
| 326 | + } |
| 327 | + |
| 328 | + /** |
| 329 | + * In-place merge two simplified tree nodes (optimized version, reduces object creation) |
| 330 | + */ |
| 331 | + private mergeSimpleTreeNodesInPlace(existing: SimpleTreeNode, newTree: SimpleTreeNode): void { |
| 332 | + for (const [key, value] of Object.entries(newTree)) { |
| 333 | + if (value === true) { |
| 334 | + // New node is file, overwrite directly |
| 335 | + existing[key] = true |
| 336 | + } else { |
| 337 | + // New node is directory |
| 338 | + if (!existing[key] || existing[key] === true) { |
| 339 | + // Original doesn't exist or is file, replace with directory directly |
| 340 | + existing[key] = value |
| 341 | + } else { |
| 342 | + // Original is also directory, merge recursively |
| 343 | + this.mergeSimpleTreeNodesInPlace(existing[key] as SimpleTreeNode, value) |
| 344 | + } |
| 345 | + } |
| 346 | + } |
| 347 | + } |
| 348 | + |
| 349 | + /** |
| 350 | + * Clear cache |
| 351 | + */ |
| 352 | + clearCache(): void { |
| 353 | + this.cachedTree = null |
| 354 | + this.invalidatedDirectories.clear() |
| 355 | + this.currentBuildPromise = null |
| 356 | + } |
| 357 | +} |
0 commit comments