diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts index fbc1f2dc57..d352ab992e 100644 --- a/src/core/environment/getEnvironmentDetails.ts +++ b/src/core/environment/getEnvironmentDetails.ts @@ -17,6 +17,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { arePathsEqual } from "../../utils/path" import { formatResponse } from "../prompts/responses" +import { getProjectFileLimit } from "../../utils/projectDetection" import { Task } from "../task/Task" import { formatReminderSection } from "./reminder" @@ -252,7 +253,15 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo if (maxFiles === 0) { details += "(Workspace files context disabled. Use list_files to explore if needed.)" } else { - const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + // Apply project-specific file limits to prevent memory issues + const effectiveLimit = await getProjectFileLimit(cline.cwd, maxFiles) + + // If we're using a reduced limit for a Swift project, add a note + if (effectiveLimit < maxFiles) { + details += `\n(Note: File listing limited to ${effectiveLimit} files for Swift project memory optimization)` + } + + const [files, didHitLimit] = await listFiles(cline.cwd, true, effectiveLimit) const { showRooIgnoredFiles = true } = state ?? {} const result = formatResponse.formatFilesList( diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 7347515784..92cd10c701 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -627,6 +627,9 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi let output = "" let results: string[] = [] + // Use a smaller buffer size to process data more frequently and reduce memory usage + const MAX_BUFFER_SIZE = 1024 * 64 // 64KB buffer instead of unlimited + // Set timeout to avoid hanging const timeoutId = setTimeout(() => { rgProcess.kill() @@ -637,7 +640,11 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi // Process stdout data as it comes in rgProcess.stdout.on("data", (data) => { output += data.toString() - processRipgrepOutput() + + // Process output more frequently to avoid large memory buffers + if (output.length > MAX_BUFFER_SIZE || output.split("\n").length > 100) { + processRipgrepOutput() + } // Kill the process if we've reached the limit if (results.length >= limit) { @@ -691,6 +698,8 @@ async function execRipgrep(rgPath: string, args: string[], limit: number): Promi // Keep the relative path as returned by ripgrep results.push(line) } else if (results.length >= limit) { + // Clear the output buffer when we hit the limit to free memory + output = "" break } } diff --git a/src/utils/__tests__/projectDetection.spec.ts b/src/utils/__tests__/projectDetection.spec.ts new file mode 100644 index 0000000000..9640b9595a --- /dev/null +++ b/src/utils/__tests__/projectDetection.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as fs from "fs" +import * as path from "path" +import { isSwiftProject, getProjectFileLimit } from "../projectDetection" + +vi.mock("fs", () => ({ + promises: { + readdir: vi.fn(), + stat: vi.fn(), + }, +})) + +describe("projectDetection", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("isSwiftProject", () => { + it("should detect Swift project with Package.swift", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources", "Tests"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with .xcodeproj", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj", "MyApp", "MyAppTests"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with .xcworkspace", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcworkspace", "MyApp.xcodeproj", "Pods"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with Podfile", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["Podfile", "Podfile.lock", "MyApp"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with Cartfile", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["Cartfile", "Cartfile.resolved", "MyApp"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with .swift files in root", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["main.swift", "AppDelegate.swift", "README.md"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should detect Swift project with Sources directory containing Swift files", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["Sources", "Tests", "README.md"] as any) + + vi.mocked(fs.promises.stat).mockResolvedValue({ + isDirectory: () => true, + } as any) + + vi.mocked(fs.promises.readdir).mockResolvedValueOnce(["App.swift", "Model.swift"] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(true) + }) + + it("should not detect non-Swift project", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue([ + "package.json", + "node_modules", + "src", + "README.md", + ] as any) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(false) + }) + + it("should handle errors gracefully", async () => { + vi.mocked(fs.promises.readdir).mockRejectedValue(new Error("Permission denied")) + + const result = await isSwiftProject("/test/project") + expect(result).toBe(false) + }) + }) + + describe("getProjectFileLimit", () => { + it("should return reduced limit for Swift projects", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift", "Sources"] as any) + + const result = await getProjectFileLimit("/test/project", 200) + expect(result).toBe(100) + }) + + it("should return reduced limit not exceeding 100 for Swift projects", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["MyApp.xcodeproj"] as any) + + const result = await getProjectFileLimit("/test/project", 500) + expect(result).toBe(100) + }) + + it("should return default limit for non-Swift projects", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["package.json", "src"] as any) + + const result = await getProjectFileLimit("/test/project", 200) + expect(result).toBe(200) + }) + + it("should return smaller default if it's less than 100 for Swift projects", async () => { + vi.mocked(fs.promises.readdir).mockResolvedValue(["Package.swift"] as any) + + const result = await getProjectFileLimit("/test/project", 50) + expect(result).toBe(50) + }) + }) +}) diff --git a/src/utils/projectDetection.ts b/src/utils/projectDetection.ts new file mode 100644 index 0000000000..2bbe0cd6e5 --- /dev/null +++ b/src/utils/projectDetection.ts @@ -0,0 +1,86 @@ +import * as fs from "fs" +import * as path from "path" + +/** + * Detects if a directory contains a Swift project + * @param dirPath - The directory path to check + * @returns true if it's a Swift project, false otherwise + */ +export async function isSwiftProject(dirPath: string): Promise { + try { + // Check for common Swift project indicators + const swiftIndicators = [ + "Package.swift", // Swift Package Manager + "*.xcodeproj", // Xcode project + "*.xcworkspace", // Xcode workspace + "Podfile", // CocoaPods + "Cartfile", // Carthage + ] + + const entries = await fs.promises.readdir(dirPath) + + for (const entry of entries) { + // Check for exact matches + if (swiftIndicators.includes(entry)) { + return true + } + + // Check for pattern matches (e.g., *.xcodeproj) + for (const indicator of swiftIndicators) { + if (indicator.includes("*")) { + const pattern = indicator.replace("*", "") + if (entry.endsWith(pattern)) { + return true + } + } + } + } + + // Also check if there are .swift files in the root directory + const swiftFiles = entries.filter((entry) => entry.endsWith(".swift")) + if (swiftFiles.length > 0) { + return true + } + + // Check for iOS/macOS specific directories + const iosIndicators = ["Sources", "Tests", "UITests"] + for (const indicator of iosIndicators) { + const indicatorPath = path.join(dirPath, indicator) + try { + const stats = await fs.promises.stat(indicatorPath) + if (stats.isDirectory()) { + // Check if this directory contains Swift files + const subEntries = await fs.promises.readdir(indicatorPath) + const hasSwiftFiles = subEntries.some((entry) => entry.endsWith(".swift")) + if (hasSwiftFiles) { + return true + } + } + } catch { + // Directory doesn't exist, continue checking + } + } + + return false + } catch (error) { + console.error(`Error detecting Swift project: ${error}`) + return false + } +} + +/** + * Gets the recommended file limit for a project based on its type + * @param dirPath - The directory path to check + * @param defaultLimit - The default limit to use + * @returns The recommended file limit + */ +export async function getProjectFileLimit(dirPath: string, defaultLimit: number): Promise { + // For Swift projects, use a more conservative limit to prevent memory issues + if (await isSwiftProject(dirPath)) { + // Swift projects often have large dependency trees and generated files + // Use a smaller limit to prevent memory exhaustion + return Math.min(defaultLimit, 100) + } + + return defaultLimit +}