import Foundation import Logger import ConversationServiceProvider import CopilotForXcodeKit import XcodeInspector public let supportedFileExtensions: Set = ["swift", "m", "mm", "h", "cpp", "c", "js", "ts", "py", "rb", "java", "applescript", "scpt", "plist", "entitlements", "md", "json", "xml", "txt", "yaml", "yml", "html", "css"] public let skipPatterns: [String] = [ ".git", ".svn", ".hg", "CVS", ".DS_Store", "Thumbs.db", "node_modules", "bower_components" ] public struct ProjectInfo { public let uri: String public let name: String } extension NSError { var isPermissionDenied: Bool { return (domain == NSCocoaErrorDomain && code == 257) || (domain == NSPOSIXErrorDomain && code == 1) } } public struct WorkspaceFile { static func isXCWorkspace(_ url: URL) -> Bool { return url.pathExtension == "xcworkspace" && FileManager.default.fileExists(atPath: url.appendingPathComponent("contents.xcworkspacedata").path) } static func isXCProject(_ url: URL) -> Bool { return url.pathExtension == "xcodeproj" && FileManager.default.fileExists(atPath: url.appendingPathComponent("project.pbxproj").path) } static func getWorkspaceByProject(_ url: URL) -> URL? { guard isXCProject(url) else { return nil } let workspaceURL = url.appendingPathComponent("project.xcworkspace") return isXCWorkspace(workspaceURL) ? workspaceURL : nil } static func getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] { var subprojectURLs: [URL] = [] do { let xml = try XMLDocument(data: data) let fileRefs = try xml.nodes(forXPath: "//FileRef") for fileRef in fileRefs { if let fileRefElement = fileRef as? XMLElement, let location = fileRefElement.attribute(forName: "location")?.stringValue { var path = "" if location.starts(with: "group:") { path = location.replacingOccurrences(of: "group:", with: "") } else if location.starts(with: "container:") { path = location.replacingOccurrences(of: "container:", with: "") } else if location.starts(with: "self:") { // Handle "self:" referece - refers to the containing project directory var workspaceURLCopy = workspaceURL workspaceURLCopy.deleteLastPathComponent() path = workspaceURLCopy.path } else { // Skip absolute paths such as absolute:/path/to/project continue } if path.hasSuffix(".xcodeproj") { path = (path as NSString).deletingLastPathComponent } let subprojectURL = path.isEmpty ? workspaceURL.deletingLastPathComponent() : workspaceURL.deletingLastPathComponent().appendingPathComponent(path) if !subprojectURLs.contains(subprojectURL) { subprojectURLs.append(subprojectURL) } } } } catch { Logger.client.error("Failed to parse workspace file: \(error)") } return subprojectURLs } static func getSubprojectURLs(in workspaceURL: URL) -> [URL] { let workspaceFile = workspaceURL.appendingPathComponent("contents.xcworkspacedata") do { let data = try Data(contentsOf: workspaceFile) return getSubprojectURLs(workspaceURL: workspaceURL, data: data) } catch let error as NSError { if error.isPermissionDenied { Logger.client.info("Permission denied for accessing file at \(workspaceFile.path)") } else { Logger.client.error("Failed to read workspace file at \(workspaceFile.path): \(error)") } return [] } } static func matchesPatterns(_ url: URL, patterns: [String]) -> Bool { let fileName = url.lastPathComponent for pattern in patterns { if fnmatch(pattern, fileName, 0) == 0 { return true } } return false } public static func getWorkspaceInfo(workspaceURL: URL) -> WorkspaceInfo? { guard let projectURL = WorkspaceXcodeWindowInspector.extractProjectURL(workspaceURL: workspaceURL, documentURL: nil) else { return nil } let workspaceInfo = WorkspaceInfo(workspaceURL: workspaceURL, projectURL: projectURL) return workspaceInfo } public static func getProjects(workspace: WorkspaceInfo) -> [ProjectInfo] { var subprojects: [ProjectInfo] = [] if isXCWorkspace(workspace.workspaceURL) { subprojects = getSubprojectURLs(in: workspace.workspaceURL).map( { projectURL in ProjectInfo(uri: projectURL.absoluteString, name: getDisplayNameOfXcodeWorkspace(url: projectURL)) }) } else { subprojects.append(ProjectInfo(uri: workspace.projectURL.absoluteString, name: getDisplayNameOfXcodeWorkspace(url: workspace.projectURL))) } return subprojects } public static func getDisplayNameOfXcodeWorkspace(url: URL) -> String { var name = url.lastPathComponent let suffixes = [".xcworkspace", ".xcodeproj", ".playground"] for suffix in suffixes { if name.hasSuffix(suffix) { name = String(name.dropLast(suffix.count)) break } } return name } private static func shouldSkipFile(_ url: URL) -> Bool { return matchesPatterns(url, patterns: skipPatterns) || isXCWorkspace(url) || isXCProject(url) } public static func isValidFile( _ url: URL, shouldExcludeFile: ((URL) -> Bool)? = nil ) throws -> Bool { if shouldSkipFile(url) { return false } let resourceValues = try url.resourceValues(forKeys: [.isRegularFileKey, .isDirectoryKey]) // Handle directories if needed if resourceValues.isDirectory == true { return false } guard resourceValues.isRegularFile == true else { return false } if supportedFileExtensions.contains(url.pathExtension.lowercased()) == false { return false } // Apply the custom file exclusion check if provided if let shouldExcludeFile = shouldExcludeFile, shouldExcludeFile(url) { return false } return true } public static func getFilesInActiveWorkspace( workspaceURL: URL, workspaceRootURL: URL, shouldExcludeFile: ((URL) -> Bool)? = nil ) -> [FileReference] { var files: [FileReference] = [] do { let fileManager = FileManager.default var subprojects: [URL] = [] if isXCWorkspace(workspaceURL) { subprojects = getSubprojectURLs(in: workspaceURL) } else { subprojects.append(workspaceRootURL) } for subproject in subprojects { guard FileManager.default.fileExists(atPath: subproject.path) else { continue } let enumerator = fileManager.enumerator( at: subproject, includingPropertiesForKeys: [.isRegularFileKey, .isDirectoryKey], options: [.skipsHiddenFiles] ) while let fileURL = enumerator?.nextObject() as? URL { // Skip items matching the specified pattern if shouldSkipFile(fileURL) { enumerator?.skipDescendants() continue } guard try isValidFile(fileURL, shouldExcludeFile: shouldExcludeFile) else { continue } let relativePath = fileURL.path.replacingOccurrences(of: workspaceRootURL.path, with: "") let fileName = fileURL.lastPathComponent let file = FileReference(url: fileURL, relativePath: relativePath, fileName: fileName) files.append(file) } } } catch { Logger.client.error("Failed to get files in workspace: \(error)") } return files } /* used for `project-context` skill. Get filed for watching for syncing to CLS */ public static func getWatchedFiles( workspaceURL: URL, projectURL: URL, excludeGitIgnoredFiles: Bool, excludeIDEIgnoredFiles: Bool ) -> [String] { // Directly return for invalid workspace guard workspaceURL.path != "/" else { return [] } // TODO: implement let shouldExcludeFile: ((URL) -> Bool)? = nil let files = getFilesInActiveWorkspace( workspaceURL: workspaceURL, workspaceRootURL: projectURL, shouldExcludeFile: shouldExcludeFile ) return files.map { $0.url.absoluteString } } }