diff --git a/Sources/Reporters/DirectoryTree/DirectoryTreeFactory.swift b/Sources/Reporters/DirectoryTree/DirectoryTreeFactory.swift index 5d325ee..b3ce3e0 100644 --- a/Sources/Reporters/DirectoryTree/DirectoryTreeFactory.swift +++ b/Sources/Reporters/DirectoryTree/DirectoryTreeFactory.swift @@ -33,6 +33,12 @@ struct DirectoryTreeFactory { /// The file manager to use for operations. var fileManager: FileManager = .default + /// The extraction method to use. + var nameExtraction: Directory.NameExtraction = .localized + + /// Closure to determine if a directory's contents should be skipped. + var skipDirectoryContents: @Sendable (_ path: String) -> Bool = { _ in false } + func make() throws -> DirectoryTreeNode { guard let rootNode = try nodeFrom(path: path, depth: 0) else { throw Error.rootNodeCreationFailed @@ -43,12 +49,25 @@ struct DirectoryTreeFactory { private func nodeFrom(path: String, depth: Int) throws -> DirectoryTreeNode? { guard depth < maxDepth else { return nil } - let name = fileManager.displayName(atPath: path) + let name = switch nameExtraction { + case .localized: fileManager.displayName(atPath: path) + case .raw: (path as NSString).lastPathComponent + } guard includeHiddenFiles || !name.starts(with: ".") else { return nil } + // Skip expensive operations for directories we don't want to traverse (especially beneficial on iCloud folders). + if isDirectory(atPath: path) && skipDirectoryContents(path) { + if isSymbolicLink(atPath: path) { + guard includeSymbolicLinks else { return nil } + return .symbolLink(path, name) + } else { + return .directory(path, name, []) + } + } + let type = try fileType(atPath: path) switch type { @@ -83,4 +102,19 @@ struct DirectoryTreeFactory { } return FileAttributeType(rawValue: type) } + + /// Checks if a path is a symbolic link + private func isSymbolicLink(atPath path: String) -> Bool { + (try? fileManager.destinationOfSymbolicLink(atPath: path)) != nil + } + + /// Checks if a path points to a directory. Uses fileExists which is faster than checking attributes, especially on iCloud folders. + private func isDirectory(atPath path: String) -> Bool { + var isDirectory: ObjCBool = false + if fileManager.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue { + return true + } + + return false + } } diff --git a/Sources/Reporters/DirectoryTree/DirectoryTreeReporter.swift b/Sources/Reporters/DirectoryTree/DirectoryTreeReporter.swift index 2162467..5256370 100644 --- a/Sources/Reporters/DirectoryTree/DirectoryTreeReporter.swift +++ b/Sources/Reporters/DirectoryTree/DirectoryTreeReporter.swift @@ -8,6 +8,14 @@ import Foundation public struct Directory: Sendable { + /// How to get file and folder names. + public enum NameExtraction: Sendable { + /// Uses the system's display name. Localized but can be slow on iCloud folders. + case localized + /// Gets the name directly from the path. Fast but not localized. + case raw + } + let url: URL let customisedName: String? let maxDepth: Int @@ -15,6 +23,9 @@ public struct Directory: Sendable { let includeHiddenFiles: Bool let includeSymbolicLinks: Bool let printFullPath: Bool + let nameExtraction: NameExtraction + let skipDirectoryContents: @Sendable (String) -> Bool + /// Directory/Group to be diagnosed /// - Parameters: @@ -25,13 +36,17 @@ public struct Directory: Sendable { /// - includeHiddenFiles: Whether hidden files should be captured. Defaults to `false`. /// - includeSymbolicLinks: Whether symbolic links should be captured. Defaults to `false`. /// - printFullPath: Whether the full path of the node should be printed or just the name. Defaults to `false`. + /// - nameExtraction: How to extract node names. Defaults to `.localized`. + /// - skipDirectoryContents: Closure to determine if a directory's contents should be skipped. Defaults to never skip. public init(url: URL, customisedName: String? = nil, maxDepth: Int = .max, maxLength: Int = 10, includeHiddenFiles: Bool = false, includeSymbolicLinks: Bool = false, - printFullPath: Bool = false) { + printFullPath: Bool = false, + nameExtraction: NameExtraction = .localized, + skipDirectoryContents: @escaping @Sendable (String) -> Bool = { _ in false }) { self.url = url self.customisedName = customisedName self.maxDepth = maxDepth @@ -39,6 +54,8 @@ public struct Directory: Sendable { self.includeHiddenFiles = includeHiddenFiles self.includeSymbolicLinks = includeSymbolicLinks self.printFullPath = printFullPath + self.nameExtraction = nameExtraction + self.skipDirectoryContents = skipDirectoryContents } } @@ -93,7 +110,9 @@ public struct DirectoryTreesReporter: DiagnosticsReporting { maxDepth: trunk.maxDepth, maxLength: trunk.maxLength, includeHiddenFiles: trunk.includeHiddenFiles, - includeSymbolicLinks: trunk.includeSymbolicLinks + includeSymbolicLinks: trunk.includeSymbolicLinks, + nameExtraction: trunk.nameExtraction, + skipDirectoryContents: trunk.skipDirectoryContents ).make() diagnostics.append("""