mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
563 lines
17 KiB
TypeScript
563 lines
17 KiB
TypeScript
import { readFile } from "fs/promises"
|
|
import { createHash } from "crypto"
|
|
import * as path from "path"
|
|
import { Node } from "web-tree-sitter"
|
|
import { LanguageParser, loadRequiredLanguageParsers } from "../../tree-sitter/languageParser"
|
|
import { parseMarkdown } from "../../tree-sitter/markdownParser"
|
|
import { ICodeParser, CodeBlock } from "../interfaces"
|
|
import { scannerExtensions, shouldUseFallbackChunking } from "../shared/supported-extensions"
|
|
import { MAX_BLOCK_CHARS, MIN_BLOCK_CHARS, MIN_CHUNK_REMAINDER_CHARS, MAX_CHARS_TOLERANCE_FACTOR } from "../constants"
|
|
import { TelemetryService } from "@roo-code/telemetry"
|
|
import { TelemetryEventName } from "@roo-code/types"
|
|
import { sanitizeErrorMessage } from "../shared/validation-helpers"
|
|
|
|
/**
|
|
* Implementation of the code parser interface
|
|
*/
|
|
export class CodeParser implements ICodeParser {
|
|
private loadedParsers: LanguageParser = {}
|
|
private pendingLoads: Map<string, Promise<LanguageParser>> = new Map()
|
|
private readonly maxBlockChars: number
|
|
// Markdown files are now supported using the custom markdown parser
|
|
// which extracts headers and sections for semantic indexing
|
|
|
|
/**
|
|
* Creates a new CodeParser instance
|
|
* @param maxBlockChars Maximum characters per code chunk (default: MAX_BLOCK_CHARS from constants)
|
|
*/
|
|
constructor(maxBlockChars?: number) {
|
|
this.maxBlockChars = maxBlockChars ?? MAX_BLOCK_CHARS
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
|
|
stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined,
|
|
location: "parseFile",
|
|
})
|
|
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>()
|
|
|
|
// Handle markdown files specially
|
|
if (ext === "md" || ext === "markdown") {
|
|
return this.parseMarkdownContent(filePath, content, fileHash, seenSegmentHashes)
|
|
}
|
|
|
|
// Check if this extension should use fallback chunking
|
|
if (shouldUseFallbackChunking(`.${ext}`)) {
|
|
return this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes)
|
|
}
|
|
|
|
// 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)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
|
|
stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined,
|
|
location: "parseContent:loadParser",
|
|
})
|
|
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)
|
|
TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, {
|
|
error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
|
|
stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined,
|
|
location: "parseContent:loadParser",
|
|
})
|
|
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 = tree ? 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: Node[] = Array.from(captures).map((capture) => 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 > this.maxBlockChars * MAX_CHARS_TOLERANCE_FACTOR) {
|
|
if (currentNode.children.filter((child) => child !== null).length > 0) {
|
|
// If it has children, process them instead
|
|
queue.push(...currentNode.children.filter((child) => child !== null))
|
|
} else {
|
|
// If it's a leaf node, chunk it
|
|
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 contentPreview = content.slice(0, 100)
|
|
const segmentHash = createHash("sha256")
|
|
.update(`${filePath}-${start_line}-${end_line}-${content.length}-${contentPreview}`)
|
|
.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 minBlockChars 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 = this.maxBlockChars * 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 contentPreview = chunkContent.slice(0, 100)
|
|
const segmentHash = createHash("sha256")
|
|
.update(`${filePath}-${startLine}-${endLine}-${chunkContent.length}-${contentPreview}`)
|
|
.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 segmentPreview = segment.slice(0, 100)
|
|
const segmentHash = createHash("sha256")
|
|
.update(
|
|
`${filePath}-${originalLineNumber}-${originalLineNumber}-${startCharIndex}-${segment.length}-${segmentPreview}`,
|
|
)
|
|
.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, this.maxBlockChars)
|
|
remainingLineContent = remainingLineContent.substring(this.maxBlockChars)
|
|
createSegmentBlock(segment, originalLineNumber, currentSegmentStartChar)
|
|
currentSegmentStartChar += this.maxBlockChars
|
|
}
|
|
// Update chunkStartLineIndex to continue processing from the next line
|
|
chunkStartLineIndex = i + 1
|
|
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: Node,
|
|
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,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Helper method to process markdown content sections with consistent chunking logic
|
|
*/
|
|
private processMarkdownSection(
|
|
lines: string[],
|
|
filePath: string,
|
|
fileHash: string,
|
|
type: string,
|
|
seenSegmentHashes: Set<string>,
|
|
startLine: number,
|
|
identifier: string | null = null,
|
|
): CodeBlock[] {
|
|
const content = lines.join("\n")
|
|
|
|
if (content.trim().length < MIN_BLOCK_CHARS) {
|
|
return []
|
|
}
|
|
|
|
// Check if content needs chunking (either total size or individual line size)
|
|
const needsChunking =
|
|
content.length > this.maxBlockChars * MAX_CHARS_TOLERANCE_FACTOR ||
|
|
lines.some((line) => line.length > this.maxBlockChars * MAX_CHARS_TOLERANCE_FACTOR)
|
|
|
|
if (needsChunking) {
|
|
// Apply chunking for large content or oversized lines
|
|
const chunks = this._chunkTextByLines(lines, filePath, fileHash, type, seenSegmentHashes, startLine)
|
|
// Preserve identifier in all chunks if provided
|
|
if (identifier) {
|
|
chunks.forEach((chunk) => {
|
|
chunk.identifier = identifier
|
|
})
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
// Create a single block for normal-sized content with no oversized lines
|
|
const endLine = startLine + lines.length - 1
|
|
const contentPreview = content.slice(0, 100)
|
|
const segmentHash = createHash("sha256")
|
|
.update(`${filePath}-${startLine}-${endLine}-${content.length}-${contentPreview}`)
|
|
.digest("hex")
|
|
|
|
if (!seenSegmentHashes.has(segmentHash)) {
|
|
seenSegmentHashes.add(segmentHash)
|
|
return [
|
|
{
|
|
file_path: filePath,
|
|
identifier,
|
|
type,
|
|
start_line: startLine,
|
|
end_line: endLine,
|
|
content,
|
|
segmentHash,
|
|
fileHash,
|
|
},
|
|
]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
private parseMarkdownContent(
|
|
filePath: string,
|
|
content: string,
|
|
fileHash: string,
|
|
seenSegmentHashes: Set<string>,
|
|
): CodeBlock[] {
|
|
const lines = content.split("\n")
|
|
const markdownCaptures = parseMarkdown(content) || []
|
|
|
|
if (markdownCaptures.length === 0) {
|
|
// No headers found, process entire content
|
|
return this.processMarkdownSection(lines, filePath, fileHash, "markdown_content", seenSegmentHashes, 1)
|
|
}
|
|
|
|
const results: CodeBlock[] = []
|
|
let lastProcessedLine = 0
|
|
|
|
// Process content before the first header
|
|
if (markdownCaptures.length > 0) {
|
|
const firstHeaderLine = markdownCaptures[0].node.startPosition.row
|
|
if (firstHeaderLine > 0) {
|
|
const preHeaderLines = lines.slice(0, firstHeaderLine)
|
|
const preHeaderBlocks = this.processMarkdownSection(
|
|
preHeaderLines,
|
|
filePath,
|
|
fileHash,
|
|
"markdown_content",
|
|
seenSegmentHashes,
|
|
1,
|
|
)
|
|
results.push(...preHeaderBlocks)
|
|
}
|
|
}
|
|
|
|
// Process markdown captures (headers and sections)
|
|
for (let i = 0; i < markdownCaptures.length; i += 2) {
|
|
const nameCapture = markdownCaptures[i]
|
|
// Ensure we don't go out of bounds when accessing the next capture
|
|
if (i + 1 >= markdownCaptures.length) break
|
|
const definitionCapture = markdownCaptures[i + 1]
|
|
|
|
if (!definitionCapture) continue
|
|
|
|
const startLine = definitionCapture.node.startPosition.row + 1
|
|
const endLine = definitionCapture.node.endPosition.row + 1
|
|
const sectionLines = lines.slice(startLine - 1, endLine)
|
|
|
|
// Extract header level for type classification
|
|
const headerMatch = nameCapture.name.match(/\.h(\d)$/)
|
|
const headerLevel = headerMatch ? parseInt(headerMatch[1]) : 1
|
|
const headerText = nameCapture.node.text
|
|
|
|
const sectionBlocks = this.processMarkdownSection(
|
|
sectionLines,
|
|
filePath,
|
|
fileHash,
|
|
`markdown_header_h${headerLevel}`,
|
|
seenSegmentHashes,
|
|
startLine,
|
|
headerText,
|
|
)
|
|
results.push(...sectionBlocks)
|
|
|
|
lastProcessedLine = endLine
|
|
}
|
|
|
|
// Process any remaining content after the last header section
|
|
if (lastProcessedLine < lines.length) {
|
|
const remainingLines = lines.slice(lastProcessedLine)
|
|
const remainingBlocks = this.processMarkdownSection(
|
|
remainingLines,
|
|
filePath,
|
|
fileHash,
|
|
"markdown_content",
|
|
seenSegmentHashes,
|
|
lastProcessedLine + 1,
|
|
)
|
|
results.push(...remainingBlocks)
|
|
}
|
|
|
|
return results
|
|
}
|
|
}
|
|
|
|
// Export a singleton instance for convenience
|
|
export const codeParser = new CodeParser()
|