Roo-Code/src/services/code-index/processors/parser.ts
Daniel 61e122dc34
Codebase indexing (Clean history) (#3137)
* feat: apply changes from local main

* fix: add missing types

* feat: deduplicate code blocks coming out of parser

* feat: implement a cache manager to improve cache handling

* refactor: move code index service initialization to extension and remove await from indexing process

* fix: return undefined instead of throwing if no workspace is detected

* feat: allow auto approve if it is active for read tools

* refactor: improve UI of the results and allow opening the ranges directly in the editor

* refactor: use dependency injection to improve performance

* feat: implement result filtering by directory path

* refactor: centralize path normalization logic

* refactor: remove unnecessary barrel file

* refactor: prevent restarting the service if no settings change

* fix: the indexing process should never be awaited

* refactor: cleanup unused method

* refactor: remove batch limits for ollama

* refactor(parser): simplify method signatures and improve chunking logic

- Remove redundant min/max chars parameters
- Add better handling for oversized lines
- Improve chunking logic with segment handling
- Clean up method signatures and parameter ordering

* fix(settings): make select inputs full width in CodeIndexSettings

* refactor: increase max list file limit

* feat(ui): improve codebase search result display formatting

* test: add tests for cache and config managers

* test: create unit tests for parser and scanner

* feat(parser): improve segment hash uniqueness

- Added startCharIndex to segment hash calculation in _chunkTextByLines
- Track character position when splitting oversized lines
- Ensures unique identification of segments from same line

* feat(file-watcher): add error logging and optional ignoreController injection

* fix: allow getting the state if the service is disabled

* fix: set the embedding models when cline provider is initialized

* feat: use zod to validate form

* feat(file-watcher): enhance file watcher for batched deletions and improved vector store interactions

Improve file watcher to handle file deletions in batches and optimize vector store operations.

* feat(CodeIndexSettings): move OpenAI key input to a conditional rendering block

* feat(CodeIndexSettings): update button visibility based on indexing status

* feat(file-watcher): refactor vscode mock and enhance file watcher tests

* fix(CodeIndexManager): do not await startIndexing on configuration changes

* feat(types): add codeIndexOpenAiKey and codeIndexQdrantApiKey to ProviderSettings and IpcMessage

* feat(FileWatcher): enhance file processing with batch operations and new status handling

* fix(webviewMessageHandler): handle errors during CodeIndexManager initialization

* refactor(CodeIndexManager): streamline service creation by consolidating into a single method

* feat(CodeIndex): implement minimum search score configuration and update search methods

* refactor(CodeIndexSettings): replace ApiConfiguration with ProviderSettings and update related methods

* refactor: move contants to centralized file

* refactor(constants): rename CODEBASE_INDEX_SEARCH_MIN_SCORE to SEARCH_MIN_SCORE

* feat(QdrantVectorStore): enhance search functionality with new query structure and indexing

* feat(FileWatcher): implement batch processing and retry logic for upserting points

* fix(CodeIndexSettings): rename setProviderSettingsField to setApiConfigurationField and move model label

* fix(ChatRow): remove limit from search query messages

* refactor(CodebaseSearchResult): remove unused props from component

* feat: implement batch processing for file events in FileWatcher

- Introduced a new mechanism to accumulate file events (create, change, delete) and process them in batches.
- Added debounce functionality to optimize processing frequency.
- Emitted events for batch processing start, progress updates, and completion with detailed summaries.
- Refactored existing processing logic to handle batch deletions and upserts efficiently.
- Enhanced error handling and logging for better traceability during batch operations.

* feat(CodeIndex): implement batch processing and update progress reporting

* fix: define a default url for qdrant

* feat(CodeIndexManager): add initialization check and update startIndexing logic

* feat(CodeIndexSettings): validate Qdrant URL and update settings commitment logic

* feat: refactor progress calculation and update progress bar rendering

* refactor: remove webview provider and related methods

* fix: simplify indexing status update by directly using update values

* feat: integrate .gitignore support into file processing and scanning logic

* fix: update clearCacheFile method to write an empty object instead of deleting the cache file

* Revert this

* Run prettier

* fix: add new dependencies for qdrant client and directory scanner

* feat: add codebase search functionality to localization files

* feat: add localization strings for codebase indexing settings

* feat: integrate CodeIndexSettings into ExperimentalSettings and update settings localization

* refactor: remove console logs from various components for cleaner output

* feat: enhance capabilities section and codebase search tool description

* feat: add code indexing localization for multiple languages

* fix: correct indentation for CodeIndexSettings component in ExperimentalSettings

* refactor: update unit tests to properly test current functionality

* feat: add mock implementation for p-limit and update Jest config

* feat: track file creation, change, and deletion events in accumulatedEvents

* refactor: simplify file watcher tests by removing waitForFileProcessingToFinish and using direct event accumulation

* refactor: mock ContextProxy's getValue method to return current config name in ClineProvider tests

* refactor: mock missing properties required by codebase indexing manager

---------

Co-authored-by: cte <cestreich@gmail.com>
2025-05-23 19:07:19 -06:00

375 lines
11 KiB
TypeScript

import { readFile } from "fs/promises"
import { createHash } from "crypto"
import * as path from "path"
import * as treeSitter from "web-tree-sitter"
import { LanguageParser, loadRequiredLanguageParsers } from "../../tree-sitter/languageParser"
import { ICodeParser, CodeBlock } from "../interfaces"
import { scannerExtensions } from "../shared/supported-extensions"
import { MAX_BLOCK_CHARS, MIN_BLOCK_CHARS, MIN_CHUNK_REMAINDER_CHARS, MAX_CHARS_TOLERANCE_FACTOR } from "../constants"
/**
* Implementation of the code parser interface
*/
export class CodeParser implements ICodeParser {
private loadedParsers: LanguageParser = {}
private pendingLoads: Map<string, Promise<LanguageParser>> = new Map()
// Markdown files are excluded because the current parser logic cannot effectively handle
// potentially large Markdown sections without a tree-sitter-like child node structure for chunking
/**
* Parses a code file into code blocks
* @param filePath Path to the file to parse
* @param options Optional parsing options
* @returns Promise resolving to array of code blocks
*/
async parseFile(
filePath: string,
options?: {
content?: string
fileHash?: string
},
): Promise<CodeBlock[]> {
// Get file extension
const ext = path.extname(filePath).toLowerCase()
// Skip if not a supported language
if (!this.isSupportedLanguage(ext)) {
return []
}
// Get file content
let content: string
let fileHash: string
if (options?.content) {
content = options.content
fileHash = options.fileHash || this.createFileHash(content)
} else {
try {
content = await readFile(filePath, "utf8")
fileHash = this.createFileHash(content)
} catch (error) {
console.error(`Error reading file ${filePath}:`, error)
return []
}
}
// Parse the file
return this.parseContent(filePath, content, fileHash)
}
/**
* Checks if a language is supported
* @param extension File extension
* @returns Boolean indicating if the language is supported
*/
private isSupportedLanguage(extension: string): boolean {
return scannerExtensions.includes(extension)
}
/**
* Creates a hash for a file
* @param content File content
* @returns Hash string
*/
private createFileHash(content: string): string {
return createHash("sha256").update(content).digest("hex")
}
/**
* Parses file content into code blocks
* @param filePath Path to the file
* @param content File content
* @param fileHash File hash
* @returns Array of code blocks
*/
private async parseContent(filePath: string, content: string, fileHash: string): Promise<CodeBlock[]> {
const ext = path.extname(filePath).slice(1).toLowerCase()
const seenSegmentHashes = new Set<string>()
// Check if we already have the parser loaded
if (!this.loadedParsers[ext]) {
const pendingLoad = this.pendingLoads.get(ext)
if (pendingLoad) {
try {
await pendingLoad
} catch (error) {
console.error(`Error in pending parser load for ${filePath}:`, error)
return []
}
} else {
const loadPromise = loadRequiredLanguageParsers([filePath])
this.pendingLoads.set(ext, loadPromise)
try {
const newParsers = await loadPromise
if (newParsers) {
this.loadedParsers = { ...this.loadedParsers, ...newParsers }
}
} catch (error) {
console.error(`Error loading language parser for ${filePath}:`, error)
return []
} finally {
this.pendingLoads.delete(ext)
}
}
}
const language = this.loadedParsers[ext]
if (!language) {
console.warn(`No parser available for file extension: ${ext}`)
return []
}
const tree = language.parser.parse(content)
// We don't need to get the query string from languageQueries since it's already loaded
// in the language object
const captures = language.query.captures(tree.rootNode)
// Check if captures are empty
if (captures.length === 0) {
if (content.length >= MIN_BLOCK_CHARS) {
// Perform fallback chunking if content is large enough
const blocks = this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes)
return blocks
} else {
// Return empty if content is too small for fallback
return []
}
}
const results: CodeBlock[] = []
// Process captures if not empty
const queue: treeSitter.SyntaxNode[] = captures.map((capture: any) => capture.node)
while (queue.length > 0) {
const currentNode = queue.shift()!
// const lineSpan = currentNode.endPosition.row - currentNode.startPosition.row + 1 // Removed as per lint error
// Check if the node meets the minimum character requirement
if (currentNode.text.length >= MIN_BLOCK_CHARS) {
// If it also exceeds the maximum character limit, try to break it down
if (currentNode.text.length > MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR) {
if (currentNode.children.length > 0) {
// If it has children, process them instead
queue.push(...currentNode.children)
} else {
// If it's a leaf node, chunk it (passing MIN_BLOCK_CHARS as per Task 1 Step 5)
// Note: _chunkLeafNodeByLines logic might need further adjustment later
const chunkedBlocks = this._chunkLeafNodeByLines(
currentNode,
filePath,
fileHash,
seenSegmentHashes,
)
results.push(...chunkedBlocks)
}
} else {
// Node meets min chars and is within max chars, create a block
const identifier =
currentNode.childForFieldName("name")?.text ||
currentNode.children.find((c) => c.type === "identifier")?.text ||
null
const type = currentNode.type
const start_line = currentNode.startPosition.row + 1
const end_line = currentNode.endPosition.row + 1
const content = currentNode.text
const segmentHash = createHash("sha256")
.update(`${filePath}-${start_line}-${end_line}-${content}`)
.digest("hex")
if (!seenSegmentHashes.has(segmentHash)) {
seenSegmentHashes.add(segmentHash)
results.push({
file_path: filePath,
identifier,
type,
start_line,
end_line,
content,
segmentHash,
fileHash,
})
}
}
}
// Nodes smaller than MIN_BLOCK_CHARS are ignored
}
return results
}
/**
* Common helper function to chunk text by lines, avoiding tiny remainders.
*/
private _chunkTextByLines(
lines: string[],
filePath: string,
fileHash: string,
chunkType: string,
seenSegmentHashes: Set<string>,
baseStartLine: number = 1, // 1-based start line of the *first* line in the `lines` array
): CodeBlock[] {
const chunks: CodeBlock[] = []
let currentChunkLines: string[] = []
let currentChunkLength = 0
let chunkStartLineIndex = 0 // 0-based index within the `lines` array
const effectiveMaxChars = MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR
const finalizeChunk = (endLineIndex: number) => {
if (currentChunkLength >= MIN_BLOCK_CHARS && currentChunkLines.length > 0) {
const chunkContent = currentChunkLines.join("\n")
const startLine = baseStartLine + chunkStartLineIndex
const endLine = baseStartLine + endLineIndex
const segmentHash = createHash("sha256")
.update(`${filePath}-${startLine}-${endLine}-${chunkContent}`)
.digest("hex")
if (!seenSegmentHashes.has(segmentHash)) {
seenSegmentHashes.add(segmentHash)
chunks.push({
file_path: filePath,
identifier: null,
type: chunkType,
start_line: startLine,
end_line: endLine,
content: chunkContent,
segmentHash,
fileHash,
})
}
}
currentChunkLines = []
currentChunkLength = 0
chunkStartLineIndex = endLineIndex + 1
}
const createSegmentBlock = (segment: string, originalLineNumber: number, startCharIndex: number) => {
const segmentHash = createHash("sha256")
.update(`${filePath}-${originalLineNumber}-${originalLineNumber}-${startCharIndex}-${segment}`)
.digest("hex")
if (!seenSegmentHashes.has(segmentHash)) {
seenSegmentHashes.add(segmentHash)
chunks.push({
file_path: filePath,
identifier: null,
type: `${chunkType}_segment`,
start_line: originalLineNumber,
end_line: originalLineNumber,
content: segment,
segmentHash,
fileHash,
})
}
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineLength = line.length + (i < lines.length - 1 ? 1 : 0) // +1 for newline, except last line
const originalLineNumber = baseStartLine + i
// Handle oversized lines (longer than effectiveMaxChars)
if (lineLength > effectiveMaxChars) {
// Finalize any existing normal chunk before processing the oversized line
if (currentChunkLines.length > 0) {
finalizeChunk(i - 1)
}
// Split the oversized line into segments
let remainingLineContent = line
let currentSegmentStartChar = 0
while (remainingLineContent.length > 0) {
const segment = remainingLineContent.substring(0, MAX_BLOCK_CHARS)
remainingLineContent = remainingLineContent.substring(MAX_BLOCK_CHARS)
createSegmentBlock(segment, originalLineNumber, currentSegmentStartChar)
currentSegmentStartChar += MAX_BLOCK_CHARS
}
continue
}
// Handle normally sized lines
if (currentChunkLength > 0 && currentChunkLength + lineLength > effectiveMaxChars) {
// Re-balancing Logic
let splitIndex = i - 1
let remainderLength = 0
for (let j = i; j < lines.length; j++) {
remainderLength += lines[j].length + (j < lines.length - 1 ? 1 : 0)
}
if (
currentChunkLength >= MIN_BLOCK_CHARS &&
remainderLength < MIN_CHUNK_REMAINDER_CHARS &&
currentChunkLines.length > 1
) {
for (let k = i - 2; k >= chunkStartLineIndex; k--) {
const potentialChunkLines = lines.slice(chunkStartLineIndex, k + 1)
const potentialChunkLength = potentialChunkLines.join("\n").length + 1
const potentialNextChunkLines = lines.slice(k + 1)
const potentialNextChunkLength = potentialNextChunkLines.join("\n").length + 1
if (
potentialChunkLength >= MIN_BLOCK_CHARS &&
potentialNextChunkLength >= MIN_CHUNK_REMAINDER_CHARS
) {
splitIndex = k
break
}
}
}
finalizeChunk(splitIndex)
if (i >= chunkStartLineIndex) {
currentChunkLines.push(line)
currentChunkLength += lineLength
} else {
i = chunkStartLineIndex - 1
continue
}
} else {
currentChunkLines.push(line)
currentChunkLength += lineLength
}
}
// Process the last remaining chunk
if (currentChunkLines.length > 0) {
finalizeChunk(lines.length - 1)
}
return chunks
}
private _performFallbackChunking(
filePath: string,
content: string,
fileHash: string,
seenSegmentHashes: Set<string>,
): CodeBlock[] {
const lines = content.split("\n")
return this._chunkTextByLines(lines, filePath, fileHash, "fallback_chunk", seenSegmentHashes)
}
private _chunkLeafNodeByLines(
node: treeSitter.SyntaxNode,
filePath: string,
fileHash: string,
seenSegmentHashes: Set<string>,
): CodeBlock[] {
const lines = node.text.split("\n")
const baseStartLine = node.startPosition.row + 1
return this._chunkTextByLines(
lines,
filePath,
fileHash,
node.type, // Use the node's type
seenSegmentHashes,
baseStartLine,
)
}
}
// Export a singleton instance for convenience
export const codeParser = new CodeParser()