Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 10 additions & 1 deletion src/services/glob/list-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number alert! Could we extract this to a named constant for clarity?

Suggested change
if (output.length > MAX_BUFFER_SIZE || output.split("\n").length > 100) {
const MAX_LINES_BEFORE_PROCESSING = 100;
if (output.length > MAX_BUFFER_SIZE || output.split("
").length > MAX_LINES_BEFORE_PROCESSING) {

processRipgrepOutput()
}

// Kill the process if we've reached the limit
if (results.length >= limit) {
Expand Down Expand Up @@ -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
}
}
Expand Down
127 changes: 127 additions & 0 deletions src/utils/__tests__/projectDetection.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
86 changes: 86 additions & 0 deletions src/utils/projectDetection.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance consideration: This function is called for every file listing operation and performs multiple file system operations. Would it make sense to cache the result for the session? Something like:

Suggested change
export async function isSwiftProject(dirPath: string): Promise<boolean> {
const projectTypeCache = new Map<string, boolean>();
export async function isSwiftProject(dirPath: string): Promise<boolean> {
if (projectTypeCache.has(dirPath)) {
return projectTypeCache.get(dirPath)!;
}
try {
// ... existing detection logic ...
const result = /* detection result */;
projectTypeCache.set(dirPath, result);
return result;

try {
// Check for common Swift project indicators
const swiftIndicators = [
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this list comprehensive enough? We might be missing:

  • Swift Playgrounds (*.playground)
  • Projects using Bazel (BUILD.bazel with swift_library rules)
  • Projects using Buck (BUCK files)
  • SwiftPM's Package.resolved

Would it be worth expanding the detection patterns?

"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("*", "")

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This replaces only the first occurrence of "*".

Copilot Autofix

AI 3 months ago

To properly address the issue and avoid incomplete pattern replacement when handling globs in swiftIndicators, we should replace all occurrences of * in the indicator string, not just the first one. This can be done by passing a regular expression with the global (g) flag to replace:

Change:

const pattern = indicator.replace("*", "")

to:

const pattern = indicator.replace(/\*/g, "")

No new methods or complex imports are needed; this change can be made in place. Ensure the edit is only made at the highlighted line in src/utils/projectDetection.ts.


Suggested changeset 1
src/utils/projectDetection.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/utils/projectDetection.ts b/src/utils/projectDetection.ts
--- a/src/utils/projectDetection.ts
+++ b/src/utils/projectDetection.ts
@@ -28,7 +28,7 @@
 			// Check for pattern matches (e.g., *.xcodeproj)
 			for (const indicator of swiftIndicators) {
 				if (indicator.includes("*")) {
-					const pattern = indicator.replace("*", "")
+					const pattern = indicator.replace(/\*/g, "")
 					if (entry.endsWith(pattern)) {
 						return true
 					}
EOF
@@ -28,7 +28,7 @@
// Check for pattern matches (e.g., *.xcodeproj)
for (const indicator of swiftIndicators) {
if (indicator.includes("*")) {
const pattern = indicator.replace("*", "")
const pattern = indicator.replace(/\*/g, "")
if (entry.endsWith(pattern)) {
return true
}
Copilot is powered by AI and may make mistakes. Always verify output.
Unable to commit as this autofix suggestion is now outdated
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}`)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the extension's logging system instead of console.error for consistency with the rest of the codebase?

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<number> {
// 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hard-coded limit of 100 files might be too restrictive for some Swift projects or too generous for others. Could we make this configurable through settings? Perhaps:

Suggested change
return Math.min(defaultLimit, 100)
const SWIFT_PROJECT_FILE_LIMIT = 100; // Or from configuration
return Math.min(defaultLimit, SWIFT_PROJECT_FILE_LIMIT);

}

return defaultLimit
}
Loading