Skip to content

Commit 3e1f7b6

Browse files
committed
feat read file range
feat: add file truncation with code structure preview Add configurable line limit for large file handling with source code structure preview. This improves performance and memory usage while maintaining code readability. Key changes: Add maxReadFileLine setting (default: 4500) with UI controls Create efficient line counter utility using streams Add parseSourceCodeDefinitionsForFile for single file parsing Show truncation notice with code structure for large files Add comprehensive tests for new functionality Move file extensions to shared constant add line number pr comment Return definition ranges from tree-sitter make treesitter always add line add line number to rg search enhance tree siter output & fix test update snapshot fix test
1 parent daf9ae8 commit 3e1f7b6

File tree

22 files changed

+1299
-93
lines changed

22 files changed

+1299
-93
lines changed

src/core/Cline.ts

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,14 @@ import {
2828
stripLineNumbers,
2929
everyLineHasLineNumbers,
3030
} from "../integrations/misc/extract-text"
31+
import { countFileLines } from "../integrations/misc/line-counter"
3132
import { ExitCodeDetails } from "../integrations/terminal/TerminalProcess"
3233
import { Terminal } from "../integrations/terminal/Terminal"
3334
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
3435
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
3536
import { listFiles } from "../services/glob/list-files"
3637
import { regexSearchFiles } from "../services/ripgrep"
37-
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
38+
import { parseSourceCodeDefinitionsForFile, parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
3839
import { CheckpointStorage } from "../shared/checkpoints"
3940
import { ApiConfiguration } from "../shared/api"
4041
import { findLastIndex } from "../shared/array"
@@ -78,6 +79,7 @@ import { DiffStrategy, getDiffStrategy } from "./diff/DiffStrategy"
7879
import { insertGroups } from "./diff/insert-groups"
7980
import { telemetryService } from "../services/telemetry/TelemetryService"
8081
import { validateToolUse, isToolAllowedForMode, ToolName } from "./mode-validator"
82+
import { readLines } from "../integrations/misc/read-lines"
8183
import { getWorkspacePath } from "../utils/path"
8284

8385
type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
@@ -2225,6 +2227,8 @@ export class Cline extends EventEmitter<ClineEvents> {
22252227

22262228
case "read_file": {
22272229
const relPath: string | undefined = block.params.path
2230+
const startLineStr: string | undefined = block.params.start_line
2231+
const endLineStr: string | undefined = block.params.end_line
22282232
const sharedMessageProps: ClineSayTool = {
22292233
tool: "readFile",
22302234
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
@@ -2244,6 +2248,45 @@ export class Cline extends EventEmitter<ClineEvents> {
22442248
break
22452249
}
22462250

2251+
// Check if we're doing a line range read
2252+
let isRangeRead = false
2253+
let startLine: number | undefined = undefined
2254+
let endLine: number | undefined = undefined
2255+
2256+
// Check if we have either range parameter
2257+
if (startLineStr || endLineStr) {
2258+
isRangeRead = true
2259+
}
2260+
2261+
// Parse start_line if provided
2262+
if (startLineStr) {
2263+
startLine = parseInt(startLineStr)
2264+
if (isNaN(startLine)) {
2265+
// Invalid start_line
2266+
this.consecutiveMistakeCount++
2267+
await this.say("error", `Failed to parse start_line: ${startLineStr}`)
2268+
pushToolResult(formatResponse.toolError("Invalid start_line value"))
2269+
break
2270+
}
2271+
startLine -= 1 // Convert to 0-based index
2272+
}
2273+
2274+
// Parse end_line if provided
2275+
if (endLineStr) {
2276+
endLine = parseInt(endLineStr)
2277+
2278+
if (isNaN(endLine)) {
2279+
// Invalid end_line
2280+
this.consecutiveMistakeCount++
2281+
await this.say("error", `Failed to parse end_line: ${endLineStr}`)
2282+
pushToolResult(formatResponse.toolError("Invalid end_line value"))
2283+
break
2284+
}
2285+
2286+
// Convert to 0-based index
2287+
endLine -= 1
2288+
}
2289+
22472290
const accessAllowed = this.rooIgnoreController?.validateAccess(relPath)
22482291
if (!accessAllowed) {
22492292
await this.say("rooignore_error", relPath)
@@ -2258,12 +2301,63 @@ export class Cline extends EventEmitter<ClineEvents> {
22582301
...sharedMessageProps,
22592302
content: absolutePath,
22602303
} satisfies ClineSayTool)
2304+
22612305
const didApprove = await askApproval("tool", completeMessage)
22622306
if (!didApprove) {
22632307
break
22642308
}
2309+
2310+
// Get the maxReadFileLine setting
2311+
const { maxReadFileLine } = (await this.providerRef.deref()?.getState()) ?? {}
2312+
2313+
// Count total lines in the file
2314+
let totalLines = 0
2315+
try {
2316+
totalLines = await countFileLines(absolutePath)
2317+
} catch (error) {
2318+
console.error(`Error counting lines in file ${absolutePath}:`, error)
2319+
}
2320+
22652321
// now execute the tool like normal
2266-
const content = await extractTextFromFile(absolutePath)
2322+
let content: string
2323+
let isFileTruncated = false
2324+
let sourceCodeDef = ""
2325+
2326+
if (isRangeRead) {
2327+
// Read specific lines (startLine is guaranteed to be defined if isRangeRead is true)
2328+
console.log("Reading specific lines", startLine, endLine, startLineStr, endLineStr)
2329+
if (startLine === undefined) {
2330+
content = addLineNumbers(await readLines(absolutePath, endLine, startLine))
2331+
} else {
2332+
content = addLineNumbers(
2333+
await readLines(absolutePath, endLine, startLine),
2334+
startLine,
2335+
)
2336+
}
2337+
} else if (totalLines > maxReadFileLine) {
2338+
// If file is too large, only read the first maxReadFileLine lines
2339+
isFileTruncated = true
2340+
2341+
const res = await Promise.all([
2342+
readLines(absolutePath, maxReadFileLine - 1, 0),
2343+
parseSourceCodeDefinitionsForFile(absolutePath, this.rooIgnoreController),
2344+
])
2345+
2346+
content = addLineNumbers(res[0])
2347+
const result = res[1]
2348+
if (result) {
2349+
sourceCodeDef = `\n\n${result}`
2350+
}
2351+
} else {
2352+
// Read entire file
2353+
content = await extractTextFromFile(absolutePath)
2354+
}
2355+
2356+
// Add truncation notice if applicable
2357+
if (isFileTruncated) {
2358+
content += `\n\n[File truncated: showing ${maxReadFileLine} of ${totalLines} total lines. Use start_line and end_line if you need to read more.].${sourceCodeDef}`
2359+
}
2360+
22672361
pushToolResult(content)
22682362
break
22692363
}
@@ -2272,6 +2366,7 @@ export class Cline extends EventEmitter<ClineEvents> {
22722366
break
22732367
}
22742368
}
2369+
22752370
case "list_files": {
22762371
const relDirPath: string | undefined = block.params.path
22772372
const recursiveRaw: string | undefined = block.params.recursive
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as path from "path"
2+
import { countFileLines } from "../../integrations/misc/line-counter"
3+
import { readLines } from "../../integrations/misc/read-lines"
4+
import { extractTextFromFile, addLineNumbers } from "../../integrations/misc/extract-text"
5+
6+
// Mock the required functions
7+
jest.mock("../../integrations/misc/line-counter")
8+
jest.mock("../../integrations/misc/read-lines")
9+
jest.mock("../../integrations/misc/extract-text")
10+
11+
describe("read_file tool with maxReadFileLine setting", () => {
12+
// Mock original implementation first to use in tests
13+
const originalCountFileLines = jest.requireActual("../../integrations/misc/line-counter").countFileLines
14+
const originalReadLines = jest.requireActual("../../integrations/misc/read-lines").readLines
15+
const originalExtractTextFromFile = jest.requireActual("../../integrations/misc/extract-text").extractTextFromFile
16+
const originalAddLineNumbers = jest.requireActual("../../integrations/misc/extract-text").addLineNumbers
17+
18+
beforeEach(() => {
19+
jest.resetAllMocks()
20+
// Reset mocks to simulate original behavior
21+
;(countFileLines as jest.Mock).mockImplementation(originalCountFileLines)
22+
;(readLines as jest.Mock).mockImplementation(originalReadLines)
23+
;(extractTextFromFile as jest.Mock).mockImplementation(originalExtractTextFromFile)
24+
;(addLineNumbers as jest.Mock).mockImplementation(originalAddLineNumbers)
25+
})
26+
27+
// Test for the case when file size is smaller than maxReadFileLine
28+
it("should read entire file when line count is less than maxReadFileLine", async () => {
29+
// Mock necessary functions
30+
;(countFileLines as jest.Mock).mockResolvedValue(100)
31+
;(extractTextFromFile as jest.Mock).mockResolvedValue("Small file content")
32+
33+
// Create mock implementation that would simulate the behavior
34+
// Note: We're not testing the Cline class directly as it would be too complex
35+
// We're testing the logic flow that would happen in the read_file implementation
36+
37+
const filePath = path.resolve("/test", "smallFile.txt")
38+
const maxReadFileLine = 500
39+
40+
// Check line count
41+
const lineCount = await countFileLines(filePath)
42+
expect(lineCount).toBeLessThan(maxReadFileLine)
43+
44+
// Should use extractTextFromFile for small files
45+
if (lineCount < maxReadFileLine) {
46+
await extractTextFromFile(filePath)
47+
}
48+
49+
expect(extractTextFromFile).toHaveBeenCalledWith(filePath)
50+
expect(readLines).not.toHaveBeenCalled()
51+
})
52+
53+
// Test for the case when file size is larger than maxReadFileLine
54+
it("should truncate file when line count exceeds maxReadFileLine", async () => {
55+
// Mock necessary functions
56+
;(countFileLines as jest.Mock).mockResolvedValue(5000)
57+
;(readLines as jest.Mock).mockResolvedValue("First 500 lines of large file")
58+
;(addLineNumbers as jest.Mock).mockReturnValue("1 | First line\n2 | Second line\n...")
59+
60+
const filePath = path.resolve("/test", "largeFile.txt")
61+
const maxReadFileLine = 500
62+
63+
// Check line count
64+
const lineCount = await countFileLines(filePath)
65+
expect(lineCount).toBeGreaterThan(maxReadFileLine)
66+
67+
// Should use readLines for large files
68+
if (lineCount > maxReadFileLine) {
69+
const content = await readLines(filePath, maxReadFileLine - 1, 0)
70+
const numberedContent = addLineNumbers(content)
71+
72+
// Verify the truncation message is shown (simulated)
73+
const truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]`
74+
const fullResult = numberedContent + truncationMsg
75+
76+
expect(fullResult).toContain("File truncated")
77+
}
78+
79+
expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0)
80+
expect(addLineNumbers).toHaveBeenCalled()
81+
expect(extractTextFromFile).not.toHaveBeenCalled()
82+
})
83+
84+
// Test for the case when the file is a source code file
85+
it("should add source code file type info for large source code files", async () => {
86+
// Mock necessary functions
87+
;(countFileLines as jest.Mock).mockResolvedValue(5000)
88+
;(readLines as jest.Mock).mockResolvedValue("First 500 lines of large JavaScript file")
89+
;(addLineNumbers as jest.Mock).mockReturnValue('1 | const foo = "bar";\n2 | function test() {...')
90+
91+
const filePath = path.resolve("/test", "largeFile.js")
92+
const maxReadFileLine = 500
93+
94+
// Check line count
95+
const lineCount = await countFileLines(filePath)
96+
expect(lineCount).toBeGreaterThan(maxReadFileLine)
97+
98+
// Check if the file is a source code file
99+
const fileExt = path.extname(filePath).toLowerCase()
100+
const isSourceCode = [
101+
".js",
102+
".ts",
103+
".jsx",
104+
".tsx",
105+
".py",
106+
".java",
107+
".c",
108+
".cpp",
109+
".cs",
110+
".go",
111+
".rb",
112+
".php",
113+
".swift",
114+
".rs",
115+
].includes(fileExt)
116+
expect(isSourceCode).toBeTruthy()
117+
118+
// Should use readLines for large files
119+
if (lineCount > maxReadFileLine) {
120+
const content = await readLines(filePath, maxReadFileLine - 1, 0)
121+
const numberedContent = addLineNumbers(content)
122+
123+
// Verify the truncation message and source code message are shown (simulated)
124+
let truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]`
125+
if (isSourceCode) {
126+
truncationMsg +=
127+
"\n\nThis appears to be a source code file. Consider using list_code_definition_names to understand its structure."
128+
}
129+
const fullResult = numberedContent + truncationMsg
130+
131+
expect(fullResult).toContain("source code file")
132+
expect(fullResult).toContain("list_code_definition_names")
133+
}
134+
135+
expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0)
136+
expect(addLineNumbers).toHaveBeenCalled()
137+
})
138+
})

src/core/assistant-message/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export interface ExecuteCommandToolUse extends ToolUse {
7777

7878
export interface ReadFileToolUse extends ToolUse {
7979
name: "read_file"
80-
params: Partial<Pick<Record<ToolParamName, string>, "path">>
80+
params: Partial<Pick<Record<ToolParamName, string>, "path" | "start_line" | "end_line">>
8181
}
8282

8383
export interface WriteToFileToolUse extends ToolUse {

0 commit comments

Comments
 (0)