Extract code for read_file from Cline (#2059)

This commit is contained in:
Matt Rubens 2025-03-28 11:47:47 -04:00 committed by GitHub
parent 62a7bc7959
commit b46f6adb78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 374 additions and 391 deletions

View file

@ -30,6 +30,7 @@ import {
} from "../integrations/misc/extract-text"
import { countFileLines } from "../integrations/misc/line-counter"
import { fetchInstructionsTool } from "./tools/fetchInstructionsTool"
import { readFileTool } from "./tools/readFileTool"
import { ExitCodeDetails } from "../integrations/terminal/TerminalProcess"
import { Terminal } from "../integrations/terminal/Terminal"
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
@ -82,9 +83,7 @@ import { insertGroups } from "./diff/insert-groups"
import { telemetryService } from "../services/telemetry/TelemetryService"
import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
import { parseXml } from "../utils/xml"
import { readLines } from "../integrations/misc/read-lines"
import { getWorkspacePath } from "../utils/path"
import { isBinaryFile } from "isbinaryfile"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@ -2256,151 +2255,8 @@ export class Cline extends EventEmitter<ClineEvents> {
}
case "read_file": {
const relPath: string | undefined = block.params.path
const startLineStr: string | undefined = block.params.start_line
const endLineStr: string | undefined = block.params.end_line
// Get the full path and determine if it's outside the workspace
const fullPath = relPath ? path.resolve(this.cwd, removeClosingTag("path", relPath)) : ""
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
const sharedMessageProps: ClineSayTool = {
tool: "readFile",
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
isOutsideWorkspace,
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: undefined,
} satisfies ClineSayTool)
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("read_file", "path"))
break
}
// Check if we're doing a line range read
let isRangeRead = false
let startLine: number | undefined = undefined
let endLine: number | undefined = undefined
// Check if we have either range parameter
if (startLineStr || endLineStr) {
isRangeRead = true
}
// Parse start_line if provided
if (startLineStr) {
startLine = parseInt(startLineStr)
if (isNaN(startLine)) {
// Invalid start_line
this.consecutiveMistakeCount++
await this.say("error", `Failed to parse start_line: ${startLineStr}`)
pushToolResult(formatResponse.toolError("Invalid start_line value"))
break
}
startLine -= 1 // Convert to 0-based index
}
// Parse end_line if provided
if (endLineStr) {
endLine = parseInt(endLineStr)
if (isNaN(endLine)) {
// Invalid end_line
this.consecutiveMistakeCount++
await this.say("error", `Failed to parse end_line: ${endLineStr}`)
pushToolResult(formatResponse.toolError("Invalid end_line value"))
break
}
// Convert to 0-based index
endLine -= 1
}
const accessAllowed = this.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await this.say("rooignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
break
}
this.consecutiveMistakeCount = 0
const absolutePath = path.resolve(this.cwd, relPath)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: absolutePath,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
// Get the maxReadFileLine setting
const { maxReadFileLine = 500 } = (await this.providerRef.deref()?.getState()) ?? {}
// Count total lines in the file
let totalLines = 0
try {
totalLines = await countFileLines(absolutePath)
} catch (error) {
console.error(`Error counting lines in file ${absolutePath}:`, error)
}
// now execute the tool like normal
let content: string
let isFileTruncated = false
let sourceCodeDef = ""
const isBinary = await isBinaryFile(absolutePath).catch(() => false)
if (isRangeRead) {
if (startLine === undefined) {
content = addLineNumbers(await readLines(absolutePath, endLine, startLine))
} else {
content = addLineNumbers(
await readLines(absolutePath, endLine, startLine),
startLine + 1,
)
}
} else if (!isBinary && maxReadFileLine >= 0 && totalLines > maxReadFileLine) {
// If file is too large, only read the first maxReadFileLine lines
isFileTruncated = true
const res = await Promise.all([
maxReadFileLine > 0 ? readLines(absolutePath, maxReadFileLine - 1, 0) : "",
parseSourceCodeDefinitionsForFile(absolutePath, this.rooIgnoreController),
])
content = res[0].length > 0 ? addLineNumbers(res[0]) : ""
const result = res[1]
if (result) {
sourceCodeDef = `\n\n${result}`
}
} else {
// Read entire file
content = await extractTextFromFile(absolutePath)
}
// Add truncation notice if applicable
if (isFileTruncated) {
content += `\n\n[Showing only ${maxReadFileLine} of ${totalLines} total lines. Use start_line and end_line if you need to read more]${sourceCodeDef}`
}
pushToolResult(content)
break
}
} catch (error) {
await handleError("reading file", error)
break
}
readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
}
case "fetch_instructions": {

View file

@ -1,5 +1,3 @@
const DEBUG = false
import * as path from "path"
import { countFileLines } from "../../integrations/misc/line-counter"
import { readLines } from "../../integrations/misc/read-lines"
@ -8,7 +6,6 @@ import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
import { isBinaryFile } from "isbinaryfile"
import { ReadFileToolUse } from "../assistant-message"
import { Cline } from "../Cline"
import { ClineProvider } from "../webview/ClineProvider"
// Mock dependencies
jest.mock("../../integrations/misc/line-counter")
@ -21,7 +18,7 @@ jest.mock("../ignore/RooIgnoreController", () => ({
initialize() {
return Promise.resolve()
}
validateAccess(filePath: string) {
validateAccess() {
return true
}
},
@ -45,281 +42,240 @@ jest.mock("path", () => {
})
describe("read_file tool with maxReadFileLine setting", () => {
// Mock original implementation first to use in tests
const originalCountFileLines = jest.requireActual("../../integrations/misc/line-counter").countFileLines
const originalReadLines = jest.requireActual("../../integrations/misc/read-lines").readLines
const originalExtractTextFromFile = jest.requireActual("../../integrations/misc/extract-text").extractTextFromFile
const originalAddLineNumbers = jest.requireActual("../../integrations/misc/extract-text").addLineNumbers
const originalParseSourceCodeDefinitionsForFile =
jest.requireActual("../../services/tree-sitter").parseSourceCodeDefinitionsForFile
const originalIsBinaryFile = jest.requireActual("isbinaryfile").isBinaryFile
let cline: Cline
let mockProvider: any
// Test data
const testFilePath = "test/file.txt"
const absoluteFilePath = "/home/ewheeler/src/roo/roo-main/test/file.txt"
const absoluteFilePath = "/test/file.txt"
const fileContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
const numberedFileContent = "1 | Line 1\n2 | Line 2\n3 | Line 3\n4 | Line 4\n5 | Line 5"
const sourceCodeDef = "\n\n# file.txt\n1--5 | Content"
// Mocked functions with correct types
const mockedCountFileLines = countFileLines as jest.MockedFunction<typeof countFileLines>
const mockedReadLines = readLines as jest.MockedFunction<typeof readLines>
const mockedExtractTextFromFile = extractTextFromFile as jest.MockedFunction<typeof extractTextFromFile>
const mockedAddLineNumbers = addLineNumbers as jest.MockedFunction<typeof addLineNumbers>
const mockedParseSourceCodeDefinitionsForFile = parseSourceCodeDefinitionsForFile as jest.MockedFunction<
typeof parseSourceCodeDefinitionsForFile
>
const mockedIsBinaryFile = isBinaryFile as jest.MockedFunction<typeof isBinaryFile>
const mockedPathResolve = path.resolve as jest.MockedFunction<typeof path.resolve>
// Mock instances
const mockCline: any = {}
let mockProvider: any
let toolResult: string | undefined
beforeEach(() => {
jest.resetAllMocks()
jest.clearAllMocks()
// Reset mocks to simulate original behavior
;(countFileLines as jest.Mock).mockImplementation(originalCountFileLines)
;(readLines as jest.Mock).mockImplementation(originalReadLines)
;(extractTextFromFile as jest.Mock).mockImplementation(originalExtractTextFromFile)
;(parseSourceCodeDefinitionsForFile as jest.Mock).mockImplementation(originalParseSourceCodeDefinitionsForFile)
;(isBinaryFile as jest.Mock).mockImplementation(originalIsBinaryFile)
// Setup path resolution
mockedPathResolve.mockReturnValue(absoluteFilePath)
// Default mock implementations
;(countFileLines as jest.Mock).mockResolvedValue(5)
;(readLines as jest.Mock).mockResolvedValue(fileContent)
;(extractTextFromFile as jest.Mock).mockResolvedValue(numberedFileContent)
// Use the real addLineNumbers function
;(addLineNumbers as jest.Mock).mockImplementation(originalAddLineNumbers)
;(parseSourceCodeDefinitionsForFile as jest.Mock).mockResolvedValue(sourceCodeDef)
;(isBinaryFile as jest.Mock).mockResolvedValue(false)
// Setup mocks for file operations
mockedIsBinaryFile.mockResolvedValue(false)
mockedAddLineNumbers.mockImplementation((content: string, startLine = 1) => {
return content
.split("\n")
.map((line, i) => `${i + startLine} | ${line}`)
.join("\n")
})
// Add spy to debug the readLines calls
const readLinesSpy = jest.spyOn(require("../../integrations/misc/read-lines"), "readLines")
// Mock path.resolve to return a predictable path
;(path.resolve as jest.Mock).mockReturnValue(absoluteFilePath)
// Create mock provider
// Setup mock provider
mockProvider = {
getState: jest.fn(),
deref: jest.fn().mockReturnThis(),
}
// Create a Cline instance with the necessary configuration
cline = new Cline({
provider: mockProvider,
apiConfiguration: { apiProvider: "anthropic" } as any,
task: "Test read_file tool", // Required to satisfy constructor check
startTask: false, // Prevent actual task initialization
})
// Setup Cline instance with mock methods
mockCline.cwd = "/"
mockCline.task = "Test"
mockCline.providerRef = mockProvider
mockCline.rooIgnoreController = {
validateAccess: jest.fn().mockReturnValue(true),
}
mockCline.say = jest.fn().mockResolvedValue(undefined)
mockCline.ask = jest.fn().mockResolvedValue(true)
mockCline.presentAssistantMessage = jest.fn()
// Set up the read_file tool use
const readFileToolUse: ReadFileToolUse = {
// Reset tool result
toolResult = undefined
})
/**
* Helper function to execute the read file tool with different maxReadFileLine settings
*/
async function executeReadFileTool(maxReadFileLine: number, totalLines = 5): Promise<string | undefined> {
// Configure mocks based on test scenario
mockProvider.getState.mockResolvedValue({ maxReadFileLine })
mockedCountFileLines.mockResolvedValue(totalLines)
// Create a tool use object
const toolUse: ReadFileToolUse = {
type: "tool_use",
name: "read_file",
params: {
path: testFilePath,
},
params: { path: testFilePath },
partial: false,
}
// Set up the Cline instance for testing
const clineAny = cline as any
// Import the tool implementation dynamically to avoid hoisting issues
const { readFileTool } = require("../tools/readFileTool")
// Set up the required properties for the test
clineAny.assistantMessageContent = [readFileToolUse]
clineAny.currentStreamingContentIndex = 0
clineAny.userMessageContent = []
clineAny.presentAssistantMessageLocked = false
clineAny.didCompleteReadingStream = true
clineAny.didRejectTool = false
clineAny.didAlreadyUseTool = false
// Execute the tool
await readFileTool(
mockCline,
toolUse,
mockCline.ask,
jest.fn(),
(result: string) => {
toolResult = result
},
(param: string, value: string) => value,
)
// Mock methods that would be called during presentAssistantMessage
clineAny.say = jest.fn().mockResolvedValue(undefined)
clineAny.ask = jest.fn().mockImplementation((type, message) => {
return Promise.resolve({ response: "yesButtonClicked" })
return toolResult
}
describe("when maxReadFileLine is negative", () => {
it("should read the entire file using extractTextFromFile", async () => {
// Setup
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
// Execute
const result = await executeReadFileTool(-1)
// Verify
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
expect(mockedReadLines).not.toHaveBeenCalled()
expect(mockedParseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
expect(result).toBe(numberedFileContent)
})
})
// Helper function to get user message content
const getUserMessageContent = (clineInstance: Cline) => {
const clineAny = clineInstance as any
return clineAny.userMessageContent
}
describe("when maxReadFileLine is 0", () => {
it("should return an empty content with source code definitions", async () => {
// Setup - for maxReadFileLine = 0, the implementation won't call readLines
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
// Helper function to validate response lines
const validateResponseLines = (
responseLines: string[],
options: {
expectedLineCount: number
shouldContainLines?: number[]
shouldNotContainLines?: number[]
},
) => {
if (options.shouldContainLines) {
const contentLines = responseLines.filter((line) => line.includes("Line "))
expect(contentLines.length).toBe(options.expectedLineCount)
options.shouldContainLines.forEach((lineNum) => {
expect(contentLines[lineNum - 1]).toContain(`Line ${lineNum}`)
})
}
// Execute
const result = await executeReadFileTool(0)
if (options.shouldNotContainLines) {
options.shouldNotContainLines.forEach((lineNum) => {
expect(responseLines.some((line) => line.includes(`Line ${lineNum}`))).toBe(false)
})
}
}
// Verify
expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
expect(mockedReadLines).not.toHaveBeenCalled() // Per implementation line 141
expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
absoluteFilePath,
mockCline.rooIgnoreController,
)
expect(result).toContain("[Showing only 0 of 5 total lines")
expect(result).toContain(sourceCodeDef)
})
})
interface TestExpectations {
extractTextCalled: boolean
readLinesCalled: boolean
sourceCodeDefCalled: boolean
readLinesParams?: [string, number, number]
responseValidation: {
expectedLineCount: number
shouldContainLines?: number[]
shouldNotContainLines?: number[]
}
expectedContent?: string
truncationMessage?: string
includeSourceCodeDef?: boolean
}
describe("when maxReadFileLine is less than file length", () => {
it("should read only maxReadFileLine lines and add source code definitions", async () => {
// Setup
const content = "Line 1\nLine 2\nLine 3"
mockedReadLines.mockResolvedValue(content)
mockedParseSourceCodeDefinitionsForFile.mockResolvedValue(sourceCodeDef)
interface TestCase {
name: string
maxReadFileLine: number
setup?: () => void
expectations: TestExpectations
}
// Execute
const result = await executeReadFileTool(3)
// Test cases
const testCases: TestCase[] = [
{
name: "read entire file when maxReadFileLine is -1",
maxReadFileLine: -1,
expectations: {
extractTextCalled: true,
readLinesCalled: false,
sourceCodeDefCalled: false,
responseValidation: {
expectedLineCount: 5,
shouldContainLines: [1, 2, 3, 4, 5],
// Verify - check behavior but not specific implementation details
expect(mockedExtractTextFromFile).not.toHaveBeenCalled()
expect(mockedReadLines).toHaveBeenCalled()
expect(mockedParseSourceCodeDefinitionsForFile).toHaveBeenCalledWith(
absoluteFilePath,
mockCline.rooIgnoreController,
)
expect(result).toContain("1 | Line 1")
expect(result).toContain("2 | Line 2")
expect(result).toContain("3 | Line 3")
expect(result).toContain("[Showing only 3 of 5 total lines")
expect(result).toContain(sourceCodeDef)
})
})
describe("when maxReadFileLine equals or exceeds file length", () => {
it("should use extractTextFromFile when maxReadFileLine > totalLines", async () => {
// Setup
mockedCountFileLines.mockResolvedValue(5) // File shorter than maxReadFileLine
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
// Execute
const result = await executeReadFileTool(10, 5)
// Verify
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
expect(result).toBe(numberedFileContent)
})
it("should read with extractTextFromFile when file has few lines", async () => {
// Setup
mockedCountFileLines.mockResolvedValue(3) // File shorter than maxReadFileLine
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
// Execute
const result = await executeReadFileTool(5, 3)
// Verify
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
expect(mockedReadLines).not.toHaveBeenCalled()
expect(result).toBe(numberedFileContent)
})
})
describe("when file is binary", () => {
it("should always use extractTextFromFile regardless of maxReadFileLine", async () => {
// Setup
mockedIsBinaryFile.mockResolvedValue(true)
mockedExtractTextFromFile.mockResolvedValue(numberedFileContent)
// Execute
const result = await executeReadFileTool(3)
// Verify
expect(mockedExtractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
expect(mockedReadLines).not.toHaveBeenCalled()
expect(result).toBe(numberedFileContent)
})
})
describe("with range parameters", () => {
it("should honor start_line and end_line when provided", async () => {
// Setup
const rangeToolUse: ReadFileToolUse = {
type: "tool_use",
name: "read_file",
params: {
path: testFilePath,
start_line: "2",
end_line: "4",
},
expectedContent: numberedFileContent,
},
},
{
name: "read entire file when maxReadFileLine >= file length",
maxReadFileLine: 10,
expectations: {
extractTextCalled: true,
readLinesCalled: false,
sourceCodeDefCalled: false,
responseValidation: {
expectedLineCount: 5,
shouldContainLines: [1, 2, 3, 4, 5],
},
expectedContent: numberedFileContent,
},
},
{
name: "read zero lines and only provide line declaration definitions when maxReadFileLine is 0",
maxReadFileLine: 0,
expectations: {
extractTextCalled: false,
readLinesCalled: false,
sourceCodeDefCalled: true,
responseValidation: {
expectedLineCount: 0,
},
truncationMessage: `[Showing only 0 of 5 total lines. Use start_line and end_line if you need to read more]`,
includeSourceCodeDef: true,
},
},
{
name: "read maxReadFileLine lines and provide line declaration definitions when maxReadFileLine < file length",
maxReadFileLine: 3,
setup: () => {
jest.clearAllMocks()
;(countFileLines as jest.Mock).mockResolvedValue(5)
;(readLines as jest.Mock).mockImplementation((path, endLine, startLine = 0) => {
const lines = fileContent.split("\n")
const actualEndLine = endLine !== undefined ? Math.min(endLine, lines.length - 1) : lines.length - 1
const actualStartLine = startLine !== undefined ? Math.min(startLine, lines.length - 1) : 0
const requestedLines = lines.slice(actualStartLine, actualEndLine + 1)
return Promise.resolve(requestedLines.join("\n"))
})
},
expectations: {
extractTextCalled: false,
readLinesCalled: true,
sourceCodeDefCalled: true,
readLinesParams: [absoluteFilePath, 2, 0],
responseValidation: {
expectedLineCount: 3,
shouldContainLines: [1, 2, 3],
shouldNotContainLines: [4, 5],
},
truncationMessage: `[Showing only 3 of 5 total lines. Use start_line and end_line if you need to read more]`,
includeSourceCodeDef: true,
},
},
]
test.each(testCases)("should $name", async (testCase) => {
// Setup
if (testCase.setup) {
testCase.setup()
}
mockProvider.getState.mockResolvedValue({ maxReadFileLine: testCase.maxReadFileLine })
// Execute
await cline.presentAssistantMessage()
// Verify mock calls
if (testCase.expectations.extractTextCalled) {
expect(extractTextFromFile).toHaveBeenCalledWith(absoluteFilePath)
} else {
expect(extractTextFromFile).not.toHaveBeenCalled()
}
if (testCase.expectations.readLinesCalled) {
const params = testCase.expectations.readLinesParams
if (!params) {
throw new Error("readLinesParams must be defined when readLinesCalled is true")
partial: false,
}
expect(readLines).toHaveBeenCalledWith(...params)
} else {
expect(readLines).not.toHaveBeenCalled()
}
if (testCase.expectations.sourceCodeDefCalled) {
expect(parseSourceCodeDefinitionsForFile).toHaveBeenCalled()
} else {
expect(parseSourceCodeDefinitionsForFile).not.toHaveBeenCalled()
}
mockedReadLines.mockResolvedValue("Line 2\nLine 3\nLine 4")
// Verify response content
const userMessageContent = getUserMessageContent(cline)
// Import the tool implementation dynamically
const { readFileTool } = require("../tools/readFileTool")
if (DEBUG) {
console.log(`\n=== Test: ${testCase.name} ===`)
console.log(`maxReadFileLine: ${testCase.maxReadFileLine}`)
console.log("Response content:", JSON.stringify(userMessageContent, null, 2))
}
const responseLines = userMessageContent[1].text.split("\n")
// Execute the tool
let rangeResult: string | undefined
await readFileTool(
mockCline,
rangeToolUse,
mockCline.ask,
jest.fn(),
(result: string) => {
rangeResult = result
},
(param: string, value: string) => value,
)
if (DEBUG) {
console.log(`Number of lines in response: ${responseLines.length}`)
}
expect(userMessageContent.length).toBe(2)
expect(userMessageContent[0].text).toBe(`[read_file for '${testFilePath}'] Result:`)
if (testCase.expectations.expectedContent) {
expect(userMessageContent[1].text).toBe(testCase.expectations.expectedContent)
}
if (testCase.expectations.responseValidation) {
validateResponseLines(responseLines, testCase.expectations.responseValidation)
}
if (testCase.expectations.truncationMessage) {
expect(userMessageContent[1].text).toContain(testCase.expectations.truncationMessage)
}
if (testCase.expectations.includeSourceCodeDef) {
expect(userMessageContent[1].text).toContain(sourceCodeDef)
}
// Verify
expect(mockedReadLines).toHaveBeenCalledWith(absoluteFilePath, 3, 1) // end_line - 1, start_line - 1
expect(mockedAddLineNumbers).toHaveBeenCalledWith(expect.any(String), 2) // start with proper line numbers
})
})
})

View file

@ -0,0 +1,168 @@
import path from "path"
import { Cline } from "../Cline"
import { ClineSayTool } from "../../shared/ExtensionMessage"
import { ToolUse } from "../assistant-message"
import { formatResponse } from "../prompts/responses"
import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
import { isPathOutsideWorkspace } from "../../utils/pathUtils"
import { getReadablePath } from "../../utils/path"
import { countFileLines } from "../../integrations/misc/line-counter"
import { readLines } from "../../integrations/misc/read-lines"
import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/extract-text"
import { parseSourceCodeDefinitionsForFile } from "../../services/tree-sitter"
import { isBinaryFile } from "isbinaryfile"
export async function readFileTool(
cline: Cline,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
removeClosingTag: RemoveClosingTag,
) {
switch (true) {
default:
const relPath: string | undefined = block.params.path
const startLineStr: string | undefined = block.params.start_line
const endLineStr: string | undefined = block.params.end_line
// Get the full path and determine if it's outside the workspace
const fullPath = relPath ? path.resolve(cline.cwd, removeClosingTag("path", relPath)) : ""
const isOutsideWorkspace = isPathOutsideWorkspace(fullPath)
const sharedMessageProps: ClineSayTool = {
tool: "readFile",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
isOutsideWorkspace,
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
...sharedMessageProps,
content: undefined,
} satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
pushToolResult(await cline.sayAndCreateMissingParamError("read_file", "path"))
break
}
// Check if we're doing a line range read
let isRangeRead = false
let startLine: number | undefined = undefined
let endLine: number | undefined = undefined
// Check if we have either range parameter
if (startLineStr || endLineStr) {
isRangeRead = true
}
// Parse start_line if provided
if (startLineStr) {
startLine = parseInt(startLineStr)
if (isNaN(startLine)) {
// Invalid start_line
cline.consecutiveMistakeCount++
await cline.say("error", `Failed to parse start_line: ${startLineStr}`)
pushToolResult(formatResponse.toolError("Invalid start_line value"))
break
}
startLine -= 1 // Convert to 0-based index
}
// Parse end_line if provided
if (endLineStr) {
endLine = parseInt(endLineStr)
if (isNaN(endLine)) {
// Invalid end_line
cline.consecutiveMistakeCount++
await cline.say("error", `Failed to parse end_line: ${endLineStr}`)
pushToolResult(formatResponse.toolError("Invalid end_line value"))
break
}
// Convert to 0-based index
endLine -= 1
}
const accessAllowed = cline.rooIgnoreController?.validateAccess(relPath)
if (!accessAllowed) {
await cline.say("rooignore_error", relPath)
pushToolResult(formatResponse.toolError(formatResponse.rooIgnoreError(relPath)))
break
}
cline.consecutiveMistakeCount = 0
const absolutePath = path.resolve(cline.cwd, relPath)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: absolutePath,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
break
}
// Get the maxReadFileLine setting
const { maxReadFileLine = 500 } = (await cline.providerRef.deref()?.getState()) ?? {}
// Count total lines in the file
let totalLines = 0
try {
totalLines = await countFileLines(absolutePath)
} catch (error) {
console.error(`Error counting lines in file ${absolutePath}:`, error)
}
// now execute the tool like normal
let content: string
let isFileTruncated = false
let sourceCodeDef = ""
const isBinary = await isBinaryFile(absolutePath).catch(() => false)
if (isRangeRead) {
if (startLine === undefined) {
content = addLineNumbers(await readLines(absolutePath, endLine, startLine))
} else {
content = addLineNumbers(await readLines(absolutePath, endLine, startLine), startLine + 1)
}
} else if (!isBinary && maxReadFileLine >= 0 && totalLines > maxReadFileLine) {
// If file is too large, only read the first maxReadFileLine lines
isFileTruncated = true
const res = await Promise.all([
maxReadFileLine > 0 ? readLines(absolutePath, maxReadFileLine - 1, 0) : "",
parseSourceCodeDefinitionsForFile(absolutePath, cline.rooIgnoreController),
])
content = res[0].length > 0 ? addLineNumbers(res[0]) : ""
const result = res[1]
if (result) {
sourceCodeDef = `\n\n${result}`
}
} else {
// Read entire file
content = await extractTextFromFile(absolutePath)
}
// Add truncation notice if applicable
if (isFileTruncated) {
content += `\n\n[Showing only ${maxReadFileLine} of ${totalLines} total lines. Use start_line and end_line if you need to read more]${sourceCodeDef}`
}
pushToolResult(content)
break
}
} catch (error) {
await handleError("reading file", error)
break
}
}
}

View file

@ -1,4 +1,5 @@
import { ClineAsk, ToolProgressStatus } from "../../schemas"
import { ToolParamName } from "../assistant-message"
import { ToolResponse } from "../Cline"
export type AskApproval = (
@ -10,3 +11,5 @@ export type AskApproval = (
export type HandleError = (action: string, error: Error) => void
export type PushToolResult = (content: ToolResponse) => void
export type RemoveClosingTag = (tag: ToolParamName, content?: string) => string