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", "Preview Content", ".swiftpm" ] 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 { private static let wellKnownBundleExtensions: Set = ["app", "xcarchive"] 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 isKnownPackageFolder(_ url: URL) -> Bool { guard wellKnownBundleExtensions.contains(url.pathExtension) else { return false } let resourceValues = try? url.resourceValues(forKeys: [.isPackageKey]) return resourceValues?.isPackage == true } 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(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 getSubprojectURLs(workspaceURL: URL, data: Data) -> [URL] { do { let xml = try XMLDocument(data: data) let workspaceBaseURL = workspaceURL.deletingLastPathComponent() // Process all FileRefs and Groups recursively return processWorkspaceNodes(xml.rootElement()?.children ?? [], baseURL: workspaceBaseURL) } catch { Logger.client.error("Failed to parse workspace file: \(error)") } return [] } /// Recursively processes all nodes in a workspace file, collecting project URLs private static func processWorkspaceNodes(_ nodes: [XMLNode], baseURL: URL, currentGroupPath: String = "") -> [URL] { var results: [URL] = [] for node in nodes { guard let element = node as? XMLElement else { continue } let location = element.attribute(forName: "location")?.stringValue ?? "" if element.name == "FileRef" { if let url = resolveProjectLocation(location: location, baseURL: baseURL, groupPath: currentGroupPath), !results.contains(url) { results.append(url) } } else if element.name == "Group" { var groupPath = currentGroupPath if !location.isEmpty, let path = extractPathFromLocation(location) { groupPath = (groupPath as NSString).appendingPathComponent(path) } // Process all children of this group, passing the updated group path let childResults = processWorkspaceNodes(element.children ?? [], baseURL: baseURL, currentGroupPath: groupPath) for url in childResults { if !results.contains(url) { results.append(url) } } } } return results } /// Extracts path component from a location string private static func extractPathFromLocation(_ location: String) -> String? { for prefix in ["group:", "container:", "self:"] { if location.starts(with: prefix) { return location.replacingOccurrences(of: prefix, with: "") } } return nil } static func resolveProjectLocation(location: String, baseURL: URL, groupPath: String = "") -> URL? { var path = "" // Extract the path from the location string if let extractedPath = extractPathFromLocation(location) { path = extractedPath } else { // Unknown location format return nil } var url: URL = groupPath.isEmpty ? baseURL : baseURL.appendingPathComponent(groupPath) url = path.isEmpty ? url : url.appendingPathComponent(path) url = url.standardized // normalize “..” or “.” in the path if isXCProject(url) { // return the containing directory of the .xcodeproj file url.deleteLastPathComponent() } return url } 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 } // Commom URL skip checking public static func shouldSkipURL(_ url: URL) -> Bool { return matchesPatterns(url, patterns: skipPatterns) || isXCWorkspace(url) || isXCProject(url) || isKnownPackageFolder(url) || url.pathExtension == "xcassets" } public static func isValidFile( _ url: URL, shouldExcludeFile: ((URL) -> Bool)? = nil ) throws -> Bool { if shouldSkipURL(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 ) -> [ConversationFileReference] { var files: [ConversationFileReference] = [] 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 shouldSkipURL(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 = ConversationFileReference(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 ) -> [ConversationFileReference] { // 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 } }